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 repo = match git2::Repository::open(repo_path) {
149 Ok(r) => r,
150 Err(_) => return vec![],
151 };
152 let tag_names = match repo.tag_names(None) {
153 Ok(t) => t,
154 Err(_) => return vec![],
155 };
156 let mut tags: Vec<&str> = tag_names.iter().flatten().collect();
157 tags.sort_by(|a, b| b.cmp(a));
158 collect_latest_tags(&tags)
159}
160
161pub fn collect_latest_tags(tags: &[&str]) -> Vec<(String, String)> {
162 let mut scopes: Vec<(String, String)> = Vec::new();
163 for t in tags {
164 let scope = if t.contains('/') {
165 t.split('/').next().unwrap_or("").to_string()
166 } else {
167 "(root)".to_string()
168 };
169 if !scopes.iter().any(|(s, _)| s == &scope) {
170 scopes.push((scope, t.to_string()));
171 }
172 }
173 scopes
174}
175
176fn count_unreleased_in_dir(repo_path: &Path, tag: &str, scope_dir: &Path) -> usize {
177 let repo = match git2::Repository::open(repo_path) {
178 Ok(r) => r,
179 Err(_) => return 0,
180 };
181 let tag_ref = format!("refs/tags/{}", tag);
182 let tag_oid = match repo.find_reference(&tag_ref).ok().and_then(|r| r.target()) {
183 Some(t) => t,
184 None => return 0,
185 };
186 let head_oid = match repo.head().ok().and_then(|h| h.target()) {
187 Some(t) => t,
188 None => return 0,
189 };
190 let mut revwalk = match repo.revwalk() {
191 Ok(w) => w,
192 Err(_) => return 0,
193 };
194 if revwalk.push(head_oid).is_err() || revwalk.hide(tag_oid).is_err() {
195 return 0;
196 }
197 if scope_dir == repo_path {
198 return revwalk.count();
199 }
200 let rel = scope_dir.strip_prefix(repo_path).unwrap_or(scope_dir);
201 let rel_str = rel.to_string_lossy().trim_start_matches('/').to_string();
202 revwalk
203 .filter_map(|oid| oid.ok())
204 .filter(|oid| {
205 if let Ok(commit) = repo.find_commit(*oid) {
206 if let Ok(tree) = commit.tree() {
207 tree.iter().any(|entry| {
208 entry.name().map_or(false, |n| {
209 n == &rel_str || n.starts_with(&format!("{}/", rel_str))
210 })
211 })
212 } else {
213 false
214 }
215 } else {
216 false
217 }
218 })
219 .count()
220}
221
222fn get_github_repo(repo_path: &Path) -> Option<String> {
223 let repo = git2::Repository::open(repo_path).ok()?;
224 let remote = repo.find_remote("origin").ok()?;
225 let url = remote.url()?;
226 let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
227 let caps = re.captures(url)?;
228 Some(caps.get(1)?.as_str().to_string())
229}
230
231fn check_all_configs(
232 writer: &mut impl std::io::Write,
233 repo_path: &Path,
234 other_scope_dirs: &[std::path::PathBuf],
235 expected: &str,
236) -> std::io::Result<()> {
237 let checks: [(&str, fn(&str) -> Option<String>); 5] = [
238 ("Cargo.toml", |c| extract_kv(c, "version")),
239 ("pyproject.toml", |c| extract_kv(c, "version")),
240 ("package.json", extract_json_version),
241 ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
242 ("setup.cfg", |c| extract_kv(c, "version")),
243 ];
244 for (name, extract) in &checks {
245 let content = match std::fs::read_to_string(&repo_path.join(name)) {
246 Ok(c) => c,
247 Err(_) => continue,
248 };
249 match extract(&content) {
250 Some(v) if v == expected => {
251 writeln!(writer, " {:<15} {} ✅", format!("{}:", name), v)?
252 }
253 Some(v) => writeln!(
254 writer,
255 " {:<15} {} ❌ (期望 {})",
256 format!("{}:", name),
257 v,
258 expected
259 )?,
260 None => writeln!(writer, " {:<15} (未找到版本字段)", format!("{}:", name))?,
261 }
262 }
263 let vf = repo_path.join("VERSION");
264 if let Ok(c) = std::fs::read_to_string(&vf) {
265 let v = c.trim().to_string();
266 if !v.is_empty() {
267 if v == expected {
268 writeln!(writer, " VERSION {} ✅", v)?;
269 } else {
270 writeln!(writer, " VERSION {} ❌ (期望 {})", v, expected)?;
271 }
272 }
273 }
274 for p in find_go_files(repo_path, other_scope_dirs) {
275 let content = match std::fs::read_to_string(&p) {
276 Ok(c) => c,
277 Err(_) => continue,
278 };
279 for prefix in &[
280 "var Version = \"",
281 "var VERSION = \"",
282 "const Version = \"",
283 "const VERSION = \"",
284 ] {
285 for line in content.lines() {
286 let t = line.trim();
287 if let Some(rest) = t.strip_prefix(prefix) {
288 if let Some(end) = rest.find('"') {
289 let v = rest[..end].to_string();
290 if !v.is_empty() {
291 let rel = p.strip_prefix(repo_path).unwrap_or(&p);
292 let name = rel.to_string_lossy();
293 if v == expected {
294 writeln!(writer, " {:<15} {} ✅", format!("{}:", name), v)?;
295 } else {
296 writeln!(
297 writer,
298 " {:<15} {} ❌ (期望 {})",
299 format!("{}:", name),
300 v,
301 expected
302 )?;
303 }
304 }
305 }
306 }
307 }
308 }
309 }
310 Ok(())
311}
312
313fn find_go_files(dir: &Path, excludes: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
314 let mut files = Vec::new();
315 let entries = match std::fs::read_dir(dir) {
316 Ok(e) => e,
317 Err(_) => return files,
318 };
319 for entry in entries.flatten() {
320 let p = entry.path();
321 if p.is_dir() {
322 if excludes.iter().any(|e| p == *e) {
323 continue;
324 }
325 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
326 if !name.starts_with('.')
327 && name != "node_modules"
328 && name != "target"
329 && name != "vendor"
330 {
331 files.extend(find_go_files(&p, excludes));
332 }
333 } else if p.extension().and_then(|e| e.to_str()) == Some("go") {
334 files.push(p);
335 }
336 }
337 files
338}
339
340fn extract_kv(content: &str, key: &str) -> Option<String> {
341 let p1 = format!("{} = \"", key);
342 let p2 = format!("{} = '", key);
343 for line in content.lines() {
344 let t = line.trim();
345 if let Some(r) = t.strip_prefix(&p1) {
346 if let Some(e) = r.find('"') {
347 let v = r[..e].to_string();
348 if !v.is_empty() {
349 return Some(v);
350 }
351 }
352 }
353 if let Some(r) = t.strip_prefix(&p2) {
354 if let Some(e) = r.find('\'') {
355 let v = r[..e].to_string();
356 if !v.is_empty() {
357 return Some(v);
358 }
359 }
360 }
361 }
362 None
363}
364
365fn extract_json_version(content: &str) -> Option<String> {
366 for line in content.lines() {
367 let t = line.trim();
368 if let Some(pos) = t.find("\"version\":") {
370 let after_colon = t[pos + "\"version\":".len()..].trim();
371 let value_start = after_colon.find('"')?;
373 let after_open = &after_colon[value_start + 1..];
374 let value_end = after_open.find('"')?;
376 let v = &after_open[..value_end];
377 if !v.is_empty() {
378 return Some(v.to_string());
379 }
380 }
381 }
382 None
383}
384
385fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
386 let p = format!("{}:", key);
387 for line in content.lines() {
388 let t = line.trim();
389 if let Some(r) = t.strip_prefix(&p) {
390 let v = r.trim();
391 if !v.is_empty() && !v.starts_with('#') {
392 return Some(v.to_string());
393 }
394 }
395 }
396 None
397}
398
399fn check_changelog(repo_path: &Path, version: &str) -> bool {
400 if version.is_empty() {
401 return false;
402 }
403 std::fs::read_to_string(repo_path.join("CHANGELOG.md"))
404 .unwrap_or_default()
405 .contains(&format!("[{}]", version))
406}
407
408fn is_dirty(repo_path: &Path) -> bool {
409 let repo = match git2::Repository::open(repo_path) {
410 Ok(r) => r,
411 Err(_) => return false,
412 };
413 repo.statuses(None).map_or(false, |s| !s.is_empty())
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
423 fn test_extract_kv_double_quotes() {
424 assert_eq!(
425 extract_kv("version = \"1.0.0\"\n", "version"),
426 Some("1.0.0".into())
427 );
428 }
429
430 #[test]
431 fn test_extract_kv_single_quotes() {
432 assert_eq!(
433 extract_kv("version = '2.0.0'\n", "version"),
434 Some("2.0.0".into())
435 );
436 }
437
438 #[test]
439 fn test_extract_kv_missing_key() {
440 assert_eq!(extract_kv("name = \"foo\"\n", "version"), None);
441 }
442
443 #[test]
444 fn test_extract_kv_empty_value() {
445 assert_eq!(extract_kv("version = \"\"\n", "version"), None);
446 }
447
448 #[test]
449 fn test_extract_kv_indented() {
450 assert_eq!(
451 extract_kv(" version = \"0.5.0\"\n", "version"),
452 Some("0.5.0".into())
453 );
454 }
455
456 #[test]
459 fn test_extract_json_version_normal() {
460 let content = "{\n \"version\": \"1.0.0\",\n}\n";
461 assert_eq!(extract_json_version(content), Some("1.0.0".into()));
462 }
463
464 #[test]
465 fn test_extract_json_version_single_line() {
466 let content = r#"{"name":"foo","version":"2.0.0"}"#;
467 assert_eq!(extract_json_version(content), Some("2.0.0".into()));
468 }
469
470 #[test]
471 fn test_extract_json_version_trailing_comma() {
472 let content = r#"{"version":"1.0.0",}"#;
473 assert_eq!(extract_json_version(content), Some("1.0.0".into()));
474 }
475
476 #[test]
477 fn test_extract_json_version_missing() {
478 let content = r#"{"name":"foo"}"#;
479 assert_eq!(extract_json_version(content), None);
480 }
481
482 #[test]
483 fn test_extract_json_version_empty() {
484 let content = r#"{"version":""}"#;
485 assert_eq!(extract_json_version(content), None);
486 }
487
488 #[test]
491 fn test_extract_kv_yaml_normal() {
492 assert_eq!(
493 extract_kv_yaml("version: 1.0.0\n", "version"),
494 Some("1.0.0".into())
495 );
496 }
497
498 #[test]
499 fn test_extract_kv_yaml_indented() {
500 assert_eq!(
501 extract_kv_yaml(" version: 3.0.0\n", "version"),
502 Some("3.0.0".into())
503 );
504 }
505
506 #[test]
507 fn test_extract_kv_yaml_ignores_comment() {
508 assert_eq!(extract_kv_yaml("version: # 注释\n", "version"), None);
509 }
510
511 #[test]
512 fn test_extract_kv_yaml_missing() {
513 assert_eq!(extract_kv_yaml("name: foo\n", "version"), None);
514 }
515
516 #[test]
517 fn test_extract_kv_yaml_empty_value() {
518 assert_eq!(extract_kv_yaml("version:\n", "version"), None);
519 }
520
521 #[test]
522 fn test_collect_tags_empty() {
523 assert!(collect_latest_tags(&[]).is_empty());
524 }
525
526 #[test]
527 fn test_collect_tags_root_only() {
528 let tags = collect_latest_tags(&["v2.0.0", "v1.0.0"]);
529 assert_eq!(tags.len(), 1);
530 assert_eq!(tags[0].0, "(root)");
531 assert_eq!(tags[0].1, "v2.0.0");
532 }
533
534 #[test]
535 fn test_collect_tags_scoped() {
536 let tags = collect_latest_tags(&["cli/v0.1.0", "web/v0.2.0"]);
537 assert_eq!(tags.len(), 2);
538 assert_eq!(tags[0].0, "cli");
539 assert_eq!(tags[1].0, "web");
540 }
541
542 #[test]
543 fn test_collect_tags_prerelease_is_kept() {
544 let tags = collect_latest_tags(&["cli/v0.2.0-rc.1", "cli/v0.1.0"]);
546 assert_eq!(tags.len(), 1);
547 assert_eq!(tags[0].1, "cli/v0.2.0-rc.1");
548 }
549
550 #[test]
551 fn test_collect_tags_prerelease_as_fallback() {
552 let tags = collect_latest_tags(&["cli/v0.1.0-rc.2", "cli/v0.1.0-rc.1"]);
553 assert_eq!(tags.len(), 1);
554 assert_eq!(tags[0].1, "cli/v0.1.0-rc.2");
555 }
556
557 fn with_mock_path<F: FnOnce(&Path) -> R, R>(scripts: &[(&str, &str)], f: F) -> R {
559 let dir = tempfile::tempdir().unwrap();
560 let bin = dir.path().join("bin");
561 std::fs::create_dir(&bin).unwrap();
562 for (name, body) in scripts {
563 let path = bin.join(name);
564 std::fs::write(&path, body).unwrap();
565 #[cfg(unix)]
566 std::process::Command::new("chmod")
567 .args(["+x", path.to_str().unwrap()])
568 .output()
569 .unwrap();
570 }
571 let old_path = std::env::var("PATH").unwrap_or_default();
572 std::env::set_var("PATH", format!("{}:{}", bin.display(), old_path));
573 let result = f(dir.path());
574 std::env::set_var("PATH", &old_path);
575 result
576 }
577
578 const GH_NOT_FOUND: &str = "#!/bin/sh\nexit 1\n";
579 const GH_WITH_BODY: &str = "#!/bin/sh\necho '{\"body\":\"content\"}'\n";
580
581 #[test]
582 fn test_status_gh_not_found() {
583 let dir = tempfile::tempdir().unwrap();
585 git_init_test(dir.path());
586 git_tag_test(dir.path(), "v1.0.0");
587 set_remote(dir.path());
588 with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
589 status(dir.path());
590 });
591 }
592
593 #[test]
594 fn test_status_gh_with_body() {
595 let dir = tempfile::tempdir().unwrap();
597 git_init_test(dir.path());
598 std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
599 git_commit_test(dir.path());
600 git_tag_test(dir.path(), "v1.0.0");
601 set_remote(dir.path());
602 with_mock_path(&[("gh", GH_WITH_BODY)], |_| {
603 status(dir.path());
604 });
605 }
606
607 #[test]
608 fn test_status_custom_tags() {
609 let dir = tempfile::tempdir().unwrap();
611 git_init_test(dir.path());
613 git_tag_test(dir.path(), "cli/v0.1.0");
614 git_tag_test(dir.path(), "web/v0.2.0");
615 set_remote(dir.path());
616 with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
617 status(dir.path());
618 });
619 }
620
621 fn git_init_test(path: &Path) {
624 std::process::Command::new("git")
625 .args(["init", "-b", "main"])
626 .current_dir(path)
627 .output()
628 .unwrap();
629 std::process::Command::new("git")
630 .args(["config", "user.email", "t@t"])
631 .current_dir(path)
632 .output()
633 .unwrap();
634 std::process::Command::new("git")
635 .args(["config", "user.name", "t"])
636 .current_dir(path)
637 .output()
638 .unwrap();
639 std::fs::write(path.join("f"), "").unwrap();
640 std::process::Command::new("git")
641 .args(["add", "."])
642 .current_dir(path)
643 .output()
644 .unwrap();
645 std::process::Command::new("git")
646 .args(["commit", "-m", "init"])
647 .current_dir(path)
648 .output()
649 .unwrap();
650 }
651
652 fn git_commit_test(path: &Path) {
653 std::fs::write(path.join("f"), "x").unwrap();
654 std::process::Command::new("git")
655 .args(["add", "."])
656 .current_dir(path)
657 .output()
658 .unwrap();
659 std::process::Command::new("git")
660 .args(["commit", "-m", "x"])
661 .current_dir(path)
662 .output()
663 .unwrap();
664 }
665
666 fn git_tag_test(path: &Path, tag: &str) {
667 std::process::Command::new("git")
668 .args(["-C", path.to_str().unwrap(), "tag", tag])
669 .output()
670 .unwrap();
671 }
672
673 fn set_remote(path: &Path) {
674 std::process::Command::new("git")
675 .args([
676 "-C",
677 path.to_str().unwrap(),
678 "remote",
679 "add",
680 "origin",
681 "https://github.com/owner/repo.git",
682 ])
683 .output()
684 .unwrap();
685 }
686
687 #[test]
688 fn test_collect_tags_mixed_root_and_scoped() {
689 let tags = collect_latest_tags(&["v1.0.0", "cli/v0.2.0", "cli/v0.1.0"]);
690 assert_eq!(tags.len(), 2);
691 let root = tags.iter().find(|(s, _)| s == "(root)").unwrap();
692 assert_eq!(root.1, "v1.0.0");
693 let cli = tags.iter().find(|(s, _)| s == "cli").unwrap();
694 assert_eq!(cli.1, "cli/v0.2.0");
695 }
696
697 #[test]
698 fn test_status_to_output() {
699 let d = tempfile::tempdir().unwrap();
700 std::process::Command::new("git")
702 .args(["init", "-b", "main"])
703 .current_dir(d.path())
704 .output()
705 .unwrap();
706 std::process::Command::new("git")
707 .args(["config", "user.email", "t@t"])
708 .current_dir(d.path())
709 .output()
710 .unwrap();
711 std::process::Command::new("git")
712 .args(["config", "user.name", "t"])
713 .current_dir(d.path())
714 .output()
715 .unwrap();
716 std::fs::write(d.path().join("f"), "").unwrap();
717 std::process::Command::new("git")
718 .args(["add", "."])
719 .current_dir(d.path())
720 .output()
721 .unwrap();
722 std::process::Command::new("git")
723 .args(["commit", "-m", "init"])
724 .current_dir(d.path())
725 .output()
726 .unwrap();
727 std::process::Command::new("git")
729 .args(["tag", "v1.0.0"])
730 .current_dir(d.path())
731 .output()
732 .unwrap();
733 std::fs::write(d.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
735
736 let mut buf = Vec::new();
737 let result = status_to(&mut buf, d.path());
738 assert!(result.is_ok(), "status_to 应成功: {:?}", result);
739 let out = String::from_utf8_lossy(&buf);
740 assert!(out.contains("发布状态"), "应包含标题");
741 assert!(out.contains("v1.0.0"), "应包含 tag 信息");
742 }
743}