1use std::path::Path;
2
3use crate::contract;
4
5#[derive(Debug, PartialEq)]
7struct CiRun {
8 conclusion: String,
9 title: String,
10 branch: String,
11 number: String,
12}
13
14pub fn status(repo_path: &Path) {
16 let mut stdout = std::io::stdout();
17 status_to(&mut stdout, repo_path).ok();
18}
19
20pub fn status_to(writer: &mut impl std::io::Write, repo_path: &Path) -> std::io::Result<()> {
21 let c = contract::load(repo_path);
22
23 writeln!(writer, "构建状态")?;
24 writeln!(writer, "{}", "-".repeat(50))?;
25
26 if c.scopes.is_empty() {
27 let lang = contract::detect_by_files(repo_path);
28 let root_scope = contract::Scope {
29 name: "(root)".into(),
30 dir: ".".into(),
31 language: lang.clone(),
32 framework: String::new(),
33 build_tool: contract::BuildTool::Unknown(String::new()),
34 registry: contract::Registry::None,
35 release: contract::StageRelease::default(),
36 test_threshold: None,
37 ci_workflow: None,
38 };
39 let vs = contract::version_status(repo_path, &root_scope);
40 let release = c.scope_release(&root_scope);
41 print_scope(writer, "(root)", repo_path, &lang, &c, &vs, &release)?;
42 } else {
43 for scope in &c.scopes {
44 let scope_dir = repo_path.join(&scope.dir);
45 if !scope_dir.exists() {
46 writeln!(writer, " [{}] ⚠ 目录不存在: {}", scope.name, scope.dir)?;
47 continue;
48 }
49 let lang = c.resolve_language(scope, &scope_dir);
50 let vs = contract::version_status(repo_path, scope);
51 let release = c.scope_release(scope);
52 print_scope(writer, &scope.name, &scope_dir, &lang, &c, &vs, &release)?;
53 }
54 }
55
56 let dirty = is_working_tree_dirty(repo_path);
57 writeln!(
58 writer,
59 " {} {}",
60 "工作区".to_string(),
61 if dirty {
62 "⚠ 有未提交变更"
63 } else {
64 "✅ 干净"
65 }
66 )?;
67 Ok(())
68}
69
70fn print_scope(
71 writer: &mut impl std::io::Write,
72 name: &str,
73 dir: &Path,
74 lang: &contract::Language,
75 c: &contract::Contract,
76 vs: &contract::VersionState,
77 release: &contract::StageRelease,
78) -> std::io::Result<()> {
79 writeln!(writer, " [{:<12}] {}", name, lang.as_str())?;
80 writeln!(writer, " CI: {}", check_ci(name, None))?;
81 writeln!(writer, " build: {}", check_syntax(lang, dir))?;
82 match (&vs.tag_version, &vs.config_version) {
83 (Some(t), Some(_)) if vs.consistent => {
84 writeln!(writer, " version: ✅ {}(一致)", t)?
85 }
86 (Some(t), Some(_)) => writeln!(writer, " version: ⚠ {}(配置不一致)", t)?,
87 (Some(t), None) => writeln!(writer, " version: tag {}(无配置文件)", t)?,
88 (None, Some(_)) => writeln!(writer, " version: 有配置版本(无 tag)")?,
89 (None, None) => writeln!(writer, " version: 暂无发布")?,
90 }
91 for (fname, ver) in &vs.config_files {
92 match (ver, &vs.tag_version) {
93 (Some(v), Some(t)) if v == t => {
94 writeln!(writer, " {:<15} {} ✅", format!("{}:", fname), v)?
95 }
96 (Some(v), Some(_)) => writeln!(
97 writer,
98 " {:<15} {} ❌(期望 {})",
99 format!("{}:", fname),
100 v,
101 vs.tag_version.as_deref().unwrap_or("?")
102 )?,
103 (Some(v), None) => writeln!(
104 writer,
105 " {:<15} {}(无 tag)",
106 format!("{}:", fname),
107 v
108 )?,
109 (None, _) => writeln!(
110 writer,
111 " {:<15} (未找到版本字段)",
112 format!("{}:", fname)
113 )?,
114 }
115 }
116 writeln!(writer, " registry: {:?}", c.platform.artifact_registry)?;
117 writeln!(writer, " deps: {}", check_dependencies(dir))?;
118 writeln!(writer, " changelog: {}", release.changelog)?;
119 Ok(())
120}
121
122pub fn resolve_workflow(scope: &str, ci_workflow: Option<&str>) -> String {
124 match ci_workflow {
125 Some(w) => w.to_string(),
126 None => format!("build-{}", scope),
127 }
128}
129
130fn parse_gh_run_list(output: &str) -> Option<CiRun> {
135 let conclusion = output
136 .split("\"conclusion\":")
137 .nth(1)
138 .and_then(|s| s.split('"').nth(1))?;
139 if conclusion.is_empty() {
140 return None;
141 }
142 let title = output
143 .split("\"displayTitle\":")
144 .nth(1)
145 .and_then(|s| s.split('"').nth(1))
146 .unwrap_or("");
147 let branch = output
148 .split("\"headBranch\":")
149 .nth(1)
150 .and_then(|s| s.split('"').nth(1))
151 .unwrap_or("?");
152 let number: String = output
153 .split("\"number\":")
154 .nth(1)
155 .map(|s| s.chars().take_while(|c| c.is_ascii_digit()).collect())
156 .filter(|s: &String| !s.is_empty())
157 .unwrap_or_else(|| "?".into());
158
159 Some(CiRun {
160 conclusion: conclusion.to_string(),
161 title: title.to_string(),
162 branch: branch.to_string(),
163 number,
164 })
165}
166
167fn check_ci(scope: &str, ci_workflow: Option<&str>) -> String {
168 let workflow = resolve_workflow(scope, ci_workflow);
169 let output = match std::process::Command::new("gh")
170 .args([
171 "run",
172 "list",
173 "--limit",
174 "1",
175 "--workflow",
176 &workflow,
177 "--json",
178 "conclusion,displayTitle,headBranch,number",
179 ])
180 .output()
181 {
182 Ok(o) if o.status.success() => o.stdout,
183 Ok(_) => return "⚠ 无 CI 运行记录".into(),
184 Err(_) => return "⚠ gh CLI 未安装".into(),
185 };
186
187 let out = String::from_utf8_lossy(&output);
188 match parse_gh_run_list(&out) {
189 Some(run) => match run.conclusion.as_str() {
190 "success" => format!("✅ {} ({} #{})", run.title, run.branch, run.number),
191 "failure" => format!("❌ {} ({} #{})", run.title, run.branch, run.number),
192 "cancelled" => format!("🔶 {} 已取消", run.title),
193 s => format!("⏳ {} ({}) - {}", run.title, run.branch, s),
194 },
195 None => "⚠ 无 CI 运行记录".into(),
196 }
197}
198
199fn check_command(lang: &contract::Language) -> Option<(&'static str, &'static str)> {
201 match lang {
202 contract::Language::Rust => Some(("cargo", "cargo check")),
203 contract::Language::Python => Some(("uv", "uv check")),
204 contract::Language::Go => Some(("go", "go vet")),
205 contract::Language::Dart => Some(("dart", "dart analyze")),
206 contract::Language::TypeScript => Some(("npx", "tsc --noEmit")),
207 contract::Language::Unknown(_) => None,
208 }
209}
210
211fn check_manifest_file(lang: &contract::Language) -> Option<&'static str> {
213 match lang {
214 contract::Language::Rust => Some("Cargo.toml"),
215 contract::Language::Python => Some("pyproject.toml"),
216 contract::Language::Go => Some("go.mod"),
217 contract::Language::Dart => Some("pubspec.yaml"),
218 contract::Language::TypeScript => Some("package.json"),
219 contract::Language::Unknown(_) => None,
220 }
221}
222
223fn check_args(lang: &contract::Language, dir: &Path) -> Option<Vec<String>> {
225 match lang {
226 contract::Language::Rust => {
227 let mp = dir.join("Cargo.toml");
228 Some(vec![
229 "check".into(),
230 "--manifest-path".into(),
231 mp.to_string_lossy().to_string(),
232 ])
233 }
234 contract::Language::Python => Some(vec!["check".into()]),
235 contract::Language::Go => Some(vec!["vet".into(), "./...".into()]),
236 contract::Language::Dart => Some(vec!["analyze".into()]),
237 contract::Language::TypeScript => Some(vec!["tsc".into(), "--noEmit".into()]),
238 contract::Language::Unknown(_) => None,
239 }
240}
241
242fn check_syntax(lang: &contract::Language, dir: &Path) -> String {
243 let (cmd, label) = match check_command(lang) {
244 Some(x) => x,
245 None => return "⚠ 语言未知,跳过".into(),
246 };
247 if let Some(mf) = check_manifest_file(lang) {
248 if !dir.join(mf).exists() {
249 return "—".into();
250 }
251 }
252 let args = match check_args(lang, dir) {
253 Some(a) => a,
254 None => return "⚠ 语言未知,跳过".into(),
255 };
256 match std::process::Command::new(cmd)
257 .args(&args)
258 .current_dir(dir)
259 .output()
260 {
261 Ok(o) if o.status.success() => format!("✅ {} 通过", label),
262 Ok(_) => format!("❌ {} 失败", label),
263 Err(_) => format!("⚠ {} 未安装", cmd),
264 }
265}
266
267fn is_working_tree_dirty(repo_path: &Path) -> bool {
268 std::process::Command::new("git")
269 .args(["status", "--porcelain"])
270 .current_dir(repo_path)
271 .output()
272 .map(|o| !o.stdout.is_empty())
273 .unwrap_or(false)
274}
275
276fn check_dependencies(dir: &Path) -> String {
278 let cargo_toml = dir.join("Cargo.toml");
279 if !cargo_toml.exists() {
280 return "—".into();
281 }
282 let content = match std::fs::read_to_string(&cargo_toml) {
283 Ok(c) => c,
284 Err(_) => return "⚠ 无法读取".into(),
285 };
286
287 let mut in_deps = false;
289 let mut issues: Vec<&str> = Vec::new();
290 for line in content.lines() {
291 let t = line.trim();
292 if t.starts_with('[') {
293 in_deps = t == "[dependencies]"
294 || t.starts_with("[dependencies.")
295 || t == "[dev-dependencies]"
296 || t.starts_with("[dev-dependencies.");
297 continue;
298 }
299 if !in_deps || t.starts_with('#') || t.is_empty() {
300 continue;
301 }
302 if t.contains("path = \"") && !t.contains("\"\"") {
303 issues.push("path");
304 }
305 if t.contains("git = \"") && !t.contains("rev = \"") {
306 issues.push("git (no rev)");
307 }
308 }
309
310 if issues.is_empty() {
311 "✅ crates.io".into()
312 } else {
313 format!("⚠ {}", issues.join(", "))
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn test_print_scope_all_ok() {
323 let d = tempfile::tempdir().unwrap();
324 let c = contract::load(d.path());
325 let vs = contract::VersionState {
326 tag_version: Some("0.1.0".into()),
327 config_version: Some("0.1.0".into()),
328 consistent: true,
329 config_files: vec![("Cargo.toml".into(), Some("0.1.0".into()))],
330 };
331 let release = contract::StageRelease::default();
332 print_scope(
333 &mut std::io::sink(),
334 "test",
335 d.path(),
336 &contract::Language::Rust,
337 &c,
338 &vs,
339 &release,
340 )
341 .unwrap();
342 }
343
344 #[test]
345 fn test_print_scope_version_inconsistent() {
346 let vs = contract::VersionState {
347 tag_version: Some("0.2.0".into()),
348 config_version: Some("0.1.0".into()),
349 consistent: false,
350 config_files: vec![("Cargo.toml".into(), Some("0.1.0".into()))],
351 };
352 let release = contract::StageRelease::default();
353 let c = contract::Contract::default();
354 let mut buf = Vec::new();
355 print_scope(
356 &mut buf,
357 "test",
358 Path::new("/tmp"),
359 &contract::Language::Rust,
360 &c,
361 &vs,
362 &release,
363 )
364 .unwrap();
365 let out = String::from_utf8_lossy(&buf);
366 assert!(out.contains("配置不一致"), "应显示不一致");
367 }
368
369 #[test]
370 fn test_print_scope_tag_without_config() {
371 let vs = contract::VersionState {
372 tag_version: Some("0.1.0".into()),
373 config_version: None,
374 consistent: false,
375 config_files: vec![("Cargo.toml".into(), None)],
376 };
377 let release = contract::StageRelease::default();
378 let c = contract::Contract::default();
379 let mut buf = Vec::new();
380 print_scope(
381 &mut buf,
382 "test",
383 Path::new("/tmp"),
384 &contract::Language::Rust,
385 &c,
386 &vs,
387 &release,
388 )
389 .unwrap();
390 let out = String::from_utf8_lossy(&buf);
391 assert!(out.contains("无配置文件"), "应显示无配置文件");
392 }
393
394 #[test]
395 fn test_print_scope_config_without_tag() {
396 let vs = contract::VersionState {
397 tag_version: None,
398 config_version: Some("0.1.0".into()),
399 consistent: false,
400 config_files: vec![("Cargo.toml".into(), Some("0.1.0".into()))],
401 };
402 let release = contract::StageRelease::default();
403 let c = contract::Contract::default();
404 let mut buf = Vec::new();
405 print_scope(
406 &mut buf,
407 "test",
408 Path::new("/tmp"),
409 &contract::Language::Rust,
410 &c,
411 &vs,
412 &release,
413 )
414 .unwrap();
415 let out = String::from_utf8_lossy(&buf);
416 assert!(out.contains("无 tag"), "应显示无 tag");
417 }
418
419 #[test]
420 fn test_print_scope_no_release() {
421 let vs = contract::VersionState {
422 tag_version: None,
423 config_version: None,
424 consistent: false,
425 config_files: vec![],
426 };
427 let release = contract::StageRelease::default();
428 let c = contract::Contract::default();
429 let mut buf = Vec::new();
430 print_scope(
431 &mut buf,
432 "test",
433 Path::new("/tmp"),
434 &contract::Language::Rust,
435 &c,
436 &vs,
437 &release,
438 )
439 .unwrap();
440 let out = String::from_utf8_lossy(&buf);
441 assert!(out.contains("暂无发布"), "应显示暂无发布");
442 }
443
444 #[test]
445 fn test_is_working_tree_dirty_empty_repo() {
446 let d = tempfile::tempdir().unwrap();
447 assert!(!is_working_tree_dirty(d.path()));
448 }
449
450 #[test]
451 fn test_resolve_workflow_default() {
452 assert_eq!(resolve_workflow("cli", None), "build-cli");
453 assert_eq!(resolve_workflow("studio", None), "build-studio");
454 }
455
456 #[test]
457 fn test_resolve_workflow_custom() {
458 assert_eq!(resolve_workflow("cli", Some("my-pipeline")), "my-pipeline");
459 assert_eq!(resolve_workflow("cli", Some("release-ci")), "release-ci");
460 }
461
462 #[test]
463 fn test_detect_no_contract_yaml() {
464 let d = tempfile::tempdir().unwrap();
465 let c = contract::load(d.path());
466 assert!(c.scopes.is_empty());
467 }
468
469 #[test]
472 fn test_parse_gh_run_list_success() {
473 let out =
474 r#"[{"conclusion":"success","displayTitle":"CI","headBranch":"main","number":42}]"#;
475 let run = parse_gh_run_list(out).unwrap();
476 assert_eq!(run.conclusion, "success");
477 assert_eq!(run.title, "CI");
478 assert_eq!(run.branch, "main");
479 assert_eq!(run.number, "42");
480 }
481
482 #[test]
483 fn test_parse_gh_run_list_failure() {
484 let out =
485 r#"[{"conclusion":"failure","displayTitle":"Build","headBranch":"feat/x","number":7}]"#;
486 let run = parse_gh_run_list(out).unwrap();
487 assert_eq!(run.conclusion, "failure");
488 assert_eq!(run.title, "Build");
489 assert_eq!(run.branch, "feat/x");
490 assert_eq!(run.number, "7");
491 }
492
493 #[test]
494 fn test_parse_gh_run_list_cancelled() {
495 let out =
496 r#"[{"conclusion":"cancelled","displayTitle":"CI","headBranch":"main","number":99}]"#;
497 let run = parse_gh_run_list(out).unwrap();
498 assert_eq!(run.conclusion, "cancelled");
499 assert_eq!(run.number, "99");
500 }
501
502 #[test]
503 fn test_parse_gh_run_list_empty_array() {
504 assert!(parse_gh_run_list("[]").is_none());
505 }
506
507 #[test]
508 fn test_parse_gh_run_list_empty_stdout() {
509 assert!(parse_gh_run_list("").is_none());
510 }
511
512 #[test]
513 fn test_parse_gh_run_list_no_number() {
514 let out = r#"[{"conclusion":"success","displayTitle":"CI","headBranch":"main"}]"#;
516 let run = parse_gh_run_list(out).unwrap();
517 assert_eq!(run.number, "?");
518 }
519
520 #[test]
521 fn test_parse_gh_run_list_unknown_conclusion() {
522 let out =
523 r#"[{"conclusion":"neutral","displayTitle":"Check","headBranch":"main","number":1}]"#;
524 let run = parse_gh_run_list(out).unwrap();
525 assert_eq!(run.conclusion, "neutral");
526 assert_eq!(run.title, "Check");
527 }
528
529 #[test]
530 fn test_parse_gh_run_list_large_input() {
531 use std::time::Instant;
532 let mut items = Vec::with_capacity(1000);
534 for i in 0..1000 {
535 items.push(format!(
536 r#"{{"conclusion":"success","displayTitle":"CI","headBranch":"main","number":{}}}"#,
537 i
538 ));
539 }
540 let out = format!("[{}]", items.join(","));
541 let start = Instant::now();
542 let run = parse_gh_run_list(&out);
543 let elapsed = start.elapsed();
544 assert!(run.is_some(), "应解析成功");
545 assert_eq!(run.as_ref().unwrap().number, "0");
546 assert!(
547 elapsed.as_micros() < 5000,
548 "1000 条记录应在 5ms 内解析,实际: {}μs",
549 elapsed.as_micros()
550 );
551 }
552
553 #[test]
556 fn test_check_command_all_languages() {
557 assert_eq!(
558 check_command(&contract::Language::Rust),
559 Some(("cargo", "cargo check"))
560 );
561 assert_eq!(
562 check_command(&contract::Language::Python),
563 Some(("uv", "uv check"))
564 );
565 assert_eq!(
566 check_command(&contract::Language::Go),
567 Some(("go", "go vet"))
568 );
569 assert_eq!(
570 check_command(&contract::Language::Dart),
571 Some(("dart", "dart analyze"))
572 );
573 assert_eq!(
574 check_command(&contract::Language::TypeScript),
575 Some(("npx", "tsc --noEmit"))
576 );
577 assert_eq!(
578 check_command(&contract::Language::Unknown("?".into())),
579 None
580 );
581 }
582
583 #[test]
586 fn test_check_manifest_file_all_languages() {
587 assert_eq!(
588 check_manifest_file(&contract::Language::Rust),
589 Some("Cargo.toml")
590 );
591 assert_eq!(
592 check_manifest_file(&contract::Language::Python),
593 Some("pyproject.toml")
594 );
595 assert_eq!(check_manifest_file(&contract::Language::Go), Some("go.mod"));
596 assert_eq!(
597 check_manifest_file(&contract::Language::Dart),
598 Some("pubspec.yaml")
599 );
600 assert_eq!(
601 check_manifest_file(&contract::Language::TypeScript),
602 Some("package.json")
603 );
604 assert_eq!(
605 check_manifest_file(&contract::Language::Unknown("?".into())),
606 None
607 );
608 }
609
610 #[test]
613 fn test_check_args_rust_includes_manifest_path() {
614 let d = tempfile::tempdir().unwrap();
615 let args = check_args(&contract::Language::Rust, d.path()).unwrap();
616 assert!(args.contains(&"check".to_string()));
617 assert!(args.iter().any(|a| a.contains("Cargo.toml")));
618 }
619
620 #[test]
621 fn test_check_args_unknown_returns_none() {
622 let d = tempfile::tempdir().unwrap();
623 assert!(check_args(&contract::Language::Unknown("?".into()), d.path()).is_none());
624 }
625
626 #[test]
629 fn test_check_deps_clean() {
630 let d = tempfile::tempdir().unwrap();
631 std::fs::write(
632 d.path().join("Cargo.toml"),
633 "[dependencies]\nserde = \"1\"\n",
634 )
635 .unwrap();
636 let r = check_dependencies(d.path());
637 assert!(r.contains("✅"), "应返回干净: {}", r);
638 }
639
640 #[test]
641 fn test_check_deps_path_dep() {
642 let d = tempfile::tempdir().unwrap();
643 std::fs::write(
644 d.path().join("Cargo.toml"),
645 "[dependencies]\nfoo = { path = \"../local\" }\n",
646 )
647 .unwrap();
648 let r = check_dependencies(d.path());
649 assert!(r.contains("⚠"), "应检测到 path 依赖: {}", r);
650 }
651
652 #[test]
653 fn test_check_deps_git_no_rev() {
654 let d = tempfile::tempdir().unwrap();
655 std::fs::write(
656 d.path().join("Cargo.toml"),
657 "[dependencies]\nbar = { git = \"https://github.com/foo/bar\" }\n",
658 )
659 .unwrap();
660 let r = check_dependencies(d.path());
661 assert!(r.contains("⚠"), "应检测到 git 无 rev: {}", r);
662 }
663
664 #[test]
665 fn test_check_deps_no_cargo_toml() {
666 let d = tempfile::tempdir().unwrap();
667 assert_eq!(check_dependencies(d.path()), "—");
668 }
669}