1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::contract;
5
6pub fn status(repo_path: &Path) {
7 let scopes_map = load_scopes_map(repo_path);
8 let latest_tags = get_latest_tags_by_scope(repo_path);
9 let dirty = is_dirty(repo_path);
10
11 let other_scope_dirs: Vec<std::path::PathBuf> = scopes_map
12 .iter()
13 .filter(|(k, _)| *k != "(root)")
14 .map(|(_, v)| repo_path.join(v))
15 .collect();
16
17 println!("发布状态");
18 println!("{}", "─".repeat(40));
19
20 if latest_tags.is_empty() {
21 println!(" 最新标签: (无)");
22 return;
23 }
24
25 for (scope, tag) in &latest_tags {
26 let tag_only = tag.split('/').last().unwrap_or(tag);
27 let ver = tag_only.strip_prefix('v').unwrap_or(tag_only);
28
29 let scope_dir = if scope == "(root)" {
30 repo_path.to_path_buf()
31 } else {
32 match scopes_map.get(scope) {
33 Some(rel) => repo_path.join(rel),
34 None => {
35 let d = repo_path.join(scope);
36 if d.is_dir() {
37 d
38 } else {
39 repo_path.to_path_buf()
40 }
41 }
42 }
43 };
44
45 println!(" [{}]", scope);
46 let rel_path = scopes_map.get(scope).cloned().unwrap_or_else(|| {
47 if scope == "(root)" {
48 ".".to_string()
49 } else {
50 scope.clone()
51 }
52 });
53 println!(" 路径: {}", rel_path);
54 println!(" 最新标签: {}", tag);
55
56 let unreleased = count_unreleased_in_dir(repo_path, tag, &scope_dir);
57 println!(" 未发布提交: {}", unreleased);
58
59 if check_changelog(&scope_dir, ver) {
60 println!(" CHANGELOG: ✅");
61 } else {
62 println!(" CHANGELOG: ❌ 缺少 {} 条目", ver);
63 }
64
65 check_github_release(repo_path, tag, &scope_dir, ver);
66 check_all_configs(&scope_dir, &other_scope_dirs, ver);
67 }
68
69 if dirty {
70 println!(" 工作区: ❌ 有未提交变更");
71 } else {
72 println!(" 工作区: ✅ 干净");
73 }
74}
75
76fn check_github_release(repo_path: &Path, tag: &str, scope_dir: &Path, _version: &str) {
78 let repo = get_github_repo(repo_path);
80 let repo = match repo {
81 Some(r) => r,
82 None => return,
83 };
84
85 let out = std::process::Command::new("gh")
87 .args([
88 "release", "view", tag, "--repo", &repo, "--json", "body", "--jq", ".body",
89 ])
90 .output()
91 .ok();
92
93 let body = match out {
94 Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
95 _ => {
96 println!(" GitHub Release: ❌ 不存在");
97 return;
98 }
99 };
100
101 let changelog_path = scope_dir.join("CHANGELOG.md");
103 let notes = super::util::extract_notes(tag, &changelog_path);
104 let notes = notes.unwrap_or_default();
105
106 if body == notes {
107 println!(" GitHub Release: ✅ body 与 CHANGELOG 一致");
108 } else if body.trim().is_empty() {
109 println!(" GitHub Release: ⚠️ body 为空");
110 } else if notes.is_empty() {
111 println!(" GitHub Release: ✅ 已创建 (CHANGELOG 无此版本条目)");
112 } else {
113 println!(" GitHub Release: ⚠️ body 与 CHANGELOG 不同步");
114 }
115}
116
117fn load_scopes_map(repo_path: &Path) -> HashMap<String, String> {
119 let mut map: HashMap<String, String> = contract::load_scopes(repo_path)
120 .into_iter()
121 .map(|s| (s.name, s.dir))
122 .collect();
123 if !map.contains_key("(root)") {
124 map.insert("(root)".to_string(), "".to_string());
125 }
126 map
127}
128
129fn get_latest_tags_by_scope(repo_path: &Path) -> Vec<(String, String)> {
130 let repo = match git2::Repository::open(repo_path) {
131 Ok(r) => r,
132 Err(_) => return vec![],
133 };
134 let tag_names = match repo.tag_names(None) {
135 Ok(t) => t,
136 Err(_) => return vec![],
137 };
138 let mut tags: Vec<&str> = tag_names.iter().flatten().collect();
139 tags.sort_by(|a, b| b.cmp(a));
140 collect_latest_tags(&tags)
141}
142
143pub fn collect_latest_tags(tags: &[&str]) -> Vec<(String, String)> {
144 let mut scopes: Vec<(String, String)> = Vec::new();
145 for t in tags {
146 let scope = if t.contains('/') {
147 t.split('/').next().unwrap_or("").to_string()
148 } else {
149 "(root)".to_string()
150 };
151 if !scopes.iter().any(|(s, _)| s == &scope) {
152 scopes.push((scope, t.to_string()));
153 }
154 }
155 scopes
156}
157
158fn count_unreleased_in_dir(repo_path: &Path, tag: &str, scope_dir: &Path) -> usize {
159 let repo = match git2::Repository::open(repo_path) {
160 Ok(r) => r,
161 Err(_) => return 0,
162 };
163 let tag_ref = format!("refs/tags/{}", tag);
164 let tag_oid = match repo.find_reference(&tag_ref).ok().and_then(|r| r.target()) {
165 Some(t) => t,
166 None => return 0,
167 };
168 let head_oid = match repo.head().ok().and_then(|h| h.target()) {
169 Some(t) => t,
170 None => return 0,
171 };
172 let mut revwalk = match repo.revwalk() {
173 Ok(w) => w,
174 Err(_) => return 0,
175 };
176 if revwalk.push(head_oid).is_err() || revwalk.hide(tag_oid).is_err() {
177 return 0;
178 }
179 if scope_dir == repo_path {
180 return revwalk.count();
181 }
182 let rel = scope_dir.strip_prefix(repo_path).unwrap_or(scope_dir);
183 let rel_str = rel.to_string_lossy().trim_start_matches('/').to_string();
184 revwalk
185 .filter_map(|oid| oid.ok())
186 .filter(|oid| {
187 if let Ok(commit) = repo.find_commit(*oid) {
188 if let Ok(tree) = commit.tree() {
189 tree.iter().any(|entry| {
190 entry.name().map_or(false, |n| {
191 n == &rel_str || n.starts_with(&format!("{}/", rel_str))
192 })
193 })
194 } else {
195 false
196 }
197 } else {
198 false
199 }
200 })
201 .count()
202}
203
204fn get_github_repo(repo_path: &Path) -> Option<String> {
205 let repo = git2::Repository::open(repo_path).ok()?;
206 let remote = repo.find_remote("origin").ok()?;
207 let url = remote.url()?;
208 let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
209 let caps = re.captures(url)?;
210 Some(caps.get(1)?.as_str().to_string())
211}
212
213fn check_all_configs(repo_path: &Path, other_scope_dirs: &[std::path::PathBuf], expected: &str) {
214 let checks: [(&str, fn(&str) -> Option<String>); 5] = [
215 ("Cargo.toml", |c| extract_kv(c, "version")),
216 ("pyproject.toml", |c| extract_kv(c, "version")),
217 ("package.json", extract_json_version),
218 ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
219 ("setup.cfg", |c| extract_kv(c, "version")),
220 ];
221 for (name, extract) in &checks {
222 let content = match std::fs::read_to_string(&repo_path.join(name)) {
223 Ok(c) => c,
224 Err(_) => continue,
225 };
226 match extract(&content) {
227 Some(v) if v == expected => println!(" {:<15} {} ✅", format!("{}:", name), v),
228 Some(v) => println!(
229 " {:<15} {} ❌ (期望 {})",
230 format!("{}:", name),
231 v,
232 expected
233 ),
234 None => println!(" {:<15} (未找到版本字段)", format!("{}:", name)),
235 }
236 }
237 let vf = repo_path.join("VERSION");
238 if let Ok(c) = std::fs::read_to_string(&vf) {
239 let v = c.trim().to_string();
240 if !v.is_empty() {
241 if v == expected {
242 println!(" VERSION {} ✅", v);
243 } else {
244 println!(" VERSION {} ❌ (期望 {})", v, expected);
245 }
246 }
247 }
248 for p in find_go_files(repo_path, other_scope_dirs) {
249 let content = match std::fs::read_to_string(&p) {
250 Ok(c) => c,
251 Err(_) => continue,
252 };
253 for prefix in &[
254 "var Version = \"",
255 "var VERSION = \"",
256 "const Version = \"",
257 "const VERSION = \"",
258 ] {
259 for line in content.lines() {
260 let t = line.trim();
261 if let Some(rest) = t.strip_prefix(prefix) {
262 if let Some(end) = rest.find('"') {
263 let v = rest[..end].to_string();
264 if !v.is_empty() {
265 let rel = p.strip_prefix(repo_path).unwrap_or(&p);
266 let name = rel.to_string_lossy();
267 if v == expected {
268 println!(" {:<15} {} ✅", format!("{}:", name), v);
269 } else {
270 println!(
271 " {:<15} {} ❌ (期望 {})",
272 format!("{}:", name),
273 v,
274 expected
275 );
276 }
277 }
278 }
279 }
280 }
281 }
282 }
283}
284
285fn find_go_files(dir: &Path, excludes: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
286 let mut files = Vec::new();
287 let entries = match std::fs::read_dir(dir) {
288 Ok(e) => e,
289 Err(_) => return files,
290 };
291 for entry in entries.flatten() {
292 let p = entry.path();
293 if p.is_dir() {
294 if excludes.iter().any(|e| p == *e) {
295 continue;
296 }
297 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
298 if !name.starts_with('.')
299 && name != "node_modules"
300 && name != "target"
301 && name != "vendor"
302 {
303 files.extend(find_go_files(&p, excludes));
304 }
305 } else if p.extension().and_then(|e| e.to_str()) == Some("go") {
306 files.push(p);
307 }
308 }
309 files
310}
311
312fn extract_kv(content: &str, key: &str) -> Option<String> {
313 let p1 = format!("{} = \"", key);
314 let p2 = format!("{} = '", key);
315 for line in content.lines() {
316 let t = line.trim();
317 if let Some(r) = t.strip_prefix(&p1) {
318 if let Some(e) = r.find('"') {
319 let v = r[..e].to_string();
320 if !v.is_empty() {
321 return Some(v);
322 }
323 }
324 }
325 if let Some(r) = t.strip_prefix(&p2) {
326 if let Some(e) = r.find('\'') {
327 let v = r[..e].to_string();
328 if !v.is_empty() {
329 return Some(v);
330 }
331 }
332 }
333 }
334 None
335}
336
337fn extract_json_version(content: &str) -> Option<String> {
338 for line in content.lines() {
339 let t = line.trim();
340 if let Some(pos) = t.find("\"version\":") {
342 let after_colon = t[pos + "\"version\":".len()..].trim();
343 let value_start = after_colon.find('"')?;
345 let after_open = &after_colon[value_start + 1..];
346 let value_end = after_open.find('"')?;
348 let v = &after_open[..value_end];
349 if !v.is_empty() {
350 return Some(v.to_string());
351 }
352 }
353 }
354 None
355}
356
357fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
358 let p = format!("{}:", key);
359 for line in content.lines() {
360 let t = line.trim();
361 if let Some(r) = t.strip_prefix(&p) {
362 let v = r.trim();
363 if !v.is_empty() && !v.starts_with('#') {
364 return Some(v.to_string());
365 }
366 }
367 }
368 None
369}
370
371fn check_changelog(repo_path: &Path, version: &str) -> bool {
372 if version.is_empty() {
373 return false;
374 }
375 std::fs::read_to_string(repo_path.join("CHANGELOG.md"))
376 .unwrap_or_default()
377 .contains(&format!("[{}]", version))
378}
379
380fn is_dirty(repo_path: &Path) -> bool {
381 let repo = match git2::Repository::open(repo_path) {
382 Ok(r) => r,
383 Err(_) => return false,
384 };
385 repo.statuses(None).map_or(false, |s| !s.is_empty())
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
395 fn test_extract_kv_double_quotes() {
396 assert_eq!(
397 extract_kv("version = \"1.0.0\"\n", "version"),
398 Some("1.0.0".into())
399 );
400 }
401
402 #[test]
403 fn test_extract_kv_single_quotes() {
404 assert_eq!(
405 extract_kv("version = '2.0.0'\n", "version"),
406 Some("2.0.0".into())
407 );
408 }
409
410 #[test]
411 fn test_extract_kv_missing_key() {
412 assert_eq!(extract_kv("name = \"foo\"\n", "version"), None);
413 }
414
415 #[test]
416 fn test_extract_kv_empty_value() {
417 assert_eq!(extract_kv("version = \"\"\n", "version"), None);
418 }
419
420 #[test]
421 fn test_extract_kv_indented() {
422 assert_eq!(
423 extract_kv(" version = \"0.5.0\"\n", "version"),
424 Some("0.5.0".into())
425 );
426 }
427
428 #[test]
431 fn test_extract_json_version_normal() {
432 let content = "{\n \"version\": \"1.0.0\",\n}\n";
433 assert_eq!(extract_json_version(content), Some("1.0.0".into()));
434 }
435
436 #[test]
437 fn test_extract_json_version_single_line() {
438 let content = r#"{"name":"foo","version":"2.0.0"}"#;
439 assert_eq!(extract_json_version(content), Some("2.0.0".into()));
440 }
441
442 #[test]
443 fn test_extract_json_version_trailing_comma() {
444 let content = r#"{"version":"1.0.0",}"#;
445 assert_eq!(extract_json_version(content), Some("1.0.0".into()));
446 }
447
448 #[test]
449 fn test_extract_json_version_missing() {
450 let content = r#"{"name":"foo"}"#;
451 assert_eq!(extract_json_version(content), None);
452 }
453
454 #[test]
455 fn test_extract_json_version_empty() {
456 let content = r#"{"version":""}"#;
457 assert_eq!(extract_json_version(content), None);
458 }
459
460 #[test]
463 fn test_extract_kv_yaml_normal() {
464 assert_eq!(
465 extract_kv_yaml("version: 1.0.0\n", "version"),
466 Some("1.0.0".into())
467 );
468 }
469
470 #[test]
471 fn test_extract_kv_yaml_indented() {
472 assert_eq!(
473 extract_kv_yaml(" version: 3.0.0\n", "version"),
474 Some("3.0.0".into())
475 );
476 }
477
478 #[test]
479 fn test_extract_kv_yaml_ignores_comment() {
480 assert_eq!(extract_kv_yaml("version: # 注释\n", "version"), None);
481 }
482
483 #[test]
484 fn test_extract_kv_yaml_missing() {
485 assert_eq!(extract_kv_yaml("name: foo\n", "version"), None);
486 }
487
488 #[test]
489 fn test_extract_kv_yaml_empty_value() {
490 assert_eq!(extract_kv_yaml("version:\n", "version"), None);
491 }
492
493 #[test]
494 fn test_collect_tags_empty() {
495 assert!(collect_latest_tags(&[]).is_empty());
496 }
497
498 #[test]
499 fn test_collect_tags_root_only() {
500 let tags = collect_latest_tags(&["v2.0.0", "v1.0.0"]);
501 assert_eq!(tags.len(), 1);
502 assert_eq!(tags[0].0, "(root)");
503 assert_eq!(tags[0].1, "v2.0.0");
504 }
505
506 #[test]
507 fn test_collect_tags_scoped() {
508 let tags = collect_latest_tags(&["cli/v0.1.0", "web/v0.2.0"]);
509 assert_eq!(tags.len(), 2);
510 assert_eq!(tags[0].0, "cli");
511 assert_eq!(tags[1].0, "web");
512 }
513
514 #[test]
515 fn test_collect_tags_prerelease_is_kept() {
516 let tags = collect_latest_tags(&["cli/v0.2.0-rc.1", "cli/v0.1.0"]);
518 assert_eq!(tags.len(), 1);
519 assert_eq!(tags[0].1, "cli/v0.2.0-rc.1");
520 }
521
522 #[test]
523 fn test_collect_tags_prerelease_as_fallback() {
524 let tags = collect_latest_tags(&["cli/v0.1.0-rc.2", "cli/v0.1.0-rc.1"]);
525 assert_eq!(tags.len(), 1);
526 assert_eq!(tags[0].1, "cli/v0.1.0-rc.2");
527 }
528
529 fn with_mock_path<F: FnOnce(&Path) -> R, R>(scripts: &[(&str, &str)], f: F) -> R {
531 let dir = tempfile::tempdir().unwrap();
532 let bin = dir.path().join("bin");
533 std::fs::create_dir(&bin).unwrap();
534 for (name, body) in scripts {
535 let path = bin.join(name);
536 std::fs::write(&path, body).unwrap();
537 #[cfg(unix)]
538 std::process::Command::new("chmod")
539 .args(["+x", path.to_str().unwrap()])
540 .output()
541 .unwrap();
542 }
543 let old_path = std::env::var("PATH").unwrap_or_default();
544 std::env::set_var("PATH", format!("{}:{}", bin.display(), old_path));
545 let result = f(dir.path());
546 std::env::set_var("PATH", &old_path);
547 result
548 }
549
550 const GH_NOT_FOUND: &str = "#!/bin/sh\nexit 1\n";
551 const GH_WITH_BODY: &str = "#!/bin/sh\necho '{\"body\":\"content\"}'\n";
552
553 #[test]
554 fn test_status_gh_not_found() {
555 let dir = tempfile::tempdir().unwrap();
557 git_init_test(dir.path());
558 git_tag_test(dir.path(), "v1.0.0");
559 set_remote(dir.path());
560 with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
561 status(dir.path());
562 });
563 }
564
565 #[test]
566 fn test_status_gh_with_body() {
567 let dir = tempfile::tempdir().unwrap();
569 git_init_test(dir.path());
570 std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
571 git_commit_test(dir.path());
572 git_tag_test(dir.path(), "v1.0.0");
573 set_remote(dir.path());
574 with_mock_path(&[("gh", GH_WITH_BODY)], |_| {
575 status(dir.path());
576 });
577 }
578
579 #[test]
580 fn test_status_custom_tags() {
581 let dir = tempfile::tempdir().unwrap();
583 git_init_test(dir.path());
585 git_tag_test(dir.path(), "cli/v0.1.0");
586 git_tag_test(dir.path(), "web/v0.2.0");
587 set_remote(dir.path());
588 with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
589 status(dir.path());
590 });
591 }
592
593 fn git_init_test(path: &Path) {
596 std::process::Command::new("git")
597 .args(["init", "-b", "main"])
598 .current_dir(path)
599 .output()
600 .unwrap();
601 std::process::Command::new("git")
602 .args(["config", "user.email", "t@t"])
603 .current_dir(path)
604 .output()
605 .unwrap();
606 std::process::Command::new("git")
607 .args(["config", "user.name", "t"])
608 .current_dir(path)
609 .output()
610 .unwrap();
611 std::fs::write(path.join("f"), "").unwrap();
612 std::process::Command::new("git")
613 .args(["add", "."])
614 .current_dir(path)
615 .output()
616 .unwrap();
617 std::process::Command::new("git")
618 .args(["commit", "-m", "init"])
619 .current_dir(path)
620 .output()
621 .unwrap();
622 }
623
624 fn git_commit_test(path: &Path) {
625 std::fs::write(path.join("f"), "x").unwrap();
626 std::process::Command::new("git")
627 .args(["add", "."])
628 .current_dir(path)
629 .output()
630 .unwrap();
631 std::process::Command::new("git")
632 .args(["commit", "-m", "x"])
633 .current_dir(path)
634 .output()
635 .unwrap();
636 }
637
638 fn git_tag_test(path: &Path, tag: &str) {
639 std::process::Command::new("git")
640 .args(["-C", path.to_str().unwrap(), "tag", tag])
641 .output()
642 .unwrap();
643 }
644
645 fn set_remote(path: &Path) {
646 std::process::Command::new("git")
647 .args([
648 "-C",
649 path.to_str().unwrap(),
650 "remote",
651 "add",
652 "origin",
653 "https://github.com/owner/repo.git",
654 ])
655 .output()
656 .unwrap();
657 }
658
659 #[test]
660 fn test_collect_tags_mixed_root_and_scoped() {
661 let tags = collect_latest_tags(&["v1.0.0", "cli/v0.2.0", "cli/v0.1.0"]);
662 assert_eq!(tags.len(), 2);
663 let root = tags.iter().find(|(s, _)| s == "(root)").unwrap();
664 assert_eq!(root.1, "v1.0.0");
665 let cli = tags.iter().find(|(s, _)| s == "cli").unwrap();
666 assert_eq!(cli.1, "cli/v0.2.0");
667 }
668}