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
83fn 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 let repo = get_github_repo(repo_path);
93 let repo = match repo {
94 Some(r) => r,
95 None => return Ok(()),
96 };
97
98 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 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
135fn 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
209fn is_git_repo(path: &Path) -> bool {
211 let git_dir = path.join(".git");
212 git_dir.is_dir() || git_dir.is_file()
213}
214
215fn 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 if let Some(pos) = t.find("\"version\":") {
387 let after_colon = t[pos + "\"version\":".len()..].trim();
388 let value_start = after_colon.find('"')?;
390 let after_open = &after_colon[value_start + 1..];
391 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 #[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 #[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 #[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 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 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 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 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 let dir = tempfile::tempdir().unwrap();
629 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 std::process::Command::new("git")
906 .args(["tag", "v1.0.0"])
907 .current_dir(d.path())
908 .output()
909 .unwrap();
910 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}