feat(tag): 支持从多个配置文件读取版本并交互选择
将 `read_project_version` 重构为 `read_project_versions`,支持从 Cargo.toml、package.json、pyproject.toml 等多个配置文件同时读取版本。当检测到多个版本时,提供交互式选择界面让用户决定使用哪个版本。
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,5 +26,6 @@ config.toml
|
|||||||
.qoder/
|
.qoder/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
**/agents/
|
**/agents/
|
||||||
|
adr/
|
||||||
.scratch/
|
.scratch/
|
||||||
CONTEXT.md
|
CONTEXT.md
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "quicommit"
|
name = "quicommit"
|
||||||
version = "0.5.0"
|
version = "0.5.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Sidney Zhang <zly@lyzhang.me>"]
|
authors = ["Sidney Zhang <zly@lyzhang.me>"]
|
||||||
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation"
|
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation"
|
||||||
@@ -47,6 +47,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
|||||||
# Utilities
|
# Utilities
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
|
roxmltree = "0.20"
|
||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
colored = "2.1"
|
colored = "2.1"
|
||||||
handlebars = "5.1"
|
handlebars = "5.1"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a
|
|||||||
- **AI-Powered Generation**: Generate commits, tags, and changelogs using LLM APIs (Ollama, OpenAI, Anthropic, Kimi, DeepSeek, OpenRouter) or local models
|
- **AI-Powered Generation**: Generate commits, tags, and changelogs using LLM APIs (Ollama, OpenAI, Anthropic, Kimi, DeepSeek, OpenRouter) or local models
|
||||||
- **Conventional Commits**: Full support for Conventional Commits and commitlint formats
|
- **Conventional Commits**: Full support for Conventional Commits and commitlint formats
|
||||||
- **Profile Management**: Manage multiple Git identities with SSH keys and GPG signing support
|
- **Profile Management**: Manage multiple Git identities with SSH keys and GPG signing support
|
||||||
- **Smart Tagging**: Semantic version bumping with AI-generated release notes
|
- **Smart Tagging**: Semantic version bumping with AI-generated release notes; auto-detects version from 16+ project config files (Cargo.toml, package.json, pom.xml, pubspec.yaml, etc.)
|
||||||
- **Changelog Generation**: Automatic changelog generation in Keep a Changelog format
|
- **Changelog Generation**: Automatic changelog generation in Keep a Changelog format
|
||||||
- **Security**: Use system keyring to store API keys securely
|
- **Security**: Use system keyring to store API keys securely
|
||||||
- **Interactive UI**: Beautiful CLI with previews and confirmations
|
- **Interactive UI**: Beautiful CLI with previews and confirmations
|
||||||
@@ -87,7 +87,8 @@ When staging changes (auto-stage or `--all`), files matched by `.gitignore` rule
|
|||||||
# Auto-detect version bump
|
# Auto-detect version bump
|
||||||
quicommit tag
|
quicommit tag
|
||||||
|
|
||||||
# Auto-detect version from Cargo.toml/pyproject.toml, fall back to commit analysis
|
# Auto-detect version from project config files (Cargo.toml, package.json, pom.xml, etc.),
|
||||||
|
# fall back to commit analysis
|
||||||
quicommit tag --auto
|
quicommit tag --auto
|
||||||
|
|
||||||
# Specify bump type
|
# Specify bump type
|
||||||
@@ -338,7 +339,7 @@ quicommit config reset --force
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `-n, --name` | Tag name |
|
| `-n, --name` | Tag name |
|
||||||
| `-b, --bump` | Version bump (major/minor/patch) |
|
| `-b, --bump` | Version bump (major/minor/patch) |
|
||||||
| `-A, --auto` | Auto-detect version from Cargo.toml/pyproject.toml, fall back to commit analysis (conflicts with `--bump`) |
|
| `-A, --auto` | Auto-detect version from project config files (Cargo.toml, package.json, pom.xml, etc.), fall back to commit analysis (conflicts with `--bump`) |
|
||||||
| `-m, --message` | Tag message |
|
| `-m, --message` | Tag message |
|
||||||
| `-g, --generate` | AI-generate message |
|
| `-g, --generate` | AI-generate message |
|
||||||
| `-S, --sign` | GPG sign tag |
|
| `-S, --sign` | GPG sign tag |
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
- **AI智能生成**:使用LLM API(Ollama本地、OpenAI、Anthropic Claude、Kimi、DeepSeek、OpenRouter)生成提交信息、标签和变更日志
|
- **AI智能生成**:使用LLM API(Ollama本地、OpenAI、Anthropic Claude、Kimi、DeepSeek、OpenRouter)生成提交信息、标签和变更日志
|
||||||
- **规范化提交**:支持Conventional Commits和commitlint格式规范
|
- **规范化提交**:支持Conventional Commits和commitlint格式规范
|
||||||
- **多配置管理**:为不同场景管理多个Git身份,支持SSH密钥和GPG签名配置
|
- **多配置管理**:为不同场景管理多个Git身份,支持SSH密钥和GPG签名配置
|
||||||
- **智能标签管理**:基于语义版本自动检测升级,AI生成标签信息
|
- **智能标签管理**:基于语义版本自动检测升级,AI生成标签信息;支持从 16+ 种项目配置文件(Cargo.toml、package.json、pom.xml、pubspec.yaml 等)自动读取版本
|
||||||
- **变更日志生成**:自动生成Keep a Changelog格式的变更日志
|
- **变更日志生成**:自动生成Keep a Changelog格式的变更日志
|
||||||
- **安全保护**:使用系统密钥环进行安全存储
|
- **安全保护**:使用系统密钥环进行安全存储
|
||||||
- **交互式界面**:美观的CLI界面,支持预览和确认
|
- **交互式界面**:美观的CLI界面,支持预览和确认
|
||||||
@@ -86,7 +86,8 @@ quicommit commit --push
|
|||||||
# 自动检测版本升级
|
# 自动检测版本升级
|
||||||
quicommit tag
|
quicommit tag
|
||||||
|
|
||||||
# 从 Cargo.toml/pyproject.toml 自动检测版本,回退到基于 commit 的分析
|
# 从项目配置文件(Cargo.toml、package.json、pom.xml 等)自动检测版本,
|
||||||
|
# 回退到基于 commit 的分析
|
||||||
quicommit tag --auto
|
quicommit tag --auto
|
||||||
|
|
||||||
# 指定版本升级类型
|
# 指定版本升级类型
|
||||||
@@ -332,7 +333,7 @@ quicommit config reset --force
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `-n, --name` | 标签名称 |
|
| `-n, --name` | 标签名称 |
|
||||||
| `-b, --bump` | 版本升级类型(major/minor/patch) |
|
| `-b, --bump` | 版本升级类型(major/minor/patch) |
|
||||||
| `-A, --auto` | 从 Cargo.toml/pyproject.toml 自动检测版本,回退到基于 commit 的分析(与 `--bump` 互斥) |
|
| `-A, --auto` | 从项目配置文件(Cargo.toml、package.json、pom.xml 等 16+ 种)自动检测版本,回退到基于 commit 的分析(与 `--bump` 互斥) |
|
||||||
| `-m, --message` | 标签信息 |
|
| `-m, --message` | 标签信息 |
|
||||||
| `-g, --generate` | AI生成标签信息 |
|
| `-g, --generate` | AI生成标签信息 |
|
||||||
| `-S, --sign` | GPG签名标签 |
|
| `-S, --sign` | GPG签名标签 |
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use std::path::PathBuf;
|
|||||||
use crate::config::{Language, manager::ConfigManager};
|
use crate::config::{Language, manager::ConfigManager};
|
||||||
use crate::generator::ContentGenerator;
|
use crate::generator::ContentGenerator;
|
||||||
use crate::git::tag::{
|
use crate::git::tag::{
|
||||||
TagBuilder, VersionBump, bump_version, get_latest_version, read_project_version,
|
ConfigVersion, TagBuilder, VersionBump, bump_version, get_latest_version,
|
||||||
suggest_version_bump,
|
read_project_versions, suggest_version_bump,
|
||||||
};
|
};
|
||||||
use crate::git::{GitRepo, find_repo};
|
use crate::git::{GitRepo, find_repo};
|
||||||
use crate::i18n::Messages;
|
use crate::i18n::Messages;
|
||||||
@@ -343,16 +343,25 @@ impl TagCommand {
|
|||||||
let project_dir = std::env::current_dir()?;
|
let project_dir = std::env::current_dir()?;
|
||||||
|
|
||||||
// 1. Try reading from project config files
|
// 1. Try reading from project config files
|
||||||
if let Some(version) = read_project_version(&project_dir) {
|
let config_versions = read_project_versions(&project_dir);
|
||||||
let tag_name = format!("{}{}", prefix, version);
|
|
||||||
|
if !config_versions.is_empty() {
|
||||||
|
if config_versions.len() == 1 {
|
||||||
|
let cv = &config_versions[0];
|
||||||
|
let tag_name = format!("{}{}", prefix, cv.version);
|
||||||
println!(
|
println!(
|
||||||
"{} {}",
|
"{} ({}): {}",
|
||||||
messages.latest_version(),
|
messages.found_version_in(),
|
||||||
|
cv.source,
|
||||||
tag_name.cyan()
|
tag_name.cyan()
|
||||||
);
|
);
|
||||||
return Ok(tag_name);
|
return Ok(tag_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multiple config files: let user choose
|
||||||
|
return self.select_config_version(&config_versions, prefix, messages);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Fall back to commit analysis
|
// 2. Fall back to commit analysis
|
||||||
println!("{}", messages.auto_detect_bump());
|
println!("{}", messages.auto_detect_bump());
|
||||||
let commits = repo.get_commits(50)?;
|
let commits = repo.get_commits(50)?;
|
||||||
@@ -385,6 +394,29 @@ impl TagCommand {
|
|||||||
Ok(tag_name)
|
Ok(tag_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn select_config_version(
|
||||||
|
&self,
|
||||||
|
config_versions: &[ConfigVersion],
|
||||||
|
prefix: &str,
|
||||||
|
messages: &Messages,
|
||||||
|
) -> Result<String> {
|
||||||
|
println!("\n{}", messages.found_multiple_versions().bold());
|
||||||
|
|
||||||
|
let items: Vec<String> = config_versions
|
||||||
|
.iter()
|
||||||
|
.map(|cv| format!("{} → {}{}", cv.source, prefix, cv.version))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let selection = Select::new()
|
||||||
|
.with_prompt(messages.select_version_to_use())
|
||||||
|
.items(&items)
|
||||||
|
.default(0)
|
||||||
|
.interact()?;
|
||||||
|
|
||||||
|
let cv = &config_versions[selection];
|
||||||
|
Ok(format!("{}{}", prefix, cv.version))
|
||||||
|
}
|
||||||
|
|
||||||
fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
|
fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
|
||||||
let default_msg = format!("Release {}", version);
|
let default_msg = format!("Release {}", version);
|
||||||
|
|
||||||
|
|||||||
355
src/git/tag.rs
355
src/git/tag.rs
@@ -350,48 +350,216 @@ pub fn sort_tags_by_semver(tags: &mut [super::TagInfo]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the project version from Cargo.toml or pyproject.toml.
|
/// A version found in a project config file, tagged with its source.
|
||||||
/// Returns None if no project config file is found or version cannot be read.
|
#[derive(Debug, Clone)]
|
||||||
pub fn read_project_version(project_dir: &Path) -> Option<Version> {
|
pub struct ConfigVersion {
|
||||||
// Try Cargo.toml first
|
/// Source file name, e.g. "Cargo.toml", "package.json"
|
||||||
let cargo_path = project_dir.join("Cargo.toml");
|
pub source: String,
|
||||||
if cargo_path.exists() {
|
/// Parsed semantic version
|
||||||
if let Ok(content) = std::fs::read_to_string(&cargo_path) {
|
pub version: Version,
|
||||||
if let Ok(value) = content.parse::<toml::Value>() {
|
}
|
||||||
if let Some(version) = value
|
|
||||||
.get("package")
|
/// Read project versions from all supported config files in the project root.
|
||||||
.and_then(|p| p.get("version"))
|
///
|
||||||
.and_then(|v| v.as_str())
|
/// If `go.mod` exists the function returns an empty `Vec` immediately — Go projects
|
||||||
{
|
/// use git tags as the version authority and store nothing in go.mod.
|
||||||
if let Ok(ver) = Version::parse(version) {
|
///
|
||||||
return Some(ver);
|
/// Otherwise every supported format is tried. Results are returned in a stable
|
||||||
}
|
/// order (see `PARSER_ORDER`). Files that don't exist or whose version can't be
|
||||||
}
|
/// parsed are silently skipped.
|
||||||
|
pub fn read_project_versions(project_dir: &Path) -> Vec<ConfigVersion> {
|
||||||
|
// Go projects: go.mod has no version field; skip directly to commit analysis
|
||||||
|
if project_dir.join("go.mod").exists() {
|
||||||
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let parsers: &[(&str, fn(&Path) -> Option<Version>)] = PARSER_ORDER;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for &(source, parser) in parsers {
|
||||||
|
if let Some(v) = parser(project_dir) {
|
||||||
|
results.push(ConfigVersion {
|
||||||
|
source: source.to_string(),
|
||||||
|
version: v,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try pyproject.toml
|
results
|
||||||
let pyproject_path = project_dir.join("pyproject.toml");
|
}
|
||||||
if pyproject_path.exists() {
|
|
||||||
if let Ok(content) = std::fs::read_to_string(&pyproject_path) {
|
|
||||||
if let Ok(value) = content.parse::<toml::Value>() {
|
|
||||||
if let Some(version) = value
|
|
||||||
.get("project")
|
|
||||||
.and_then(|p| p.get("version"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
if let Ok(ver) = Version::parse(version) {
|
|
||||||
return Some(ver);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Stable order for parser dispatch. No priority implied — all results are
|
||||||
|
/// surfaced to the user when more than one is found.
|
||||||
|
type Parser = fn(&Path) -> Option<Version>;
|
||||||
|
const PARSER_ORDER: &[(&str, Parser)] = &[
|
||||||
|
("Cargo.toml", try_cargo_toml),
|
||||||
|
("package.json", try_package_json),
|
||||||
|
("pyproject.toml", try_pyproject_toml),
|
||||||
|
("pom.xml", try_pom_xml),
|
||||||
|
("pubspec.yaml", try_pubspec_yaml),
|
||||||
|
("build.sbt", try_build_sbt),
|
||||||
|
("project.clj", try_project_clj),
|
||||||
|
("mix.exs", try_mix_exs),
|
||||||
|
("package.yaml", try_package_yaml),
|
||||||
|
("Project.toml", try_project_toml_julia),
|
||||||
|
("composer.json", try_composer_json),
|
||||||
|
(".csproj", try_csproj),
|
||||||
|
("CMakeLists.txt", try_cmake_lists),
|
||||||
|
("meson.build", try_meson_build),
|
||||||
|
(".gemspec", try_gemspec),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Structured parsers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn try_cargo_toml(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
|
||||||
|
let value = content.parse::<toml::Value>().ok()?;
|
||||||
|
let version_str = value.get("package")?.get("version")?.as_str()?;
|
||||||
|
Version::parse(version_str).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_pyproject_toml(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("pyproject.toml")).ok()?;
|
||||||
|
let value = content.parse::<toml::Value>().ok()?;
|
||||||
|
let version_str = value.get("project")?.get("version")?.as_str()?;
|
||||||
|
Version::parse(version_str).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_project_toml_julia(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("Project.toml")).ok()?;
|
||||||
|
let value = content.parse::<toml::Value>().ok()?;
|
||||||
|
let version_str = value.get("version")?.as_str()?;
|
||||||
|
Version::parse(version_str).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_package_json(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("package.json")).ok()?;
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||||
|
let version_str = value.get("version")?.as_str()?;
|
||||||
|
Version::parse(version_str).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_composer_json(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("composer.json")).ok()?;
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||||
|
let version_str = value.get("version")?.as_str()?;
|
||||||
|
Version::parse(version_str).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_pom_xml(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("pom.xml")).ok()?;
|
||||||
|
let doc = roxmltree::Document::parse(&content).ok()?;
|
||||||
|
let project = doc.root_element();
|
||||||
|
// Read <version> that is a direct child of <project>, not inside <parent>
|
||||||
|
for child in project.children() {
|
||||||
|
if child.is_element() && child.tag_name().name() == "version" {
|
||||||
|
if let Some(t) = child.text() {
|
||||||
|
return Version::parse(t.trim()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn try_csproj(dir: &Path) -> Option<Version> {
|
||||||
|
// Find any .csproj file in the root (take the first)
|
||||||
|
let csproj = std::fs::read_dir(dir)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.find(|p| p.extension().map_or(false, |ext| ext == "csproj"))?;
|
||||||
|
let content = std::fs::read_to_string(&csproj).ok()?;
|
||||||
|
let doc = roxmltree::Document::parse(&content).ok()?;
|
||||||
|
// Look for <Version> (preferred) or <VersionPrefix> anywhere in the document
|
||||||
|
for node in doc.descendants() {
|
||||||
|
if node.is_element() {
|
||||||
|
match node.tag_name().name() {
|
||||||
|
"Version" => {
|
||||||
|
if let Some(t) = node.text() {
|
||||||
|
return Version::parse(t.trim()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"VersionPrefix" => {
|
||||||
|
if let Some(t) = node.text() {
|
||||||
|
return Version::parse(t.trim()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Regex-based parsers (code-as-config or simple data formats) ─────
|
||||||
|
|
||||||
|
fn try_pubspec_yaml(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("pubspec.yaml")).ok()?;
|
||||||
|
let re =
|
||||||
|
regex::Regex::new(r##"(?m)^version\s*:\s*['"]?(\d+\.\d+\.\d+[^'"#\s]*)['"]?"##).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_package_yaml(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("package.yaml")).ok()?;
|
||||||
|
let re =
|
||||||
|
regex::Regex::new(r##"(?m)^version\s*:\s*['"]?(\d+\.\d+\.\d+[^'"#\s]*)['"]?"##).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_gemspec(dir: &Path) -> Option<Version> {
|
||||||
|
// A directory may contain multiple .gemspec files; take the first.
|
||||||
|
let gemspec = std::fs::read_dir(dir)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.find(|p| p.extension().map_or(false, |ext| ext == "gemspec"))?;
|
||||||
|
let content = std::fs::read_to_string(&gemspec).ok()?;
|
||||||
|
let re =
|
||||||
|
regex::Regex::new(r#"spec\.version\s*=\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_build_sbt(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("build.sbt")).ok()?;
|
||||||
|
let re = regex::Regex::new(r#"version\s*:=\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_project_clj(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("project.clj")).ok()?;
|
||||||
|
let re = regex::Regex::new(r#"\(defproject\s+[^\s]+\s+"(\d+\.\d+\.\d+[^"]*)""#).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_mix_exs(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("mix.exs")).ok()?;
|
||||||
|
let re = regex::Regex::new(r#"version\s*:\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_cmake_lists(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("CMakeLists.txt")).ok()?;
|
||||||
|
let re =
|
||||||
|
regex::Regex::new(r"project\s*\([^)]*VERSION\s+(\d+\.\d+\.\d+[^\s)]*)").ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_meson_build(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("meson.build")).ok()?;
|
||||||
|
let re =
|
||||||
|
regex::Regex::new(r#"version\s*:\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -471,38 +639,42 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_read_project_version_from_cargo_toml() {
|
fn test_read_versions_cargo_toml() {
|
||||||
let dir = tempfile::TempDir::new().unwrap();
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
dir.path().join("Cargo.toml"),
|
dir.path().join("Cargo.toml"),
|
||||||
"[package]\nname = \"test\"\nversion = \"0.3.0\"\n",
|
"[package]\nname = \"test\"\nversion = \"0.3.0\"\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let version = read_project_version(dir.path());
|
let versions = read_project_versions(dir.path());
|
||||||
assert_eq!(version, Some(Version::new(0, 3, 0)));
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "Cargo.toml");
|
||||||
|
assert_eq!(versions[0].version, Version::new(0, 3, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_read_project_version_from_pyproject_toml() {
|
fn test_read_versions_pyproject_toml() {
|
||||||
let dir = tempfile::TempDir::new().unwrap();
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
dir.path().join("pyproject.toml"),
|
dir.path().join("pyproject.toml"),
|
||||||
"[project]\nname = \"test\"\nversion = \"0.2.1\"\n",
|
"[project]\nname = \"test\"\nversion = \"0.2.1\"\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let version = read_project_version(dir.path());
|
let versions = read_project_versions(dir.path());
|
||||||
assert_eq!(version, Some(Version::new(0, 2, 1)));
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "pyproject.toml");
|
||||||
|
assert_eq!(versions[0].version, Version::new(0, 2, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_read_project_version_no_config_files() {
|
fn test_read_versions_no_config_files() {
|
||||||
let dir = tempfile::TempDir::new().unwrap();
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
let version = read_project_version(dir.path());
|
let versions = read_project_versions(dir.path());
|
||||||
assert_eq!(version, None);
|
assert!(versions.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_read_project_version_cargo_takes_priority() {
|
fn test_read_versions_multiple_configs_returns_all() {
|
||||||
let dir = tempfile::TempDir::new().unwrap();
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
dir.path().join("Cargo.toml"),
|
dir.path().join("Cargo.toml"),
|
||||||
@@ -514,7 +686,98 @@ mod tests {
|
|||||||
"[project]\nname = \"test\"\nversion = \"0.2.1\"\n",
|
"[project]\nname = \"test\"\nversion = \"0.2.1\"\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let version = read_project_version(dir.path());
|
let versions = read_project_versions(dir.path());
|
||||||
assert_eq!(version, Some(Version::new(0, 3, 0)));
|
assert_eq!(versions.len(), 2);
|
||||||
|
// Order is defined by PARSER_ORDER: Cargo.toml before pyproject.toml
|
||||||
|
assert_eq!(versions[0].source, "Cargo.toml");
|
||||||
|
assert_eq!(versions[0].version, Version::new(0, 3, 0));
|
||||||
|
assert_eq!(versions[1].source, "pyproject.toml");
|
||||||
|
assert_eq!(versions[1].version, Version::new(0, 2, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_package_json() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("package.json"),
|
||||||
|
r#"{"name": "test", "version": "1.2.3"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "package.json");
|
||||||
|
assert_eq!(versions[0].version, Version::new(1, 2, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_pom_xml() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("pom.xml"),
|
||||||
|
"<project><modelVersion>4.0.0</modelVersion><version>2.0.0</version></project>",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "pom.xml");
|
||||||
|
assert_eq!(versions[0].version, Version::new(2, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_pom_xml_ignores_parent_version() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("pom.xml"),
|
||||||
|
"<project><parent><version>3.0.0</version></parent><version>2.0.0</version></project>",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].version, Version::new(2, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_pubspec_yaml() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("pubspec.yaml"),
|
||||||
|
"name: myapp\nversion: 1.0.0+1\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "pubspec.yaml");
|
||||||
|
assert_eq!(
|
||||||
|
versions[0].version,
|
||||||
|
Version::parse("1.0.0+1").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_go_mod_skips_all() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("go.mod"), "module example.com/m\n").unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("Cargo.toml"),
|
||||||
|
"[package]\nname = \"test\"\nversion = \"0.3.0\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// go.mod triggers immediate return — Cargo.toml is ignored
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert!(versions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_project_toml_julia() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("Project.toml"),
|
||||||
|
"name = \"MyPkg\"\nversion = \"0.1.0\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "Project.toml");
|
||||||
|
assert_eq!(versions[0].version, Version::new(0, 1, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -930,6 +930,42 @@ impl Messages {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn found_version_in(&self) -> &str {
|
||||||
|
match self.language {
|
||||||
|
Language::English => "Found version in",
|
||||||
|
Language::Chinese => "在配置文件中找到版本",
|
||||||
|
Language::Japanese => "設定ファイルでバージョンが見つかりました",
|
||||||
|
Language::Korean => "구성 파일에서 버전을 찾음",
|
||||||
|
Language::Spanish => "Versión encontrada en",
|
||||||
|
Language::French => "Version trouvée dans",
|
||||||
|
Language::German => "Version gefunden in",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn found_multiple_versions(&self) -> &str {
|
||||||
|
match self.language {
|
||||||
|
Language::English => "Found versions in multiple config files:",
|
||||||
|
Language::Chinese => "在多个配置文件中找到版本:",
|
||||||
|
Language::Japanese => "複数の設定ファイルでバージョンが見つかりました:",
|
||||||
|
Language::Korean => "여러 구성 파일에서 버전을 찾음:",
|
||||||
|
Language::Spanish => "Versiones encontradas en múltiples archivos:",
|
||||||
|
Language::French => "Versions trouvées dans plusieurs fichiers :",
|
||||||
|
Language::German => "Versionen in mehreren Dateien gefunden:",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_version_to_use(&self) -> &str {
|
||||||
|
match self.language {
|
||||||
|
Language::English => "Select version to use",
|
||||||
|
Language::Chinese => "选择要使用的版本",
|
||||||
|
Language::Japanese => "使用するバージョンを選択",
|
||||||
|
Language::Korean => "사용할 버전 선택",
|
||||||
|
Language::Spanish => "Seleccionar versión a usar",
|
||||||
|
Language::French => "Sélectionner la version à utiliser",
|
||||||
|
Language::German => "Zu verwendende Version auswählen",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn profile_description(&self) -> &str {
|
pub fn profile_description(&self) -> &str {
|
||||||
match self.language {
|
match self.language {
|
||||||
Language::English => "Profile description (optional)",
|
Language::English => "Profile description (optional)",
|
||||||
|
|||||||
Reference in New Issue
Block a user