Skip to main content

qtcloud_devops_cli/
build.rs

1use std::path::Path;
2
3use crate::contract;
4
5/// CI 运行记录。
6#[derive(Debug, PartialEq)]
7struct CiRun {
8    conclusion: String,
9    title: String,
10    branch: String,
11    number: String,
12}
13
14/// 输出当前仓库的构建状态(按 scope)。
15pub 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::VersionStatus,
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
122/// 解析 CI workflow 名称。ci_workflow 优先,无则按约定 build-{scope}。
123pub 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
130/// 从 `gh run list --json conclusion,displayTitle,headBranch,number` 的输出解析运行记录。
131///
132/// 输入格式:`[{"conclusion":"success","displayTitle":"CI","headBranch":"main","number":42}]`
133/// 返回 None 表示无有效记录(空数组或格式异常)。
134fn 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
199/// 返回语言对应的构建检查命令和标签,None 表示不支持。
200fn 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
211/// 返回语言对应的清单文件名(存在验证用),None 表示不需要验证。
212fn 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
223/// 构建检查参数(依赖目录,因为 Rust 需要 --manifest-path)。
224fn 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    let repo = match git2::Repository::open(repo_path) {
269        Ok(r) => r,
270        Err(_) => return false,
271    };
272    repo.statuses(None).map_or(false, |s| !s.is_empty())
273}
274
275/// 检查 scope 目录下的 Cargo.toml 是否有 path 或 git 依赖。
276fn check_dependencies(dir: &Path) -> String {
277    let cargo_toml = dir.join("Cargo.toml");
278    if !cargo_toml.exists() {
279        return "—".into();
280    }
281    let content = match std::fs::read_to_string(&cargo_toml) {
282        Ok(c) => c,
283        Err(_) => return "⚠ 无法读取".into(),
284    };
285
286    // 检查 [dependencies] 和 [dev-dependencies] 段
287    let mut in_deps = false;
288    let mut issues: Vec<&str> = Vec::new();
289    for line in content.lines() {
290        let t = line.trim();
291        if t.starts_with('[') {
292            in_deps = t == "[dependencies]"
293                || t.starts_with("[dependencies.")
294                || t == "[dev-dependencies]"
295                || t.starts_with("[dev-dependencies.");
296            continue;
297        }
298        if !in_deps || t.starts_with('#') || t.is_empty() {
299            continue;
300        }
301        if t.contains("path = \"") && !t.contains("\"\"") {
302            issues.push("path");
303        }
304        if t.contains("git = \"") && !t.contains("rev = \"") {
305            issues.push("git (no rev)");
306        }
307    }
308
309    if issues.is_empty() {
310        "✅ crates.io".into()
311    } else {
312        format!("⚠ {}", issues.join(", "))
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_print_scope_all_ok() {
322        let d = tempfile::tempdir().unwrap();
323        let c = contract::load(d.path());
324        let vs = contract::VersionStatus {
325            tag_version: Some("0.1.0".into()),
326            config_version: Some("0.1.0".into()),
327            consistent: true,
328            config_files: vec![("Cargo.toml".into(), Some("0.1.0".into()))],
329        };
330        let release = contract::StageRelease::default();
331        print_scope(
332            &mut std::io::sink(),
333            "test",
334            d.path(),
335            &contract::Language::Rust,
336            &c,
337            &vs,
338            &release,
339        )
340        .unwrap();
341    }
342
343    #[test]
344    fn test_print_scope_version_inconsistent() {
345        let vs = contract::VersionStatus {
346            tag_version: Some("0.2.0".into()),
347            config_version: Some("0.1.0".into()),
348            consistent: false,
349            config_files: vec![("Cargo.toml".into(), Some("0.1.0".into()))],
350        };
351        let release = contract::StageRelease::default();
352        let c = contract::Contract::default();
353        let mut buf = Vec::new();
354        print_scope(
355            &mut buf,
356            "test",
357            Path::new("/tmp"),
358            &contract::Language::Rust,
359            &c,
360            &vs,
361            &release,
362        )
363        .unwrap();
364        let out = String::from_utf8_lossy(&buf);
365        assert!(out.contains("配置不一致"), "应显示不一致");
366    }
367
368    #[test]
369    fn test_print_scope_tag_without_config() {
370        let vs = contract::VersionStatus {
371            tag_version: Some("0.1.0".into()),
372            config_version: None,
373            consistent: false,
374            config_files: vec![("Cargo.toml".into(), None)],
375        };
376        let release = contract::StageRelease::default();
377        let c = contract::Contract::default();
378        let mut buf = Vec::new();
379        print_scope(
380            &mut buf,
381            "test",
382            Path::new("/tmp"),
383            &contract::Language::Rust,
384            &c,
385            &vs,
386            &release,
387        )
388        .unwrap();
389        let out = String::from_utf8_lossy(&buf);
390        assert!(out.contains("无配置文件"), "应显示无配置文件");
391    }
392
393    #[test]
394    fn test_print_scope_config_without_tag() {
395        let vs = contract::VersionStatus {
396            tag_version: None,
397            config_version: Some("0.1.0".into()),
398            consistent: false,
399            config_files: vec![("Cargo.toml".into(), Some("0.1.0".into()))],
400        };
401        let release = contract::StageRelease::default();
402        let c = contract::Contract::default();
403        let mut buf = Vec::new();
404        print_scope(
405            &mut buf,
406            "test",
407            Path::new("/tmp"),
408            &contract::Language::Rust,
409            &c,
410            &vs,
411            &release,
412        )
413        .unwrap();
414        let out = String::from_utf8_lossy(&buf);
415        assert!(out.contains("无 tag"), "应显示无 tag");
416    }
417
418    #[test]
419    fn test_print_scope_no_release() {
420        let vs = contract::VersionStatus {
421            tag_version: None,
422            config_version: None,
423            consistent: false,
424            config_files: vec![],
425        };
426        let release = contract::StageRelease::default();
427        let c = contract::Contract::default();
428        let mut buf = Vec::new();
429        print_scope(
430            &mut buf,
431            "test",
432            Path::new("/tmp"),
433            &contract::Language::Rust,
434            &c,
435            &vs,
436            &release,
437        )
438        .unwrap();
439        let out = String::from_utf8_lossy(&buf);
440        assert!(out.contains("暂无发布"), "应显示暂无发布");
441    }
442
443    #[test]
444    fn test_is_working_tree_dirty_empty_repo() {
445        let d = tempfile::tempdir().unwrap();
446        assert!(!is_working_tree_dirty(d.path()));
447    }
448
449    #[test]
450    fn test_resolve_workflow_default() {
451        assert_eq!(resolve_workflow("cli", None), "build-cli");
452        assert_eq!(resolve_workflow("studio", None), "build-studio");
453    }
454
455    #[test]
456    fn test_resolve_workflow_custom() {
457        assert_eq!(resolve_workflow("cli", Some("my-pipeline")), "my-pipeline");
458        assert_eq!(resolve_workflow("cli", Some("release-ci")), "release-ci");
459    }
460
461    #[test]
462    fn test_detect_no_contract_yaml() {
463        let d = tempfile::tempdir().unwrap();
464        let c = contract::load(d.path());
465        assert!(c.scopes.is_empty());
466    }
467
468    // ── parse_gh_run_list ─────────────────────────────────────
469
470    #[test]
471    fn test_parse_gh_run_list_success() {
472        let out =
473            r#"[{"conclusion":"success","displayTitle":"CI","headBranch":"main","number":42}]"#;
474        let run = parse_gh_run_list(out).unwrap();
475        assert_eq!(run.conclusion, "success");
476        assert_eq!(run.title, "CI");
477        assert_eq!(run.branch, "main");
478        assert_eq!(run.number, "42");
479    }
480
481    #[test]
482    fn test_parse_gh_run_list_failure() {
483        let out =
484            r#"[{"conclusion":"failure","displayTitle":"Build","headBranch":"feat/x","number":7}]"#;
485        let run = parse_gh_run_list(out).unwrap();
486        assert_eq!(run.conclusion, "failure");
487        assert_eq!(run.title, "Build");
488        assert_eq!(run.branch, "feat/x");
489        assert_eq!(run.number, "7");
490    }
491
492    #[test]
493    fn test_parse_gh_run_list_cancelled() {
494        let out =
495            r#"[{"conclusion":"cancelled","displayTitle":"CI","headBranch":"main","number":99}]"#;
496        let run = parse_gh_run_list(out).unwrap();
497        assert_eq!(run.conclusion, "cancelled");
498        assert_eq!(run.number, "99");
499    }
500
501    #[test]
502    fn test_parse_gh_run_list_empty_array() {
503        assert!(parse_gh_run_list("[]").is_none());
504    }
505
506    #[test]
507    fn test_parse_gh_run_list_empty_stdout() {
508        assert!(parse_gh_run_list("").is_none());
509    }
510
511    #[test]
512    fn test_parse_gh_run_list_no_number() {
513        // 一些旧版本 gh 可能不返回 number
514        let out = r#"[{"conclusion":"success","displayTitle":"CI","headBranch":"main"}]"#;
515        let run = parse_gh_run_list(out).unwrap();
516        assert_eq!(run.number, "?");
517    }
518
519    #[test]
520    fn test_parse_gh_run_list_unknown_conclusion() {
521        let out =
522            r#"[{"conclusion":"neutral","displayTitle":"Check","headBranch":"main","number":1}]"#;
523        let run = parse_gh_run_list(out).unwrap();
524        assert_eq!(run.conclusion, "neutral");
525        assert_eq!(run.title, "Check");
526    }
527
528    // ── check_command ─────────────────────────────────────────
529
530    #[test]
531    fn test_check_command_all_languages() {
532        assert_eq!(
533            check_command(&contract::Language::Rust),
534            Some(("cargo", "cargo check"))
535        );
536        assert_eq!(
537            check_command(&contract::Language::Python),
538            Some(("uv", "uv check"))
539        );
540        assert_eq!(
541            check_command(&contract::Language::Go),
542            Some(("go", "go vet"))
543        );
544        assert_eq!(
545            check_command(&contract::Language::Dart),
546            Some(("dart", "dart analyze"))
547        );
548        assert_eq!(
549            check_command(&contract::Language::TypeScript),
550            Some(("npx", "tsc --noEmit"))
551        );
552        assert_eq!(
553            check_command(&contract::Language::Unknown("?".into())),
554            None
555        );
556    }
557
558    // ── check_manifest_file ────────────────────────────────────
559
560    #[test]
561    fn test_check_manifest_file_all_languages() {
562        assert_eq!(
563            check_manifest_file(&contract::Language::Rust),
564            Some("Cargo.toml")
565        );
566        assert_eq!(
567            check_manifest_file(&contract::Language::Python),
568            Some("pyproject.toml")
569        );
570        assert_eq!(check_manifest_file(&contract::Language::Go), Some("go.mod"));
571        assert_eq!(
572            check_manifest_file(&contract::Language::Dart),
573            Some("pubspec.yaml")
574        );
575        assert_eq!(
576            check_manifest_file(&contract::Language::TypeScript),
577            Some("package.json")
578        );
579        assert_eq!(
580            check_manifest_file(&contract::Language::Unknown("?".into())),
581            None
582        );
583    }
584
585    // ── check_args ─────────────────────────────────────────────
586
587    #[test]
588    fn test_check_args_rust_includes_manifest_path() {
589        let d = tempfile::tempdir().unwrap();
590        let args = check_args(&contract::Language::Rust, d.path()).unwrap();
591        assert!(args.contains(&"check".to_string()));
592        assert!(args.iter().any(|a| a.contains("Cargo.toml")));
593    }
594
595    #[test]
596    fn test_check_args_unknown_returns_none() {
597        let d = tempfile::tempdir().unwrap();
598        assert!(check_args(&contract::Language::Unknown("?".into()), d.path()).is_none());
599    }
600
601    // ── check_dependencies ──────────────────────────────────────
602
603    #[test]
604    fn test_check_deps_clean() {
605        let d = tempfile::tempdir().unwrap();
606        std::fs::write(
607            d.path().join("Cargo.toml"),
608            "[dependencies]\nserde = \"1\"\n",
609        )
610        .unwrap();
611        let r = check_dependencies(d.path());
612        assert!(r.contains("✅"), "应返回干净: {}", r);
613    }
614
615    #[test]
616    fn test_check_deps_path_dep() {
617        let d = tempfile::tempdir().unwrap();
618        std::fs::write(
619            d.path().join("Cargo.toml"),
620            "[dependencies]\nfoo = { path = \"../local\" }\n",
621        )
622        .unwrap();
623        let r = check_dependencies(d.path());
624        assert!(r.contains("⚠"), "应检测到 path 依赖: {}", r);
625    }
626
627    #[test]
628    fn test_check_deps_git_no_rev() {
629        let d = tempfile::tempdir().unwrap();
630        std::fs::write(
631            d.path().join("Cargo.toml"),
632            "[dependencies]\nbar = { git = \"https://github.com/foo/bar\" }\n",
633        )
634        .unwrap();
635        let r = check_dependencies(d.path());
636        assert!(r.contains("⚠"), "应检测到 git 无 rev: {}", r);
637    }
638
639    #[test]
640    fn test_check_deps_no_cargo_toml() {
641        let d = tempfile::tempdir().unwrap();
642        assert_eq!(check_dependencies(d.path()), "—");
643    }
644}