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 c = contract::load(repo_path);
17
18 println!("构建状态");
19 println!("{}", "-".repeat(50));
20
21 if c.scopes.is_empty() {
22 let lang = contract::detect_by_files(repo_path);
23 let root_scope = contract::Scope {
24 name: "(root)".into(),
25 dir: ".".into(),
26 language: lang.clone(),
27 framework: String::new(),
28 build_tool: contract::BuildTool::Unknown(String::new()),
29 registry: contract::Registry::None,
30 release: contract::StageRelease::default(),
31 test_threshold: None,
32 ci_workflow: None,
33 };
34 let vs = contract::version_status(repo_path, &root_scope);
35 let release = c.scope_release(&root_scope);
36 print_scope("(root)", repo_path, &lang, &vs, release, &c, None);
37 } else {
38 for scope in &c.scopes {
39 let scope_dir = repo_path.join(&scope.dir);
40 if !scope_dir.exists() {
41 println!(" [{}] ⚠ 目录不存在: {}", scope.name, scope.dir);
42 continue;
43 }
44 let lang = c.resolve_language(scope, &scope_dir);
45 let vs = contract::version_status(repo_path, scope);
46 let release = c.scope_release(scope);
47 print_scope(
48 &scope.name,
49 &scope_dir,
50 &lang,
51 &vs,
52 release,
53 &c,
54 scope.ci_workflow.as_deref(),
55 );
56 }
57 }
58
59 let dirty = is_working_tree_dirty(repo_path);
60 println!(
61 " {} {}",
62 "工作区".to_string(),
63 if dirty {
64 "⚠ 有未提交变更"
65 } else {
66 "✅ 干净"
67 }
68 );
69}
70
71fn print_scope(
72 name: &str,
73 dir: &Path,
74 lang: &contract::Language,
75 vs: &contract::VersionStatus,
76 release: &contract::StageRelease,
77 c: &contract::Contract,
78 ci_workflow: Option<&str>,
79) {
80 println!(" [{:<12}] {}", name, lang.as_str());
81 println!(" CI: {}", check_ci(name, ci_workflow));
82 println!(" build: {}", check_syntax(lang, dir));
83 match (&vs.tag_version, &vs.config_version) {
84 (Some(t), Some(_)) if vs.consistent => println!(" version: ✅ {}(一致)", t),
85 (Some(t), Some(_)) => println!(" version: ⚠ {}(配置不一致)", t),
86 (Some(t), None) => println!(" version: tag {}(无配置文件)", t),
87 (None, Some(_)) => println!(" version: 有配置版本(无 tag)"),
88 (None, None) => println!(" version: 暂无发布"),
89 }
90 for (fname, ver) in &vs.config_files {
91 match (ver, &vs.tag_version) {
92 (Some(v), Some(t)) if v == t => {
93 println!(" {:<15} {} ✅", format!("{}:", fname), v)
94 }
95 (Some(v), Some(_)) => println!(
96 " {:<15} {} ❌(期望 {})",
97 format!("{}:", fname),
98 v,
99 vs.tag_version.as_deref().unwrap_or("?")
100 ),
101 (Some(v), None) => println!(" {:<15} {}(无 tag)", format!("{}:", fname), v),
102 (None, _) => println!(" {:<15} (未找到版本字段)", format!("{}:", fname)),
103 }
104 }
105 println!(" registry: {:?}", c.platform.artifact_registry);
106 println!(" changelog: {}", release.changelog);
107}
108
109pub fn resolve_workflow(scope: &str, ci_workflow: Option<&str>) -> String {
111 match ci_workflow {
112 Some(w) => w.to_string(),
113 None => format!("build-{}", scope),
114 }
115}
116
117fn parse_gh_run_list(output: &str) -> Option<CiRun> {
122 let conclusion = output
123 .split("\"conclusion\":")
124 .nth(1)
125 .and_then(|s| s.split('"').nth(1))?;
126 if conclusion.is_empty() {
127 return None;
128 }
129 let title = output
130 .split("\"displayTitle\":")
131 .nth(1)
132 .and_then(|s| s.split('"').nth(1))
133 .unwrap_or("");
134 let branch = output
135 .split("\"headBranch\":")
136 .nth(1)
137 .and_then(|s| s.split('"').nth(1))
138 .unwrap_or("?");
139 let number: String = output
140 .split("\"number\":")
141 .nth(1)
142 .map(|s| s.chars().take_while(|c| c.is_ascii_digit()).collect())
143 .filter(|s: &String| !s.is_empty())
144 .unwrap_or_else(|| "?".into());
145
146 Some(CiRun {
147 conclusion: conclusion.to_string(),
148 title: title.to_string(),
149 branch: branch.to_string(),
150 number,
151 })
152}
153
154fn check_ci(scope: &str, ci_workflow: Option<&str>) -> String {
155 let workflow = resolve_workflow(scope, ci_workflow);
156 let output = match std::process::Command::new("gh")
157 .args([
158 "run",
159 "list",
160 "--limit",
161 "1",
162 "--workflow",
163 &workflow,
164 "--json",
165 "conclusion,displayTitle,headBranch,number",
166 ])
167 .output()
168 {
169 Ok(o) if o.status.success() => o.stdout,
170 Ok(_) => return "⚠ 无 CI 运行记录".into(),
171 Err(_) => return "⚠ gh CLI 未安装".into(),
172 };
173
174 let out = String::from_utf8_lossy(&output);
175 match parse_gh_run_list(&out) {
176 Some(run) => match run.conclusion.as_str() {
177 "success" => format!("✅ {} ({} #{})", run.title, run.branch, run.number),
178 "failure" => format!("❌ {} ({} #{})", run.title, run.branch, run.number),
179 "cancelled" => format!("🔶 {} 已取消", run.title),
180 s => format!("⏳ {} ({}) - {}", run.title, run.branch, s),
181 },
182 None => "⚠ 无 CI 运行记录".into(),
183 }
184}
185
186fn check_command(lang: &contract::Language) -> Option<(&'static str, &'static str)> {
188 match lang {
189 contract::Language::Rust => Some(("cargo", "cargo check")),
190 contract::Language::Python => Some(("uv", "uv check")),
191 contract::Language::Go => Some(("go", "go vet")),
192 contract::Language::Dart => Some(("dart", "dart analyze")),
193 contract::Language::TypeScript => Some(("npx", "tsc --noEmit")),
194 contract::Language::Unknown(_) => None,
195 }
196}
197
198fn check_manifest_file(lang: &contract::Language) -> Option<&'static str> {
200 match lang {
201 contract::Language::Rust => Some("Cargo.toml"),
202 contract::Language::Python => Some("pyproject.toml"),
203 contract::Language::Go => Some("go.mod"),
204 contract::Language::Dart => Some("pubspec.yaml"),
205 contract::Language::TypeScript => Some("package.json"),
206 contract::Language::Unknown(_) => None,
207 }
208}
209
210fn check_args(lang: &contract::Language, dir: &Path) -> Option<Vec<String>> {
212 match lang {
213 contract::Language::Rust => {
214 let mp = dir.join("Cargo.toml");
215 Some(vec![
216 "check".into(),
217 "--manifest-path".into(),
218 mp.to_string_lossy().to_string(),
219 ])
220 }
221 contract::Language::Python => Some(vec!["check".into()]),
222 contract::Language::Go => Some(vec!["vet".into(), "./...".into()]),
223 contract::Language::Dart => Some(vec!["analyze".into()]),
224 contract::Language::TypeScript => Some(vec!["tsc".into(), "--noEmit".into()]),
225 contract::Language::Unknown(_) => None,
226 }
227}
228
229fn check_syntax(lang: &contract::Language, dir: &Path) -> String {
230 let (cmd, label) = match check_command(lang) {
231 Some(x) => x,
232 None => return "⚠ 语言未知,跳过".into(),
233 };
234 if let Some(mf) = check_manifest_file(lang) {
235 if !dir.join(mf).exists() {
236 return "—".into();
237 }
238 }
239 let args = match check_args(lang, dir) {
240 Some(a) => a,
241 None => return "⚠ 语言未知,跳过".into(),
242 };
243 match std::process::Command::new(cmd)
244 .args(&args)
245 .current_dir(dir)
246 .output()
247 {
248 Ok(o) if o.status.success() => format!("✅ {} 通过", label),
249 Ok(_) => format!("❌ {} 失败", label),
250 Err(_) => format!("⚠ {} 未安装", cmd),
251 }
252}
253
254fn is_working_tree_dirty(repo_path: &Path) -> bool {
255 let repo = match git2::Repository::open(repo_path) {
256 Ok(r) => r,
257 Err(_) => return false,
258 };
259 repo.statuses(None).map_or(false, |s| !s.is_empty())
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn test_print_scope_all_ok() {
268 let d = tempfile::tempdir().unwrap();
269 let c = contract::load(d.path());
270 let vs = contract::VersionStatus {
271 tag_version: Some("0.1.0".into()),
272 config_version: Some("0.1.0".into()),
273 consistent: true,
274 config_files: vec![("Cargo.toml".into(), Some("0.1.0".into()))],
275 };
276 let release = contract::StageRelease::default();
277 print_scope(
278 "test",
279 d.path(),
280 &contract::Language::Rust,
281 &vs,
282 &release,
283 &c,
284 None,
285 );
286 }
287
288 #[test]
289 fn test_is_working_tree_dirty_empty_repo() {
290 let d = tempfile::tempdir().unwrap();
291 assert!(!is_working_tree_dirty(d.path()));
292 }
293
294 #[test]
295 fn test_resolve_workflow_default() {
296 assert_eq!(resolve_workflow("cli", None), "build-cli");
297 assert_eq!(resolve_workflow("studio", None), "build-studio");
298 }
299
300 #[test]
301 fn test_resolve_workflow_custom() {
302 assert_eq!(resolve_workflow("cli", Some("my-pipeline")), "my-pipeline");
303 assert_eq!(resolve_workflow("cli", Some("release-ci")), "release-ci");
304 }
305
306 #[test]
307 fn test_detect_no_contract_yaml() {
308 let d = tempfile::tempdir().unwrap();
309 let c = contract::load(d.path());
310 assert!(c.scopes.is_empty());
311 }
312
313 #[test]
316 fn test_parse_gh_run_list_success() {
317 let out =
318 r#"[{"conclusion":"success","displayTitle":"CI","headBranch":"main","number":42}]"#;
319 let run = parse_gh_run_list(out).unwrap();
320 assert_eq!(run.conclusion, "success");
321 assert_eq!(run.title, "CI");
322 assert_eq!(run.branch, "main");
323 assert_eq!(run.number, "42");
324 }
325
326 #[test]
327 fn test_parse_gh_run_list_failure() {
328 let out =
329 r#"[{"conclusion":"failure","displayTitle":"Build","headBranch":"feat/x","number":7}]"#;
330 let run = parse_gh_run_list(out).unwrap();
331 assert_eq!(run.conclusion, "failure");
332 assert_eq!(run.title, "Build");
333 assert_eq!(run.branch, "feat/x");
334 assert_eq!(run.number, "7");
335 }
336
337 #[test]
338 fn test_parse_gh_run_list_cancelled() {
339 let out =
340 r#"[{"conclusion":"cancelled","displayTitle":"CI","headBranch":"main","number":99}]"#;
341 let run = parse_gh_run_list(out).unwrap();
342 assert_eq!(run.conclusion, "cancelled");
343 assert_eq!(run.number, "99");
344 }
345
346 #[test]
347 fn test_parse_gh_run_list_empty_array() {
348 assert!(parse_gh_run_list("[]").is_none());
349 }
350
351 #[test]
352 fn test_parse_gh_run_list_empty_stdout() {
353 assert!(parse_gh_run_list("").is_none());
354 }
355
356 #[test]
357 fn test_parse_gh_run_list_no_number() {
358 let out = r#"[{"conclusion":"success","displayTitle":"CI","headBranch":"main"}]"#;
360 let run = parse_gh_run_list(out).unwrap();
361 assert_eq!(run.number, "?");
362 }
363
364 #[test]
365 fn test_parse_gh_run_list_unknown_conclusion() {
366 let out =
367 r#"[{"conclusion":"neutral","displayTitle":"Check","headBranch":"main","number":1}]"#;
368 let run = parse_gh_run_list(out).unwrap();
369 assert_eq!(run.conclusion, "neutral");
370 assert_eq!(run.title, "Check");
371 }
372
373 #[test]
376 fn test_check_command_all_languages() {
377 assert_eq!(
378 check_command(&contract::Language::Rust),
379 Some(("cargo", "cargo check"))
380 );
381 assert_eq!(
382 check_command(&contract::Language::Python),
383 Some(("uv", "uv check"))
384 );
385 assert_eq!(
386 check_command(&contract::Language::Go),
387 Some(("go", "go vet"))
388 );
389 assert_eq!(
390 check_command(&contract::Language::Dart),
391 Some(("dart", "dart analyze"))
392 );
393 assert_eq!(
394 check_command(&contract::Language::TypeScript),
395 Some(("npx", "tsc --noEmit"))
396 );
397 assert_eq!(
398 check_command(&contract::Language::Unknown("?".into())),
399 None
400 );
401 }
402
403 #[test]
406 fn test_check_manifest_file_all_languages() {
407 assert_eq!(
408 check_manifest_file(&contract::Language::Rust),
409 Some("Cargo.toml")
410 );
411 assert_eq!(
412 check_manifest_file(&contract::Language::Python),
413 Some("pyproject.toml")
414 );
415 assert_eq!(check_manifest_file(&contract::Language::Go), Some("go.mod"));
416 assert_eq!(
417 check_manifest_file(&contract::Language::Dart),
418 Some("pubspec.yaml")
419 );
420 assert_eq!(
421 check_manifest_file(&contract::Language::TypeScript),
422 Some("package.json")
423 );
424 assert_eq!(
425 check_manifest_file(&contract::Language::Unknown("?".into())),
426 None
427 );
428 }
429
430 #[test]
433 fn test_check_args_rust_includes_manifest_path() {
434 let d = tempfile::tempdir().unwrap();
435 let args = check_args(&contract::Language::Rust, d.path()).unwrap();
436 assert!(args.contains(&"check".to_string()));
437 assert!(args.iter().any(|a| a.contains("Cargo.toml")));
438 }
439
440 #[test]
441 fn test_check_args_unknown_returns_none() {
442 let d = tempfile::tempdir().unwrap();
443 assert!(check_args(&contract::Language::Unknown("?".into()), d.path()).is_none());
444 }
445}