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 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
109/// 解析 CI workflow 名称。ci_workflow 优先,无则按约定 build-{scope}。
110pub 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
117/// 从 `gh run list --json conclusion,displayTitle,headBranch,number` 的输出解析运行记录。
118///
119/// 输入格式:`[{"conclusion":"success","displayTitle":"CI","headBranch":"main","number":42}]`
120/// 返回 None 表示无有效记录(空数组或格式异常)。
121fn 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
186/// 返回语言对应的构建检查命令和标签,None 表示不支持。
187fn 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
198/// 返回语言对应的清单文件名(存在验证用),None 表示不需要验证。
199fn 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
210/// 构建检查参数(依赖目录,因为 Rust 需要 --manifest-path)。
211fn 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    // ── parse_gh_run_list ─────────────────────────────────────
314
315    #[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        // 一些旧版本 gh 可能不返回 number
359        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    // ── check_command ─────────────────────────────────────────
374
375    #[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    // ── check_manifest_file ────────────────────────────────────
404
405    #[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    // ── check_args ─────────────────────────────────────────────
431
432    #[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}