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::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
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    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
276/// 检查 scope 目录下的 Cargo.toml 是否有 path 或 git 依赖。
277fn 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    // 检查 [dependencies] 和 [dev-dependencies] 段
288    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    // ── parse_gh_run_list ─────────────────────────────────────
470
471    #[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        // 一些旧版本 gh 可能不返回 number
515        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        // 生成 1000 条 CI 运行记录 JSON
533        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    // ── check_command ─────────────────────────────────────────
554
555    #[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    // ── check_manifest_file ────────────────────────────────────
584
585    #[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    // ── check_args ─────────────────────────────────────────────
611
612    #[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    // ── check_dependencies ──────────────────────────────────────
627
628    #[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}