Skip to main content

qtcloud_devops_cli/release/
status.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::contract;
5
6pub fn status(repo_path: &Path) {
7    let mut stdout = std::io::stdout();
8    status_to(&mut stdout, repo_path).ok();
9}
10
11pub fn status_to(writer: &mut impl std::io::Write, repo_path: &Path) -> std::io::Result<()> {
12    let scopes_map = load_scopes_map(repo_path);
13    let latest_tags = get_latest_tags_by_scope(repo_path);
14    let dirty = is_dirty(repo_path);
15
16    let other_scope_dirs: Vec<std::path::PathBuf> = scopes_map
17        .iter()
18        .filter(|(k, _)| *k != "(root)")
19        .map(|(_, v)| repo_path.join(v))
20        .collect();
21
22    writeln!(writer, "发布状态")?;
23    writeln!(writer, "{}", "─".repeat(40))?;
24
25    if latest_tags.is_empty() {
26        writeln!(writer, "  最新标签:     (无)")?;
27        return Ok(());
28    }
29
30    for (scope, tag) in &latest_tags {
31        let tag_only = tag.split('/').last().unwrap_or(tag);
32        let ver = tag_only.strip_prefix('v').unwrap_or(tag_only);
33
34        let scope_dir = if scope == "(root)" {
35            repo_path.to_path_buf()
36        } else {
37            match scopes_map.get(scope) {
38                Some(rel) => repo_path.join(rel),
39                None => {
40                    let d = repo_path.join(scope);
41                    if d.is_dir() {
42                        d
43                    } else {
44                        repo_path.to_path_buf()
45                    }
46                }
47            }
48        };
49
50        writeln!(writer, "  [{}]", scope)?;
51        let rel_path = scopes_map.get(scope).cloned().unwrap_or_else(|| {
52            if scope == "(root)" {
53                ".".to_string()
54            } else {
55                scope.clone()
56            }
57        });
58        writeln!(writer, "    路径:         {}", rel_path)?;
59        writeln!(writer, "    最新标签:     {}", tag)?;
60
61        let unreleased = count_unreleased_in_dir(repo_path, tag, &scope_dir);
62        writeln!(writer, "    未发布提交:   {}", unreleased)?;
63
64        if check_changelog(&scope_dir, ver) {
65            writeln!(writer, "    CHANGELOG:    ✅")?;
66        } else {
67            writeln!(writer, "    CHANGELOG:    ❌ 缺少 {} 条目", ver)?;
68        }
69
70        check_github_release(writer, repo_path, tag, &scope_dir, ver)?;
71        check_all_configs(writer, &scope_dir, &other_scope_dirs, ver)?;
72    }
73
74    if dirty {
75        writeln!(writer, "  工作区:       ❌ 有未提交变更")?;
76    } else {
77        writeln!(writer, "  工作区:       ✅ 干净")?;
78    }
79
80    Ok(())
81}
82
83/// 检查 GitHub Release 是否存在,以及 body 是否与 CHANGELOG 同步。
84fn check_github_release(
85    writer: &mut impl std::io::Write,
86    repo_path: &Path,
87    tag: &str,
88    scope_dir: &Path,
89    _version: &str,
90) -> std::io::Result<()> {
91    // 解析 GitHub 仓库
92    let repo = get_github_repo(repo_path);
93    let repo = match repo {
94        Some(r) => r,
95        None => return Ok(()),
96    };
97
98    // 查询 Release
99    let out = std::process::Command::new("gh")
100        .args([
101            "release", "view", tag, "--repo", &repo, "--json", "body", "--jq", ".body",
102        ])
103        .output()
104        .ok();
105
106    let body = match out {
107        Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
108        _ => {
109            writeln!(writer, "    GitHub Release: ❌ 不存在")?;
110            return Ok(());
111        }
112    };
113
114    // 从 CHANGELOG 提取当前版本的 notes
115    let changelog_path = scope_dir.join("CHANGELOG.md");
116    let notes = super::util::extract_notes(tag, &changelog_path);
117    let notes = notes.unwrap_or_default();
118
119    if body == notes {
120        writeln!(writer, "    GitHub Release: ✅ body 与 CHANGELOG 一致")?;
121    } else if body.trim().is_empty() {
122        writeln!(writer, "    GitHub Release: ⚠️ body 为空")?;
123    } else if notes.is_empty() {
124        writeln!(
125            writer,
126            "    GitHub Release: ✅ 已创建 (CHANGELOG 无此版本条目)"
127        )?;
128    } else {
129        writeln!(writer, "    GitHub Release: ⚠️ body 与 CHANGELOG 不同步")?;
130    }
131
132    Ok(())
133}
134
135/// 从契约加载 scope 列表,转为 (name → dir) 映射。
136fn load_scopes_map(repo_path: &Path) -> HashMap<String, String> {
137    let mut map: HashMap<String, String> = contract::load_scopes(repo_path)
138        .into_iter()
139        .map(|s| (s.name, s.dir))
140        .collect();
141    if !map.contains_key("(root)") {
142        map.insert("(root)".to_string(), "".to_string());
143    }
144    map
145}
146
147fn get_latest_tags_by_scope(repo_path: &Path) -> Vec<(String, String)> {
148    let out = match std::process::Command::new("git")
149        .args(["tag", "--list"])
150        .current_dir(repo_path)
151        .output()
152    {
153        Ok(o) if o.status.success() => o,
154        _ => return vec![],
155    };
156    if !out.status.success() {
157        return vec![];
158    }
159    let stdout = String::from_utf8_lossy(&out.stdout);
160    let mut tags: Vec<&str> = stdout.lines().collect();
161    tags.sort_by(|a, b| b.cmp(a));
162    collect_latest_tags(&tags)
163}
164
165pub fn collect_latest_tags(tags: &[&str]) -> Vec<(String, String)> {
166    let mut scopes: Vec<(String, String)> = Vec::new();
167    for t in tags {
168        let scope = if t.contains('/') {
169            t.split('/').next().unwrap_or("").to_string()
170        } else {
171            "(root)".to_string()
172        };
173        if !scopes.iter().any(|(s, _)| s == &scope) {
174            scopes.push((scope, t.to_string()));
175        }
176    }
177    scopes
178}
179
180fn count_unreleased_in_dir(repo_path: &Path, tag: &str, scope_dir: &Path) -> usize {
181    if is_git_repo(scope_dir) {
182        return count_unreleased_in_submodule(scope_dir, tag);
183    }
184    let rel = scope_dir.strip_prefix(repo_path).unwrap_or(scope_dir);
185    let rel_str = rel.to_string_lossy().trim_start_matches('/').to_string();
186    let range = format!("{}..HEAD", tag);
187    let mut args = vec!["rev-list", "--count", &range];
188    if !rel_str.is_empty() && rel_str != "." {
189        args.push("--");
190        args.push(rel_str.as_str());
191    }
192    let out = std::process::Command::new("git")
193        .args(&args)
194        .current_dir(repo_path)
195        .output()
196        .ok()
197        .and_then(|o| {
198            if o.status.success() {
199                let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
200                s.parse::<usize>().ok()
201            } else {
202                None
203            }
204        })
205        .unwrap_or(0);
206    out
207}
208
209/// 检测路径是否为 git 仓库
210fn is_git_repo(path: &Path) -> bool {
211    let git_dir = path.join(".git");
212    git_dir.is_dir() || git_dir.is_file()
213}
214
215/// 在子模组内统计未发布提交数(子模组自己的 tag 和 HEAD)
216fn count_unreleased_in_submodule(submodule_path: &Path, tag: &str) -> usize {
217    std::process::Command::new("git")
218        .args(["rev-list", "--count", &format!("{}..HEAD", tag)])
219        .current_dir(submodule_path)
220        .output()
221        .ok()
222        .and_then(|o| {
223            if o.status.success() {
224                let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
225                s.parse::<usize>().ok()
226            } else {
227                None
228            }
229        })
230        .unwrap_or(0)
231}
232
233fn get_github_repo(repo_path: &Path) -> Option<String> {
234    let out = std::process::Command::new("git")
235        .args(["remote", "get-url", "origin"])
236        .current_dir(repo_path)
237        .output()
238        .ok()?;
239    if !out.status.success() {
240        return None;
241    }
242    let url = std::str::from_utf8(&out.stdout).ok()?.trim().to_string();
243    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
244    let caps = re.captures(&url)?;
245    Some(caps.get(1)?.as_str().to_string())
246}
247
248fn check_all_configs(
249    writer: &mut impl std::io::Write,
250    repo_path: &Path,
251    other_scope_dirs: &[std::path::PathBuf],
252    expected: &str,
253) -> std::io::Result<()> {
254    let checks: [(&str, fn(&str) -> Option<String>); 5] = [
255        ("Cargo.toml", |c| extract_kv(c, "version")),
256        ("pyproject.toml", |c| extract_kv(c, "version")),
257        ("package.json", extract_json_version),
258        ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
259        ("setup.cfg", |c| extract_kv(c, "version")),
260    ];
261    for (name, extract) in &checks {
262        let content = match std::fs::read_to_string(&repo_path.join(name)) {
263            Ok(c) => c,
264            Err(_) => continue,
265        };
266        match extract(&content) {
267            Some(v) if v == expected => {
268                writeln!(writer, "    {:<15} {} ✅", format!("{}:", name), v)?
269            }
270            Some(v) => writeln!(
271                writer,
272                "    {:<15} {} ❌ (期望 {})",
273                format!("{}:", name),
274                v,
275                expected
276            )?,
277            None => writeln!(writer, "    {:<15} (未找到版本字段)", format!("{}:", name))?,
278        }
279    }
280    let vf = repo_path.join("VERSION");
281    if let Ok(c) = std::fs::read_to_string(&vf) {
282        let v = c.trim().to_string();
283        if !v.is_empty() {
284            if v == expected {
285                writeln!(writer, "    VERSION          {} ✅", v)?;
286            } else {
287                writeln!(writer, "    VERSION          {} ❌ (期望 {})", v, expected)?;
288            }
289        }
290    }
291    for p in find_go_files(repo_path, other_scope_dirs) {
292        let content = match std::fs::read_to_string(&p) {
293            Ok(c) => c,
294            Err(_) => continue,
295        };
296        for prefix in &[
297            "var Version = \"",
298            "var VERSION = \"",
299            "const Version = \"",
300            "const VERSION = \"",
301        ] {
302            for line in content.lines() {
303                let t = line.trim();
304                if let Some(rest) = t.strip_prefix(prefix) {
305                    if let Some(end) = rest.find('"') {
306                        let v = rest[..end].to_string();
307                        if !v.is_empty() {
308                            let rel = p.strip_prefix(repo_path).unwrap_or(&p);
309                            let name = rel.to_string_lossy();
310                            if v == expected {
311                                writeln!(writer, "    {:<15} {} ✅", format!("{}:", name), v)?;
312                            } else {
313                                writeln!(
314                                    writer,
315                                    "    {:<15} {} ❌ (期望 {})",
316                                    format!("{}:", name),
317                                    v,
318                                    expected
319                                )?;
320                            }
321                        }
322                    }
323                }
324            }
325        }
326    }
327    Ok(())
328}
329
330fn find_go_files(dir: &Path, excludes: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
331    let mut files = Vec::new();
332    let entries = match std::fs::read_dir(dir) {
333        Ok(e) => e,
334        Err(_) => return files,
335    };
336    for entry in entries.flatten() {
337        let p = entry.path();
338        if p.is_dir() {
339            if excludes.iter().any(|e| p == *e) {
340                continue;
341            }
342            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
343            if !name.starts_with('.')
344                && name != "node_modules"
345                && name != "target"
346                && name != "vendor"
347            {
348                files.extend(find_go_files(&p, excludes));
349            }
350        } else if p.extension().and_then(|e| e.to_str()) == Some("go") {
351            files.push(p);
352        }
353    }
354    files
355}
356
357fn extract_kv(content: &str, key: &str) -> Option<String> {
358    let p1 = format!("{} = \"", key);
359    let p2 = format!("{} = '", key);
360    for line in content.lines() {
361        let t = line.trim();
362        if let Some(r) = t.strip_prefix(&p1) {
363            if let Some(e) = r.find('"') {
364                let v = r[..e].to_string();
365                if !v.is_empty() {
366                    return Some(v);
367                }
368            }
369        }
370        if let Some(r) = t.strip_prefix(&p2) {
371            if let Some(e) = r.find('\'') {
372                let v = r[..e].to_string();
373                if !v.is_empty() {
374                    return Some(v);
375                }
376            }
377        }
378    }
379    None
380}
381
382fn extract_json_version(content: &str) -> Option<String> {
383    for line in content.lines() {
384        let t = line.trim();
385        // 用 find 而非 strip_prefix,支持单行 JSON("version": 不在行首)
386        if let Some(pos) = t.find("\"version\":") {
387            let after_colon = t[pos + "\"version\":".len()..].trim();
388            // 定位第一个引号 -> value 起点
389            let value_start = after_colon.find('"')?;
390            let after_open = &after_colon[value_start + 1..];
391            // 定位闭合引号 -> value 终点
392            let value_end = after_open.find('"')?;
393            let v = &after_open[..value_end];
394            if !v.is_empty() {
395                return Some(v.to_string());
396            }
397        }
398    }
399    None
400}
401
402fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
403    let p = format!("{}:", key);
404    for line in content.lines() {
405        let t = line.trim();
406        if let Some(r) = t.strip_prefix(&p) {
407            let v = r.trim();
408            if !v.is_empty() && !v.starts_with('#') {
409                return Some(v.to_string());
410            }
411        }
412    }
413    None
414}
415
416fn check_changelog(repo_path: &Path, version: &str) -> bool {
417    if version.is_empty() {
418        return false;
419    }
420    std::fs::read_to_string(repo_path.join("CHANGELOG.md"))
421        .unwrap_or_default()
422        .contains(&format!("[{}]", version))
423}
424
425fn is_dirty(repo_path: &Path) -> bool {
426    std::process::Command::new("git")
427        .args(["status", "--porcelain"])
428        .current_dir(repo_path)
429        .output()
430        .map(|o| !o.stdout.is_empty())
431        .unwrap_or(false)
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    // ── extract_kv ────────────────────────────────────────────
439
440    #[test]
441    fn test_extract_kv_double_quotes() {
442        assert_eq!(
443            extract_kv("version = \"1.0.0\"\n", "version"),
444            Some("1.0.0".into())
445        );
446    }
447
448    #[test]
449    fn test_extract_kv_single_quotes() {
450        assert_eq!(
451            extract_kv("version = '2.0.0'\n", "version"),
452            Some("2.0.0".into())
453        );
454    }
455
456    #[test]
457    fn test_extract_kv_missing_key() {
458        assert_eq!(extract_kv("name = \"foo\"\n", "version"), None);
459    }
460
461    #[test]
462    fn test_extract_kv_empty_value() {
463        assert_eq!(extract_kv("version = \"\"\n", "version"), None);
464    }
465
466    #[test]
467    fn test_extract_kv_indented() {
468        assert_eq!(
469            extract_kv("  version = \"0.5.0\"\n", "version"),
470            Some("0.5.0".into())
471        );
472    }
473
474    // ── extract_json_version ───────────────────────────────────
475
476    #[test]
477    fn test_extract_json_version_normal() {
478        let content = "{\n  \"version\": \"1.0.0\",\n}\n";
479        assert_eq!(extract_json_version(content), Some("1.0.0".into()));
480    }
481
482    #[test]
483    fn test_extract_json_version_single_line() {
484        let content = r#"{"name":"foo","version":"2.0.0"}"#;
485        assert_eq!(extract_json_version(content), Some("2.0.0".into()));
486    }
487
488    #[test]
489    fn test_extract_json_version_trailing_comma() {
490        let content = r#"{"version":"1.0.0",}"#;
491        assert_eq!(extract_json_version(content), Some("1.0.0".into()));
492    }
493
494    #[test]
495    fn test_extract_json_version_missing() {
496        let content = r#"{"name":"foo"}"#;
497        assert_eq!(extract_json_version(content), None);
498    }
499
500    #[test]
501    fn test_extract_json_version_empty() {
502        let content = r#"{"version":""}"#;
503        assert_eq!(extract_json_version(content), None);
504    }
505
506    // ── extract_kv_yaml ───────────────────────────────────────
507
508    #[test]
509    fn test_extract_kv_yaml_normal() {
510        assert_eq!(
511            extract_kv_yaml("version: 1.0.0\n", "version"),
512            Some("1.0.0".into())
513        );
514    }
515
516    #[test]
517    fn test_extract_kv_yaml_indented() {
518        assert_eq!(
519            extract_kv_yaml("  version: 3.0.0\n", "version"),
520            Some("3.0.0".into())
521        );
522    }
523
524    #[test]
525    fn test_extract_kv_yaml_ignores_comment() {
526        assert_eq!(extract_kv_yaml("version: # 注释\n", "version"), None);
527    }
528
529    #[test]
530    fn test_extract_kv_yaml_missing() {
531        assert_eq!(extract_kv_yaml("name: foo\n", "version"), None);
532    }
533
534    #[test]
535    fn test_extract_kv_yaml_empty_value() {
536        assert_eq!(extract_kv_yaml("version:\n", "version"), None);
537    }
538
539    #[test]
540    fn test_collect_tags_empty() {
541        assert!(collect_latest_tags(&[]).is_empty());
542    }
543
544    #[test]
545    fn test_collect_tags_root_only() {
546        let tags = collect_latest_tags(&["v2.0.0", "v1.0.0"]);
547        assert_eq!(tags.len(), 1);
548        assert_eq!(tags[0].0, "(root)");
549        assert_eq!(tags[0].1, "v2.0.0");
550    }
551
552    #[test]
553    fn test_collect_tags_scoped() {
554        let tags = collect_latest_tags(&["cli/v0.1.0", "web/v0.2.0"]);
555        assert_eq!(tags.len(), 2);
556        assert_eq!(tags[0].0, "cli");
557        assert_eq!(tags[1].0, "web");
558    }
559
560    #[test]
561    fn test_collect_tags_prerelease_is_kept() {
562        // 输入已按版本降序,首个 tag 胜出(含 prerelease)
563        let tags = collect_latest_tags(&["cli/v0.2.0-rc.1", "cli/v0.1.0"]);
564        assert_eq!(tags.len(), 1);
565        assert_eq!(tags[0].1, "cli/v0.2.0-rc.1");
566    }
567
568    #[test]
569    fn test_collect_tags_prerelease_as_fallback() {
570        let tags = collect_latest_tags(&["cli/v0.1.0-rc.2", "cli/v0.1.0-rc.1"]);
571        assert_eq!(tags.len(), 1);
572        assert_eq!(tags[0].1, "cli/v0.1.0-rc.2");
573    }
574
575    /// 创建 mock bin 脚本并前置到 PATH,测试结束后还原。
576    fn with_mock_path<F: FnOnce(&Path) -> R, R>(scripts: &[(&str, &str)], f: F) -> R {
577        let dir = tempfile::tempdir().unwrap();
578        let bin = dir.path().join("bin");
579        std::fs::create_dir(&bin).unwrap();
580        for (name, body) in scripts {
581            let path = bin.join(name);
582            std::fs::write(&path, body).unwrap();
583            #[cfg(unix)]
584            std::process::Command::new("chmod")
585                .args(["+x", path.to_str().unwrap()])
586                .output()
587                .unwrap();
588        }
589        let old_path = std::env::var("PATH").unwrap_or_default();
590        std::env::set_var("PATH", format!("{}:{}", bin.display(), old_path));
591        let result = f(dir.path());
592        std::env::set_var("PATH", &old_path);
593        result
594    }
595
596    const GH_NOT_FOUND: &str = "#!/bin/sh\nexit 1\n";
597    const GH_WITH_BODY: &str = "#!/bin/sh\necho '{\"body\":\"content\"}'\n";
598
599    #[test]
600    fn test_status_gh_not_found() {
601        // 有 GitHub remote 但 gh CLI 返回不存在 → 不 panic
602        let dir = tempfile::tempdir().unwrap();
603        git_init_test(dir.path());
604        git_tag_test(dir.path(), "v1.0.0");
605        set_remote(dir.path());
606        with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
607            status(dir.path());
608        });
609    }
610
611    #[test]
612    fn test_status_gh_with_body() {
613        // gh 返回 body,CHANGELOG 匹配 → 一致
614        let dir = tempfile::tempdir().unwrap();
615        git_init_test(dir.path());
616        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
617        git_commit_test(dir.path());
618        git_tag_test(dir.path(), "v1.0.0");
619        set_remote(dir.path());
620        with_mock_path(&[("gh", GH_WITH_BODY)], |_| {
621            status(dir.path());
622        });
623    }
624
625    #[test]
626    fn test_status_custom_tags() {
627        // 自定义 mock git 返回的标签列表,覆盖多 scope 路径
628        let dir = tempfile::tempdir().unwrap();
629        // 用真实 git repo + 标签,验证 status 不 panic
630        git_init_test(dir.path());
631        git_tag_test(dir.path(), "cli/v0.1.0");
632        git_tag_test(dir.path(), "web/v0.2.0");
633        set_remote(dir.path());
634        with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
635            status(dir.path());
636        });
637    }
638
639    // ── 测试辅助 ────────────────────────────────────────────────
640
641    fn git_init_test(path: &Path) {
642        std::process::Command::new("git")
643            .args(["init", "-b", "main"])
644            .current_dir(path)
645            .output()
646            .unwrap();
647        std::process::Command::new("git")
648            .args(["config", "user.email", "t@t"])
649            .current_dir(path)
650            .output()
651            .unwrap();
652        std::process::Command::new("git")
653            .args(["config", "user.name", "t"])
654            .current_dir(path)
655            .output()
656            .unwrap();
657        std::fs::write(path.join("f"), "").unwrap();
658        std::process::Command::new("git")
659            .args(["add", "."])
660            .current_dir(path)
661            .output()
662            .unwrap();
663        std::process::Command::new("git")
664            .args(["commit", "-m", "init"])
665            .current_dir(path)
666            .output()
667            .unwrap();
668    }
669
670    fn git_commit_test(path: &Path) {
671        std::fs::write(path.join("f"), "x").unwrap();
672        std::process::Command::new("git")
673            .args(["add", "."])
674            .current_dir(path)
675            .output()
676            .unwrap();
677        std::process::Command::new("git")
678            .args(["commit", "-m", "x"])
679            .current_dir(path)
680            .output()
681            .unwrap();
682    }
683
684    fn git_tag_test(path: &Path, tag: &str) {
685        std::process::Command::new("git")
686            .args(["-C", path.to_str().unwrap(), "tag", tag])
687            .output()
688            .unwrap();
689    }
690
691    fn set_remote(path: &Path) {
692        std::process::Command::new("git")
693            .args([
694                "-C",
695                path.to_str().unwrap(),
696                "remote",
697                "add",
698                "origin",
699                "https://github.com/owner/repo.git",
700            ])
701            .output()
702            .unwrap();
703    }
704
705    #[test]
706    fn test_collect_tags_mixed_root_and_scoped() {
707        let tags = collect_latest_tags(&["v1.0.0", "cli/v0.2.0", "cli/v0.1.0"]);
708        assert_eq!(tags.len(), 2);
709        let root = tags.iter().find(|(s, _)| s == "(root)").unwrap();
710        assert_eq!(root.1, "v1.0.0");
711        let cli = tags.iter().find(|(s, _)| s == "cli").unwrap();
712        assert_eq!(cli.1, "cli/v0.2.0");
713    }
714
715    // ── is_git_repo ───────────────────────────────────────────
716
717    #[test]
718    fn test_is_git_repo_dir() {
719        let d = tempfile::tempdir().unwrap();
720        std::fs::create_dir(d.path().join(".git")).unwrap();
721        assert!(is_git_repo(d.path()));
722    }
723
724    #[test]
725    fn test_is_git_repo_file() {
726        let d = tempfile::tempdir().unwrap();
727        std::fs::write(d.path().join(".git"), "gitdir: ../.git/modules/foo").unwrap();
728        assert!(is_git_repo(d.path()));
729    }
730
731    #[test]
732    fn test_is_git_repo_false() {
733        let d = tempfile::tempdir().unwrap();
734        assert!(!is_git_repo(d.path()));
735    }
736
737    // ── check_changelog ────────────────────────────────────────
738
739    #[test]
740    fn test_check_changelog_found() {
741        let d = tempfile::tempdir().unwrap();
742        std::fs::write(d.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
743        assert!(check_changelog(d.path(), "1.0.0"));
744    }
745
746    #[test]
747    fn test_check_changelog_not_found() {
748        let d = tempfile::tempdir().unwrap();
749        std::fs::write(d.path().join("CHANGELOG.md"), "## [0.9.0]\n\ncontent\n").unwrap();
750        assert!(!check_changelog(d.path(), "1.0.0"));
751    }
752
753    #[test]
754    fn test_check_changelog_no_file() {
755        let d = tempfile::tempdir().unwrap();
756        assert!(!check_changelog(d.path(), "1.0.0"));
757    }
758
759    #[test]
760    fn test_check_changelog_empty_version() {
761        let d = tempfile::tempdir().unwrap();
762        assert!(!check_changelog(d.path(), ""));
763    }
764
765    // ── extract_kv 更多边缘 ─────────────────────────────────
766
767    #[test]
768    fn test_extract_kv_single_quotes_indented() {
769        assert_eq!(
770            extract_kv("  version = '1.2.3'\n", "version"),
771            Some("1.2.3".into())
772        );
773    }
774
775    #[test]
776    fn test_extract_kv_double_quotes_trailing() {
777        assert_eq!(
778            extract_kv("version = \"1.0.0\"  # comment\n", "version"),
779            Some("1.0.0".into())
780        );
781    }
782
783    // ── extract_json_version 边缘 ────────────────────────────
784
785    #[test]
786    fn test_extract_json_version_nested() {
787        let content = r#"{"scripts":{"test":"jest"},"version":"4.5.6"}"#;
788        assert_eq!(extract_json_version(content), Some("4.5.6".into()));
789    }
790
791    // ── find_go_files ──────────────────────────────────────────
792
793    #[test]
794    fn test_find_go_files_empty_dir() {
795        let d = tempfile::tempdir().unwrap();
796        let files = find_go_files(d.path(), &[]);
797        assert!(files.is_empty());
798    }
799
800    #[test]
801    fn test_find_go_files_finds_go() {
802        let d = tempfile::tempdir().unwrap();
803        std::fs::write(d.path().join("main.go"), "package main").unwrap();
804        let files = find_go_files(d.path(), &[]);
805        assert_eq!(files.len(), 1);
806        assert!(files[0].ends_with("main.go"));
807    }
808
809    #[test]
810    fn test_find_go_files_skips_excluded() {
811        let d = tempfile::tempdir().unwrap();
812        let sub = d.path().join("vendor");
813        std::fs::create_dir(&sub).unwrap();
814        std::fs::write(sub.join("lib.go"), "package lib").unwrap();
815        let files = find_go_files(d.path(), &[]);
816        assert!(files.is_empty(), "vendor 目录应被跳过");
817    }
818
819    #[test]
820    fn test_find_go_files_skips_custom_excludes() {
821        let d = tempfile::tempdir().unwrap();
822        let sub = d.path().join("generated");
823        std::fs::create_dir(&sub).unwrap();
824        std::fs::write(sub.join("code.go"), "package code").unwrap();
825        let files = find_go_files(d.path(), &[sub]);
826        assert!(files.is_empty(), "自定义排除应有 0 个文件");
827    }
828
829    #[test]
830    fn test_find_go_files_nested() {
831        let d = tempfile::tempdir().unwrap();
832        std::fs::create_dir_all(d.path().join("pkg/util")).unwrap();
833        std::fs::write(d.path().join("pkg/util/helper.go"), "package util").unwrap();
834        let files = find_go_files(d.path(), &[]);
835        assert_eq!(files.len(), 1);
836    }
837
838    // ── get_github_repo ───────────────────────────────────────
839
840    #[test]
841    fn test_get_github_repo_no_repo() {
842        let d = tempfile::tempdir().unwrap();
843        assert_eq!(get_github_repo(d.path()), None);
844    }
845
846    // ── status_to_output ───────────────────────────────────────
847
848    // ── collect_latest_tags 性能 ────────────────────────────
849
850    #[test]
851    fn test_collect_latest_tags_large_input() {
852        use std::time::Instant;
853        let mut tags: Vec<&str> = Vec::with_capacity(10000);
854        for i in 0..5000 {
855            tags.push(Box::leak(
856                format!("cli/v0.{}.{}", i / 100, i % 100).into_boxed_str(),
857            ));
858            tags.push(Box::leak(
859                format!("sdk/v0.{}.{}", i / 100, i % 100).into_boxed_str(),
860            ));
861        }
862        tags.sort_by(|a, b| b.cmp(a));
863        let start = Instant::now();
864        let result = collect_latest_tags(&tags);
865        let elapsed = start.elapsed();
866        assert_eq!(result.len(), 2, "两个 scope 各有最新 tag");
867        assert!(
868            elapsed.as_micros() < 10_000,
869            "10000 tag 排序应 < 10ms,实际: {}μs",
870            elapsed.as_micros()
871        );
872    }
873
874    #[test]
875    fn test_status_to_output() {
876        let d = tempfile::tempdir().unwrap();
877        // 初始化 git 仓库
878        std::process::Command::new("git")
879            .args(["init", "-b", "main"])
880            .current_dir(d.path())
881            .output()
882            .unwrap();
883        std::process::Command::new("git")
884            .args(["config", "user.email", "t@t"])
885            .current_dir(d.path())
886            .output()
887            .unwrap();
888        std::process::Command::new("git")
889            .args(["config", "user.name", "t"])
890            .current_dir(d.path())
891            .output()
892            .unwrap();
893        std::fs::write(d.path().join("f"), "").unwrap();
894        std::process::Command::new("git")
895            .args(["add", "."])
896            .current_dir(d.path())
897            .output()
898            .unwrap();
899        std::process::Command::new("git")
900            .args(["commit", "-m", "init"])
901            .current_dir(d.path())
902            .output()
903            .unwrap();
904        // 打一个 tag
905        std::process::Command::new("git")
906            .args(["tag", "v1.0.0"])
907            .current_dir(d.path())
908            .output()
909            .unwrap();
910        // 写 CHANGELOG
911        std::fs::write(d.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
912
913        let mut buf = Vec::new();
914        let result = status_to(&mut buf, d.path());
915        assert!(result.is_ok(), "status_to 应成功: {:?}", result);
916        let out = String::from_utf8_lossy(&buf);
917        assert!(out.contains("发布状态"), "应包含标题");
918        assert!(out.contains("v1.0.0"), "应包含 tag 信息");
919    }
920}