use quanttide_agent::llm::{CompleteOptions, LLM};
use quanttide_agent::message::Message;
use quanttide_agent::Settings;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
fn git_output(args: &[&str], repo_path: &Path) -> Result<String, String> {
let out = Command::new("git")
.args(args)
.current_dir(repo_path)
.output()
.map_err(|e| format!("git 无法执行: {}", e))?;
if !out.status.success() {
let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(if msg.is_empty() {
"git 命令失败".into()
} else {
msg
});
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub struct DetectResult {
pub version: String,
}
pub fn detect_version(repo_path: &Path) -> Result<DetectResult, String> {
let root = git_output(&["rev-parse", "--show-toplevel"], repo_path)
.map_err(|_| format!("不在 git 仓库中: {:?}", repo_path))?;
let root = Path::new(&root);
let project_type = detect_project_type(root);
println!("📌 项目类型: {}", project_type);
let scope = detect_single_scope(root)?;
println!("📌 scope: {:?}", scope);
let latest_tag = get_latest_tag_for_scope(root, scope.as_deref());
let (has_tag, major, minor, patch, pre_stage, pre_num) = match latest_tag {
Some(ref tag) => {
let (_, ver_str) = parse_tag(tag);
let (ma, mi, pa, st, nu) = parse_version(ver_str)?;
println!("📦 最新标签: {}", tag);
println!(" v{}.{}.{}", ma, mi, pa);
if let Some(ref stage) = st {
println!(" 预发布: {}.{}", stage, nu.unwrap_or(0));
}
(true, ma, mi, pa, st, nu)
}
None => {
println!("📦 没有版本标签(新项目)");
(false, 0, 1, 0, None, None)
}
};
if has_tag {
let tag = latest_tag.as_ref().unwrap();
let tag_rev = git_output(&["rev-parse", &format!("refs/tags/{}", tag)], root)
.map_err(|_| "找不到标签引用")?;
let head_rev = git_output(&["rev-parse", "HEAD"], root).map_err(|_| "找不到 HEAD")?;
if tag_rev == head_rev {
return Err("上次标签后没有新提交".into());
}
}
let range = latest_tag
.as_ref()
.map_or("HEAD".to_string(), |t| format!("{}..HEAD", t));
let log_output = git_output(&["log", "--oneline", &range], root).unwrap_or_default();
let commits: Vec<String> = log_output
.lines()
.map(|l| {
if l.len() > 8 {
l[7..].trim().to_string()
} else {
l.to_string()
}
})
.filter(|s| !s.is_empty())
.collect();
println!("📝 提交数: {}", commits.len());
for c in &commits {
println!(" • {}", c);
}
if commits.is_empty() {
return Err("没有提交记录".into());
}
let llm_tag = latest_tag.as_deref().unwrap_or("(新项目,无版本标签)");
let decision = llm_decide(
&commits,
llm_tag,
&project_type,
scope.as_deref().unwrap_or("(root)"),
)?;
println!("🧠 LLM 决策: {}", decision.reason);
let new_version = if !has_tag {
match decision.prerelease.as_deref() {
Some(pr) => format!("v0.1.0-{}.1", pr),
None => "v0.1.0".to_string(),
}
} else if decision.action == "skip" {
return Err("无需发版".into());
} else if decision.action == "human" {
return Err(format!("需要人类判断: {}", decision.reason));
} else {
let increment = decision.increment.as_deref().unwrap_or("patch");
build_version(
major,
minor,
patch,
pre_stage.as_deref(),
pre_num,
increment,
decision.prerelease.as_deref(),
)
};
let version = match scope {
Some(ref s) if !s.is_empty() && s != "(root)" => format!("{}/{}", s, new_version),
_ => new_version.clone(),
};
println!("\n🔮 建议版本: {}", version);
Ok(DetectResult { version })
}
#[derive(serde::Deserialize)]
struct LlmDecision {
action: String, increment: Option<String>, prerelease: Option<String>, reason: String,
}
fn llm_decide(
commits: &[String],
latest_tag: &str,
project_type: &str,
scope: &str,
) -> Result<LlmDecision, String> {
let settings = Settings::from_env();
if settings.llm_api_key.is_empty() {
return Ok(fallback_heuristic(commits));
}
let llm = LLM::new(
&settings.llm_model,
&settings.llm_base_url,
&settings.llm_api_key,
);
let commits_text = commits
.iter()
.enumerate()
.map(|(i, c)| format!("{}. {}", i + 1, c))
.collect::<Vec<_>>()
.join("\n");
let prompt = format!(
r#"你是一个版本号推断专家。根据以下信息,决定下一个版本号策略。
## 约束
- 不做 major bump(breaking change 交给人类)
- 仅 chore/typo/CI 配置 → skip
- `docs:` 是内容变更(文档项目的交付物),不是非逻辑改动
- patch 级别修复 → 直发正式版
- minor 级别新功能 → 代码项目走预发布(rc),文档项目直发正式
- 大版本早期未完成功能 → alpha
- 功能基本完成 → beta
- 功能冻结只修 bug → rc
- 已在预发布系列 → 同阶段递增序号(除非有理由晋级下一阶段)
### 如何判断 minor vs patch
**代码项目:**
- `feat:` → minor(追加新能力)
- `fix: / refactor: / test:` → patch(修问题)
**内容/文档项目:**
- **绝大多数变更都是 patch**。新增文档、更新内容、格式规范化、目录结构调整都是日常工作。
- minor 仅限全新内容品类上线的程度(例如从零搭建了一整套新手册),极少发生。
- 不确定时就 patch。
## 当前版本
项目类型: {project_type}
最新 tag: {tag}
scope: {scope}
## 提交记录(tag→HEAD)
{commits}
## 输出格式(仅 JSON)
{{"action": "release"|"skip"|"human", "increment": "minor"|"patch"|null, "prerelease": "alpha"|"beta"|"rc"|null, "reason": "判断理由"}}
"#,
tag = latest_tag,
scope = scope,
project_type = project_type,
commits = commits_text,
);
let messages = vec![
Message::new(
"system",
"你是一个严格的版本号推断工具。只输出 JSON,不要额外内容。",
),
Message::new("user", &prompt),
];
let options = CompleteOptions {
response_format: Some(serde_json::json!({"type": "json_object"})),
..Default::default()
};
let resp = llm
.complete(&messages, options)
.map_err(|e| format!("LLM 调用失败: {}", e.0))?;
let decision: LlmDecision = serde_json::from_str(&resp.content)
.map_err(|e| format!("LLM 输出解析失败: {} — 原始输出: {}", e, resp.content))?;
Ok(decision)
}
fn fallback_heuristic(commits: &[String]) -> LlmDecision {
let mut has_feat = false;
let mut has_breaking = false;
let mut has_logic_change = false;
for msg in commits {
let lower = msg.to_lowercase();
if lower.contains("breaking") || (msg.contains('!') && lower.starts_with("feat")) {
has_breaking = true;
has_logic_change = true;
} else if lower.starts_with("feat") || msg.contains("Added") {
has_feat = true;
has_logic_change = true;
} else if lower.starts_with("fix")
|| lower.starts_with("docs")
|| lower.starts_with("refactor")
|| lower.starts_with("test")
|| msg.contains("Fixed")
|| msg.contains("Changed")
{
has_logic_change = true;
}
}
if !has_logic_change {
return LlmDecision {
action: "skip".into(),
increment: None,
prerelease: None,
reason: "仅有 chore/typo/CI 改动,无需发版".into(),
};
}
if has_breaking {
return LlmDecision {
action: "human".into(),
increment: None,
prerelease: None,
reason: "包含 breaking change,请人类指定 major 版本号".into(),
};
}
let (increment, reason) = if has_feat {
("minor", "包含 feat,minor 增量直发正式")
} else {
("patch", "包含 docs/fix/refactor,patch 增量直发正式")
};
LlmDecision {
action: "release".into(),
increment: Some(increment.into()),
prerelease: None,
reason: reason.into(),
}
}
fn build_version(
major: u32,
minor: u32,
patch: u32,
pre_stage: Option<&str>,
pre_num: Option<u32>,
increment: &str,
prerelease: Option<&str>,
) -> String {
if let Some(stage) = pre_stage {
let next = pre_num.unwrap_or(0) + 1;
return format!("v{}.{}.{}-{}.{}", major, minor, patch, stage, next);
}
match (increment, prerelease) {
("minor", Some(pr)) => format!("v{}.{}.{}-{}.1", major, minor + 1, 0, pr),
("minor", None) => format!("v{}.{}.{}", major, minor + 1, 0),
_ => format!("v{}.{}.{}", major, minor, patch + 1),
}
}
fn detect_project_type(root: &Path) -> &'static str {
let indicators = [
root.join("src").is_dir(),
root.join("Cargo.toml").exists(),
root.join("package.json").exists(),
root.join("pyproject.toml").exists(),
root.join("setup.py").exists(),
root.join("go.mod").exists(),
root.join("packages").is_dir(),
root.join("apps").is_dir(),
];
if indicators.iter().any(|&x| x) {
"code"
} else {
"docs"
}
}
fn detect_single_scope(root: &Path) -> Result<Option<String>, String> {
let scopes = load_contract_scopes(root);
let changed_paths = get_changed_paths_since_last_tag(root)?;
let mut hits: HashMap<String, usize> = HashMap::new();
for path in &changed_paths {
for (name, dir) in &scopes {
if path.starts_with(dir.trim_start_matches('/')) || path.contains(dir) {
*hits.entry(name.clone()).or_insert(0) += 1;
}
}
}
let best = hits.iter().max_by_key(|(_, c)| *c);
if let Some((name, _)) = best {
return Ok(Some(name.clone()));
}
let all_tags = collect_tags_with_scope(root);
let scoped: Vec<&String> = all_tags.keys().filter(|k| *k != "(root)").collect();
if scoped.len() == 1 {
return Ok(Some(scoped[0].clone()));
}
if scoped.len() > 1 {
let names: Vec<&str> = scoped.iter().map(|s| s.as_str()).collect();
return Err(format!("多个 scope 有变更: {:?},请用 -v 指定", names));
}
Ok(None) }
fn load_contract_scopes(repo_root: &Path) -> HashMap<String, String> {
let paths = [
repo_root.join(".quanttide/devops/contract.yaml"),
repo_root.join("contract.yaml"),
];
for path in &paths {
if let Ok(content) = std::fs::read_to_string(path) {
if let Ok(cfg) = serde_yaml::from_str::<serde_yaml::Value>(&content) {
if let Some(scopes) = cfg.get("scopes").and_then(|s| s.as_mapping()) {
let mut map = HashMap::new();
for (k, v) in scopes {
let name = k.as_str().unwrap_or("").to_string();
let dir = v
.get("dir")
.and_then(|d| d.as_str())
.unwrap_or("")
.to_string();
map.insert(name, dir);
}
return map;
}
}
}
}
HashMap::new()
}
fn get_changed_paths_since_last_tag(root: &Path) -> Result<Vec<String>, String> {
let tags = collect_tags_with_scope(root);
let latest_tag = tags
.iter()
.filter(|(k, _)| *k != "(root)")
.find_map(|(_, v)| v.first())
.or_else(|| tags.get("(root)").and_then(|v| v.first()));
let range = match latest_tag {
Some(tag) => format!("{}..HEAD", tag),
None => return Ok(vec![]),
};
let output = git_output(&["diff", "--name-only", &range], root).unwrap_or_default();
Ok(output.lines().map(|s| s.to_string()).collect())
}
fn get_latest_tag_for_scope(root: &Path, scope: Option<&str>) -> Option<String> {
let all = collect_tags_with_scope(root);
let scope_key = scope.unwrap_or("(root)");
all.get(scope_key).and_then(|tags| tags.first().cloned())
}
fn collect_tags_with_scope(root: &Path) -> HashMap<String, Vec<String>> {
let output = match Command::new("git")
.args(["tag", "--list"])
.current_dir(root)
.output()
{
Ok(o) if o.status.success() => o,
_ => return HashMap::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut groups: HashMap<String, Vec<((u32, u32, u32, u32, u32), String)>> = HashMap::new();
for tag in stdout.lines() {
let (scope, ver_str) = parse_tag(tag);
let scope_name = scope.unwrap_or_else(|| "(root)".to_string());
if let Ok((major, minor, patch, _, pre_num)) = parse_version(ver_str) {
let pre_ord = pre_num.unwrap_or(0);
let stage_ord = if ver_str.contains("-alpha") {
1
} else if ver_str.contains("-beta") {
2
} else if ver_str.contains("-rc") {
3
} else {
0
};
let ord = (major, minor, patch, stage_ord, pre_ord);
groups
.entry(scope_name)
.or_default()
.push((ord, tag.to_string()));
}
}
let mut result: HashMap<String, Vec<String>> = HashMap::new();
for (scope, mut entries) in groups {
entries.sort_by(|a, b| b.0.cmp(&a.0));
result.insert(scope, entries.into_iter().map(|(_, t)| t).collect());
}
result
}
fn parse_tag(tag: &str) -> (Option<String>, &str) {
if let Some((scope, ver)) = tag.split_once('/') {
(Some(scope.to_string()), ver)
} else {
(None, tag)
}
}
fn parse_version(s: &str) -> Result<(u32, u32, u32, Option<String>, Option<u32>), String> {
let s = s.strip_prefix('v').unwrap_or(s);
let (ver_part, pre_part) = s.split_once('-').unwrap_or((s, ""));
let parts: Vec<&str> = ver_part.split('.').collect();
if parts.len() != 3 {
return Err(format!("版本号格式错误: {},需要 X.Y.Z", s));
}
let major = parts[0].parse().map_err(|_| "major 不是数字".to_string())?;
let minor = parts[1].parse().map_err(|_| "minor 不是数字".to_string())?;
let patch: u32 = parts[2].parse().map_err(|_| "patch 不是数字".to_string())?;
let (pre_stage, pre_num) = if pre_part.is_empty() {
(None, None)
} else {
let sp: Vec<&str> = pre_part.split('.').collect();
let stage = sp.first().map(|s| s.to_string());
let num = sp.get(1).and_then(|s| s.parse().ok());
(stage, num)
};
Ok((major, minor, patch, pre_stage, pre_num))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_tag_scoped() {
assert_eq!(parse_tag("cli/v0.8.4"), (Some("cli".into()), "v0.8.4"));
}
#[test]
fn test_parse_tag_root() {
assert_eq!(parse_tag("v0.1.0"), (None, "v0.1.0"));
}
#[test]
fn test_parse_version_formal() {
let (ma, mi, pa, st, nu) = parse_version("0.8.4").unwrap();
assert_eq!((ma, mi, pa), (0, 8, 4));
assert!(st.is_none());
assert!(nu.is_none());
}
#[test]
fn test_parse_version_prerelease() {
let (ma, mi, pa, st, nu) = parse_version("0.9.0-rc.1").unwrap();
assert_eq!((ma, mi, pa), (0, 9, 0));
assert_eq!(st.as_deref(), Some("rc"));
assert_eq!(nu, Some(1));
}
#[test]
fn test_parse_version_with_v_prefix() {
let (ma, mi, pa, _, _) = parse_version("v0.8.4").unwrap();
assert_eq!((ma, mi, pa), (0, 8, 4));
}
#[test]
fn test_parse_version_bad_format() {
assert!(parse_version("abc").is_err());
assert!(parse_version("0.1").is_err());
}
#[test]
fn test_build_version_patch() {
assert_eq!(build_version(0, 8, 4, None, None, "patch", None), "v0.8.5");
}
#[test]
fn test_build_version_minor_rc() {
assert_eq!(
build_version(0, 8, 4, None, None, "minor", Some("rc")),
"v0.9.0-rc.1"
);
}
#[test]
fn test_build_version_prerelease_increment() {
assert_eq!(
build_version(0, 9, 0, Some("rc"), Some(1), "patch", None),
"v0.9.0-rc.2"
);
}
#[test]
fn test_build_version_minor_formal() {
assert_eq!(build_version(0, 8, 4, None, None, "minor", None), "v0.9.0");
}
#[test]
fn test_fallback_heuristic_feat() {
let d = fallback_heuristic(&["feat: add command".into()]);
assert_eq!(d.action, "release");
assert_eq!(d.increment.as_deref(), Some("minor"));
}
#[test]
fn test_fallback_heuristic_fix() {
let d = fallback_heuristic(&["fix: resolve crash".into()]);
assert_eq!(d.action, "release");
assert_eq!(d.increment.as_deref(), Some("patch"));
}
#[test]
fn test_fallback_heuristic_docs() {
let d = fallback_heuristic(&["docs: update readme".into()]);
assert_eq!(d.action, "release");
assert_eq!(d.increment.as_deref(), Some("patch"));
}
#[test]
fn test_fallback_heuristic_skip() {
let d = fallback_heuristic(&["chore: bump version".into()]);
assert_eq!(d.action, "skip");
}
#[test]
fn test_fallback_heuristic_breaking() {
let d = fallback_heuristic(&["feat!: breaking".into()]);
assert_eq!(d.action, "human");
}
#[test]
fn test_parse_tag_multiple_slashes() {
assert_eq!(
parse_tag("scope/v0.1.0-rc.1"),
(Some("scope".into()), "v0.1.0-rc.1")
);
}
#[test]
fn test_parse_tag_empty() {
assert_eq!(parse_tag(""), (None, ""));
}
#[test]
fn test_parse_version_alpha() {
let (ma, mi, pa, st, nu) = parse_version("1.0.0-alpha.1").unwrap();
assert_eq!((ma, mi, pa), (1, 0, 0));
assert_eq!(st.as_deref(), Some("alpha"));
assert_eq!(nu, Some(1));
}
#[test]
fn test_parse_version_beta() {
let (ma, mi, pa, st, nu) = parse_version("0.5.0-beta.2").unwrap();
assert_eq!((ma, mi, pa), (0, 5, 0));
assert_eq!(st.as_deref(), Some("beta"));
assert_eq!(nu, Some(2));
}
#[test]
fn test_parse_version_prerelease_no_number() {
let (ma, mi, pa, st, nu) = parse_version("1.2.3-rc").unwrap();
assert_eq!((ma, mi, pa), (1, 2, 3));
assert_eq!(st.as_deref(), Some("rc"));
assert_eq!(nu, None);
}
#[test]
fn test_parse_version_non_numeric_parts() {
assert!(parse_version("a.b.c").is_err());
assert!(parse_version("1.x.3").is_err());
}
#[test]
fn test_build_version_patch_with_same_stage() {
assert_eq!(
build_version(1, 0, 0, Some("beta"), Some(3), "patch", None),
"v1.0.0-beta.4"
);
}
#[test]
fn test_build_version_minor_with_alpha() {
assert_eq!(
build_version(0, 1, 0, None, None, "minor", Some("alpha")),
"v0.2.0-alpha.1"
);
}
#[test]
fn test_build_version_no_prerelease_info() {
assert_eq!(build_version(1, 0, 0, None, None, "patch", None), "v1.0.1");
}
#[test]
fn test_fallback_heuristic_refactor() {
let d = fallback_heuristic(&["refactor: extract method".into()]);
assert_eq!(d.action, "release");
assert_eq!(d.increment.as_deref(), Some("patch"));
}
#[test]
fn test_fallback_heuristic_test_commit() {
let d = fallback_heuristic(&["test: add coverage".into()]);
assert_eq!(d.action, "release");
assert_eq!(d.increment.as_deref(), Some("patch"));
}
#[test]
fn test_fallback_heuristic_added_commits() {
let d = fallback_heuristic(&["Added new feature".into()]);
assert_eq!(d.action, "release");
assert_eq!(d.increment.as_deref(), Some("minor"));
}
#[test]
fn test_fallback_heuristic_fixed_commits() {
let d = fallback_heuristic(&["Fixed crash on startup".into()]);
assert_eq!(d.action, "release");
assert_eq!(d.increment.as_deref(), Some("patch"));
}
#[test]
fn test_fallback_heuristic_changed_commits() {
let d = fallback_heuristic(&["Changed behavior of X".into()]);
assert_eq!(d.action, "release");
assert_eq!(d.increment.as_deref(), Some("patch"));
}
#[test]
fn test_load_contract_scopes_from_file() {
let d = tempfile::tempdir().unwrap();
let contract_dir = d.path().join(".quanttide/devops");
std::fs::create_dir_all(&contract_dir).unwrap();
std::fs::write(
contract_dir.join("contract.yaml"),
"scopes:\n cli:\n dir: packages/cli\n language: rust\n sdk:\n dir: packages/sdk\n language: python\n",
)
.unwrap();
let scopes = load_contract_scopes(d.path());
assert_eq!(scopes.len(), 2);
assert_eq!(scopes.get("cli").map(|s| s.as_str()), Some("packages/cli"));
assert_eq!(scopes.get("sdk").map(|s| s.as_str()), Some("packages/sdk"));
}
#[test]
fn test_load_contract_scopes_nonexistent() {
let d = tempfile::tempdir().unwrap();
let scopes = load_contract_scopes(d.path());
assert!(scopes.is_empty());
}
#[test]
fn test_load_contract_scopes_root_contract_yaml() {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("contract.yaml"),
"scopes:\n root:\n dir: .\n language: rust\n",
)
.unwrap();
let scopes = load_contract_scopes(d.path());
assert_eq!(scopes.len(), 1);
assert_eq!(scopes.get("root").map(|s| s.as_str()), Some("."));
}
fn git_init_detect(path: &std::path::Path) {
std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(path)
.output()
.unwrap();
std::fs::write(path.join(".gitkeep"), "").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-m",
"init",
])
.current_dir(path)
.output()
.unwrap();
}
#[test]
fn test_detect_project_type_code_with_src() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
std::fs::create_dir(d.path().join("src")).unwrap();
assert_eq!(detect_project_type(d.path()), "code");
}
#[test]
fn test_detect_project_type_code_with_cargo() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
assert_eq!(detect_project_type(d.path()), "code");
}
#[test]
fn test_detect_project_type_docs() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
assert_eq!(detect_project_type(d.path()), "docs");
}
#[test]
fn test_detect_project_type_no_workdir() {
let d = tempfile::tempdir().unwrap();
assert_eq!(detect_project_type(d.path()), "docs");
}
fn git_tag(repo_path: &std::path::Path, tag: &str) {
std::process::Command::new("git")
.args(["tag", tag])
.current_dir(repo_path)
.output()
.unwrap();
}
fn git_commit_file(repo_path: &std::path::Path, path: &str, content: &str) {
std::fs::write(repo_path.join(path), content).unwrap();
std::process::Command::new("git")
.args(["add", path])
.current_dir(repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-m",
&format!("update {path}"),
])
.current_dir(repo_path)
.output()
.unwrap();
}
#[test]
fn test_collect_tags_empty_repo() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
let tags = collect_tags_with_scope(d.path());
assert!(tags.is_empty());
}
#[test]
fn test_collect_tags_root_only() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
git_tag(d.path(), "v1.0.0");
git_tag(d.path(), "v1.1.0");
let tags = collect_tags_with_scope(d.path());
assert_eq!(tags.len(), 1);
assert!(tags.contains_key("(root)"));
assert_eq!(tags["(root)"], vec!["v1.1.0", "v1.0.0"]);
}
#[test]
fn test_collect_tags_scoped_ordered() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
git_tag(d.path(), "cli/v0.2.0");
git_tag(d.path(), "cli/v0.3.0");
git_tag(d.path(), "cli/v0.1.0");
let tags = collect_tags_with_scope(d.path());
assert_eq!(tags.len(), 1);
assert!(tags.contains_key("cli"));
assert_eq!(tags["cli"], vec!["cli/v0.3.0", "cli/v0.2.0", "cli/v0.1.0"]);
}
#[test]
fn test_collect_tags_multi_scope_with_prerelease() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
git_tag(d.path(), "v0.5.0");
git_tag(d.path(), "cli/v0.2.0-rc.1");
git_tag(d.path(), "cli/v0.2.0");
git_tag(d.path(), "sdk/v0.1.0-alpha.1");
git_tag(d.path(), "sdk/v0.1.0-beta.1");
git_tag(d.path(), "sdk/v0.1.0");
let tags = collect_tags_with_scope(d.path());
assert_eq!(tags.len(), 3);
assert_eq!(tags["cli"][0], "cli/v0.2.0-rc.1", "rc 排序高于正式版");
assert_eq!(tags["cli"][1], "cli/v0.2.0");
assert!(tags["sdk"][0].contains("beta"), "beta 应排第一");
assert!(tags["sdk"][1].contains("alpha"), "alpha 应在 beta 之后");
assert_eq!(tags["(root)"][0], "v0.5.0");
}
#[test]
fn test_get_latest_tag_root_scope() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
git_tag(d.path(), "v1.0.0");
git_tag(d.path(), "v2.0.0");
assert_eq!(
get_latest_tag_for_scope(d.path(), None).as_deref(),
Some("v2.0.0")
);
assert_eq!(
get_latest_tag_for_scope(d.path(), Some("(root)")).as_deref(),
Some("v2.0.0")
);
}
#[test]
fn test_get_latest_tag_scoped() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
git_tag(d.path(), "cli/v0.1.0");
git_tag(d.path(), "cli/v0.2.0");
git_tag(d.path(), "sdk/v0.5.0");
assert_eq!(
get_latest_tag_for_scope(d.path(), Some("cli")).as_deref(),
Some("cli/v0.2.0")
);
assert_eq!(
get_latest_tag_for_scope(d.path(), Some("sdk")).as_deref(),
Some("sdk/v0.5.0")
);
assert_eq!(
get_latest_tag_for_scope(d.path(), Some("nosuch")).as_deref(),
None
);
}
#[test]
fn test_get_changed_paths_no_tag() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
git_commit_file(d.path(), "new.txt", "hello");
let paths = get_changed_paths_since_last_tag(d.path()).unwrap();
assert!(paths.is_empty(), "无 tag 时无法 diff,应返回空");
}
#[test]
fn test_get_changed_paths_after_tag() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
git_commit_file(d.path(), "initial.txt", "initial");
git_tag(d.path(), "v1.0.0");
git_commit_file(d.path(), "added.txt", "added");
git_commit_file(d.path(), "modified.txt", "modified");
let paths = get_changed_paths_since_last_tag(d.path()).unwrap();
assert!(paths.contains(&"added.txt".to_string()));
assert!(paths.contains(&"modified.txt".to_string()));
assert!(
!paths.contains(&"initial.txt".to_string()),
"tag 前的文件不应出现"
);
}
#[test]
fn test_get_changed_paths_no_new_commits() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
git_tag(d.path(), "v1.0.0");
let paths = get_changed_paths_since_last_tag(d.path()).unwrap();
assert!(paths.is_empty(), "无新提交应返回空");
}
#[test]
fn test_detect_single_scope_no_changes() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
let scope = detect_single_scope(d.path()).unwrap();
assert_eq!(scope, None, "无 tag 无 contract 应返回 None");
}
#[test]
fn test_detect_single_scope_fallback_to_tags() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
std::fs::create_dir_all(d.path().join("packages/cli")).unwrap();
git_commit_file(d.path(), "packages/cli/readme.md", "cli");
git_tag(d.path(), "cli/v0.1.0");
git_commit_file(d.path(), "readme.md", "root");
let scope = detect_single_scope(d.path()).unwrap();
assert_eq!(scope.as_deref(), Some("cli"), "唯一有 tag 的 scope");
}
#[test]
fn test_detect_single_scope_root_tag() {
let d = tempfile::tempdir().unwrap();
git_init_detect(d.path());
git_commit_file(d.path(), "file.txt", "content");
git_tag(d.path(), "v1.0.0");
let scope = detect_single_scope(d.path()).unwrap();
assert_eq!(scope, None, "只有 root tag 应返回 None");
}
}