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 out = std::process::Command::new("git")
131 .args([
132 "-C",
133 &repo_path.to_string_lossy(),
134 "tag",
135 "--sort=-version:refname",
136 ])
137 .output()
138 .ok();
139 let out = match out {
140 Some(o) if o.status.success() => o,
141 _ => return vec![],
142 };
143 let all: Vec<&str> = std::str::from_utf8(&out.stdout)
144 .unwrap_or("")
145 .lines()
146 .collect();
147 collect_latest_tags(&all)
148}
149
150pub fn collect_latest_tags(tags: &[&str]) -> Vec<(String, String)> {
151 let mut scopes: Vec<(String, String)> = Vec::new();
152 for t in tags {
153 let scope = if t.contains('/') {
154 t.split('/').next().unwrap_or("").to_string()
155 } else {
156 "(root)".to_string()
157 };
158 let tag_only = t.split('/').last().unwrap_or(t);
159 let pre = tag_only.contains('-');
160 if let Some(pos) = scopes.iter().position(|(s, _)| s == &scope) {
161 let et = scopes[pos].1.split('/').last().unwrap_or(&scopes[pos].1);
162 if !pre && et.contains('-') {
163 scopes[pos] = (scope, t.to_string());
164 }
165 } else {
166 scopes.push((scope, t.to_string()));
167 }
168 }
169 scopes
170}
171
172fn count_unreleased_in_dir(repo_path: &Path, tag: &str, scope_dir: &Path) -> usize {
173 let range = format!("{}..HEAD", tag);
174 if scope_dir == repo_path {
175 let out = std::process::Command::new("git")
176 .args([
177 "-C",
178 &repo_path.to_string_lossy(),
179 "rev-list",
180 "--count",
181 &range,
182 ])
183 .output()
184 .ok();
185 return match out {
186 Some(o) if o.status.success() => std::str::from_utf8(&o.stdout)
187 .unwrap_or("0")
188 .trim()
189 .parse()
190 .unwrap_or(0),
191 _ => 0,
192 };
193 }
194 let rel = scope_dir.strip_prefix(repo_path).unwrap_or(scope_dir);
195 let rel_str = rel.to_string_lossy().trim_start_matches('/').to_string();
196 let out = std::process::Command::new("git")
197 .args([
198 "-C",
199 &repo_path.to_string_lossy(),
200 "rev-list",
201 "--count",
202 &range,
203 "--",
204 &rel_str,
205 ])
206 .output()
207 .ok();
208 match out {
209 Some(o) if o.status.success() => std::str::from_utf8(&o.stdout)
210 .unwrap_or("0")
211 .trim()
212 .parse()
213 .unwrap_or(0),
214 _ => 0,
215 }
216}
217
218fn get_github_repo(repo_path: &Path) -> Option<String> {
219 let out = std::process::Command::new("git")
220 .args([
221 "-C",
222 &repo_path.to_string_lossy(),
223 "remote",
224 "get-url",
225 "origin",
226 ])
227 .output()
228 .ok()?;
229 if !out.status.success() {
230 return None;
231 }
232 let url = std::str::from_utf8(&out.stdout).ok()?.trim().to_string();
233 let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
234 let caps = re.captures(&url)?;
235 Some(caps.get(1)?.as_str().to_string())
236}
237
238fn check_all_configs(repo_path: &Path, other_scope_dirs: &[std::path::PathBuf], expected: &str) {
239 let checks: [(&str, fn(&str) -> Option<String>); 5] = [
240 ("Cargo.toml", |c| extract_kv(c, "version")),
241 ("pyproject.toml", |c| extract_kv(c, "version")),
242 ("package.json", extract_json_version),
243 ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
244 ("setup.cfg", |c| extract_kv(c, "version")),
245 ];
246 for (name, extract) in &checks {
247 let content = match std::fs::read_to_string(&repo_path.join(name)) {
248 Ok(c) => c,
249 Err(_) => continue,
250 };
251 match extract(&content) {
252 Some(v) if v == expected => println!(" {:<15} {} ✅", format!("{}:", name), v),
253 Some(v) => println!(
254 " {:<15} {} ❌ (期望 {})",
255 format!("{}:", name),
256 v,
257 expected
258 ),
259 None => println!(" {:<15} (未找到版本字段)", format!("{}:", name)),
260 }
261 }
262 let vf = repo_path.join("VERSION");
263 if let Ok(c) = std::fs::read_to_string(&vf) {
264 let v = c.trim().to_string();
265 if !v.is_empty() {
266 if v == expected {
267 println!(" VERSION {} ✅", v);
268 } else {
269 println!(" VERSION {} ❌ (期望 {})", v, expected);
270 }
271 }
272 }
273 for p in find_go_files(repo_path, other_scope_dirs) {
274 let content = match std::fs::read_to_string(&p) {
275 Ok(c) => c,
276 Err(_) => continue,
277 };
278 for prefix in &[
279 "var Version = \"",
280 "var VERSION = \"",
281 "const Version = \"",
282 "const VERSION = \"",
283 ] {
284 for line in content.lines() {
285 let t = line.trim();
286 if let Some(rest) = t.strip_prefix(prefix) {
287 if let Some(end) = rest.find('"') {
288 let v = rest[..end].to_string();
289 if !v.is_empty() {
290 let rel = p.strip_prefix(repo_path).unwrap_or(&p);
291 let name = rel.to_string_lossy();
292 if v == expected {
293 println!(" {:<15} {} ✅", format!("{}:", name), v);
294 } else {
295 println!(
296 " {:<15} {} ❌ (期望 {})",
297 format!("{}:", name),
298 v,
299 expected
300 );
301 }
302 }
303 }
304 }
305 }
306 }
307 }
308}
309
310fn find_go_files(dir: &Path, excludes: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
311 let mut files = Vec::new();
312 let entries = match std::fs::read_dir(dir) {
313 Ok(e) => e,
314 Err(_) => return files,
315 };
316 for entry in entries.flatten() {
317 let p = entry.path();
318 if p.is_dir() {
319 if excludes.iter().any(|e| p == *e) {
320 continue;
321 }
322 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
323 if !name.starts_with('.')
324 && name != "node_modules"
325 && name != "target"
326 && name != "vendor"
327 {
328 files.extend(find_go_files(&p, excludes));
329 }
330 } else if p.extension().and_then(|e| e.to_str()) == Some("go") {
331 files.push(p);
332 }
333 }
334 files
335}
336
337fn extract_kv(content: &str, key: &str) -> Option<String> {
338 let p1 = format!("{} = \"", key);
339 let p2 = format!("{} = '", key);
340 for line in content.lines() {
341 let t = line.trim();
342 if let Some(r) = t.strip_prefix(&p1) {
343 if let Some(e) = r.find('"') {
344 let v = r[..e].to_string();
345 if !v.is_empty() {
346 return Some(v);
347 }
348 }
349 }
350 if let Some(r) = t.strip_prefix(&p2) {
351 if let Some(e) = r.find('\'') {
352 let v = r[..e].to_string();
353 if !v.is_empty() {
354 return Some(v);
355 }
356 }
357 }
358 }
359 None
360}
361
362fn extract_json_version(content: &str) -> Option<String> {
363 for line in content.lines() {
364 let t = line.trim();
365 if let Some(r) = t.strip_prefix("\"version\":") {
366 let v = r
367 .trim()
368 .trim_matches('"')
369 .trim_matches('\'')
370 .trim_matches(',');
371 if !v.is_empty() {
372 return Some(v.to_string());
373 }
374 }
375 }
376 None
377}
378
379fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
380 let p = format!("{}:", key);
381 for line in content.lines() {
382 let t = line.trim();
383 if let Some(r) = t.strip_prefix(&p) {
384 let v = r.trim();
385 if !v.is_empty() && !v.starts_with('#') {
386 return Some(v.to_string());
387 }
388 }
389 }
390 None
391}
392
393fn check_changelog(repo_path: &Path, version: &str) -> bool {
394 if version.is_empty() {
395 return false;
396 }
397 std::fs::read_to_string(repo_path.join("CHANGELOG.md"))
398 .unwrap_or_default()
399 .contains(&format!("[{}]", version))
400}
401
402fn is_dirty(repo_path: &Path) -> bool {
403 let out = std::process::Command::new("git")
404 .args(["-C", &repo_path.to_string_lossy(), "status", "--porcelain"])
405 .output()
406 .ok();
407 match out {
408 Some(o) => !o.stdout.is_empty(),
409 None => false,
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn test_collect_tags_empty() {
419 assert!(collect_latest_tags(&[]).is_empty());
420 }
421
422 #[test]
423 fn test_collect_tags_root_only() {
424 let tags = collect_latest_tags(&["v2.0.0", "v1.0.0"]);
425 assert_eq!(tags.len(), 1);
426 assert_eq!(tags[0].0, "(root)");
427 assert_eq!(tags[0].1, "v2.0.0");
428 }
429
430 #[test]
431 fn test_collect_tags_scoped() {
432 let tags = collect_latest_tags(&["cli/v0.1.0", "web/v0.2.0"]);
433 assert_eq!(tags.len(), 2);
434 assert_eq!(tags[0].0, "cli");
435 assert_eq!(tags[1].0, "web");
436 }
437
438 #[test]
439 fn test_collect_tags_prerelease_not_preferred() {
440 let tags = collect_latest_tags(&["cli/v0.2.0-rc.1", "cli/v0.1.0"]);
441 assert_eq!(tags.len(), 1);
442 assert_eq!(tags[0].1, "cli/v0.1.0");
443 }
444
445 #[test]
446 fn test_collect_tags_prerelease_as_fallback() {
447 let tags = collect_latest_tags(&["cli/v0.1.0-rc.2", "cli/v0.1.0-rc.1"]);
448 assert_eq!(tags.len(), 1);
449 assert_eq!(tags[0].1, "cli/v0.1.0-rc.2");
450 }
451
452 #[test]
453 fn test_collect_tags_no_release_upgrades_prerelease() {
454 let tags = collect_latest_tags(&["cli/v0.1.0-rc.2", "cli/v0.1.0-rc.1"]);
456 assert_eq!(tags[0].1, "cli/v0.1.0-rc.2");
457 }
458
459 fn with_mock_path<F: FnOnce(&Path) -> R, R>(scripts: &[(&str, &str)], f: F) -> R {
461 let dir = tempfile::tempdir().unwrap();
462 let bin = dir.path().join("bin");
463 std::fs::create_dir(&bin).unwrap();
464 for (name, body) in scripts {
465 let path = bin.join(name);
466 std::fs::write(&path, body).unwrap();
467 #[cfg(unix)]
468 std::process::Command::new("chmod")
469 .args(["+x", path.to_str().unwrap()])
470 .output()
471 .unwrap();
472 }
473 let old_path = std::env::var("PATH").unwrap_or_default();
474 std::env::set_var("PATH", format!("{}:{}", bin.display(), old_path));
475 let result = f(dir.path());
476 std::env::set_var("PATH", &old_path);
477 result
478 }
479
480 const GH_NOT_FOUND: &str = "#!/bin/sh\nexit 1\n";
481 const GH_WITH_BODY: &str = "#!/bin/sh\necho '{\"body\":\"content\"}'\n";
482
483 #[test]
484 fn test_status_gh_not_found() {
485 let dir = tempfile::tempdir().unwrap();
487 git_init_test(dir.path());
488 git_tag_test(dir.path(), "v1.0.0");
489 set_remote(dir.path());
490 with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
491 status(dir.path());
492 });
493 }
494
495 #[test]
496 fn test_status_gh_with_body() {
497 let dir = tempfile::tempdir().unwrap();
499 git_init_test(dir.path());
500 std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
501 git_commit_test(dir.path());
502 git_tag_test(dir.path(), "v1.0.0");
503 set_remote(dir.path());
504 with_mock_path(&[("gh", GH_WITH_BODY)], |_| {
505 status(dir.path());
506 });
507 }
508
509 #[test]
510 fn test_status_custom_tags() {
511 let dir = tempfile::tempdir().unwrap();
513 git_init_test(dir.path());
515 git_tag_test(dir.path(), "cli/v0.1.0");
516 git_tag_test(dir.path(), "web/v0.2.0");
517 set_remote(dir.path());
518 with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
519 status(dir.path());
520 });
521 }
522
523 fn git_init_test(path: &Path) {
526 std::process::Command::new("git")
527 .args(["init", "-b", "main"])
528 .current_dir(path)
529 .output()
530 .unwrap();
531 std::process::Command::new("git")
532 .args(["config", "user.email", "t@t"])
533 .current_dir(path)
534 .output()
535 .unwrap();
536 std::process::Command::new("git")
537 .args(["config", "user.name", "t"])
538 .current_dir(path)
539 .output()
540 .unwrap();
541 std::fs::write(path.join("f"), "").unwrap();
542 std::process::Command::new("git")
543 .args(["add", "."])
544 .current_dir(path)
545 .output()
546 .unwrap();
547 std::process::Command::new("git")
548 .args(["commit", "-m", "init"])
549 .current_dir(path)
550 .output()
551 .unwrap();
552 }
553
554 fn git_commit_test(path: &Path) {
555 std::fs::write(path.join("f"), "x").unwrap();
556 std::process::Command::new("git")
557 .args(["add", "."])
558 .current_dir(path)
559 .output()
560 .unwrap();
561 std::process::Command::new("git")
562 .args(["commit", "-m", "x"])
563 .current_dir(path)
564 .output()
565 .unwrap();
566 }
567
568 fn git_tag_test(path: &Path, tag: &str) {
569 std::process::Command::new("git")
570 .args(["-C", path.to_str().unwrap(), "tag", tag])
571 .output()
572 .unwrap();
573 }
574
575 fn set_remote(path: &Path) {
576 std::process::Command::new("git")
577 .args([
578 "-C",
579 path.to_str().unwrap(),
580 "remote",
581 "add",
582 "origin",
583 "https://github.com/owner/repo.git",
584 ])
585 .output()
586 .unwrap();
587 }
588
589 #[test]
590 fn test_collect_tags_mixed_root_and_scoped() {
591 let tags = collect_latest_tags(&["v1.0.0", "cli/v0.2.0", "cli/v0.1.0"]);
592 assert_eq!(tags.len(), 2);
593 let root = tags.iter().find(|(s, _)| s == "(root)").unwrap();
594 assert_eq!(root.1, "v1.0.0");
595 let cli = tags.iter().find(|(s, _)| s == "cli").unwrap();
596 assert_eq!(cli.1, "cli/v0.2.0");
597 }
598}