Skip to main content

qtcloud_devops_cli/
build.rs

1use std::path::Path;
2
3use crate::contract;
4
5/// 输出当前仓库的构建状态(按 scope)。
6pub fn status(repo_path: &Path) {
7    let c = contract::load(repo_path);
8
9    println!("构建状态");
10    println!("{}", "-".repeat(50));
11
12    if c.scopes.is_empty() {
13        let lang = contract::detect_by_files(repo_path);
14        let root_scope = contract::Scope {
15            name: "(root)".into(),
16            dir: ".".into(),
17            language: lang.clone(),
18            framework: String::new(),
19            build_tool: contract::BuildTool::Unknown(String::new()),
20            registry: contract::Registry::None,
21            release: contract::StageRelease::default(),
22            test_threshold: None,
23            ci_workflow: None,
24        };
25        let vs = contract::version_status(repo_path, &root_scope);
26        let release = contract::scope_release(&c, &root_scope);
27        print_scope("(root)", repo_path, &lang, &vs, release, &c, None);
28    } else {
29        for scope in &c.scopes {
30            let scope_dir = repo_path.join(&scope.dir);
31            if !scope_dir.exists() {
32                println!("  [{}]     ⚠ 目录不存在: {}", scope.name, scope.dir);
33                continue;
34            }
35            let lang = contract::resolve_language(scope, &scope_dir);
36            let vs = contract::version_status(repo_path, scope);
37            let release = contract::scope_release(&c, scope);
38            print_scope(
39                &scope.name,
40                &scope_dir,
41                &lang,
42                &vs,
43                release,
44                &c,
45                scope.ci_workflow.as_deref(),
46            );
47        }
48    }
49
50    let dirty = is_working_tree_dirty(repo_path);
51    println!(
52        "  {}         {}",
53        "工作区".to_string(),
54        if dirty {
55            "⚠ 有未提交变更"
56        } else {
57            "✅ 干净"
58        }
59    );
60}
61
62fn print_scope(
63    name: &str,
64    dir: &Path,
65    lang: &contract::Language,
66    vs: &contract::VersionStatus,
67    release: &contract::StageRelease,
68    c: &contract::Contract,
69    ci_workflow: Option<&str>,
70) {
71    println!("  [{:<12}] {}", name, lang.name());
72    println!("    CI:         {}", check_ci(name, ci_workflow));
73    println!("    build:      {}", check_syntax(lang, dir));
74    match (&vs.tag_version, &vs.config_version) {
75        (Some(t), Some(cv)) if t == cv => println!("    version:    ✅ {}(一致)", t),
76        (Some(t), Some(cv)) => println!("    version:    ⚠ tag {} ≠ 配置 {}", t, cv),
77        (Some(t), None) => println!("    version:    tag {}(无配置文件)", t),
78        (None, Some(cv)) => println!("    version:    配置 {}(无 tag)", cv),
79        (None, None) => println!("    version:    暂无发布"),
80    }
81    println!("    registry:   {}", c.platforms.artifact_registry.name());
82    println!("    changelog:  {}", release.changelog);
83}
84
85fn check_ci(scope: &str, ci_workflow: Option<&str>) -> String {
86    // workflow 名称:scope 的 ci_workflow 字段优先,无则按约定 build-{scope}
87    let workflow = ci_workflow.unwrap_or(scope);
88    let output = match std::process::Command::new("gh")
89        .args([
90            "run",
91            "list",
92            "--limit",
93            "1",
94            "--workflow",
95            workflow,
96            "--json",
97            "conclusion,displayTitle,headBranch,number",
98        ])
99        .output()
100    {
101        Ok(o) if o.status.success() => o.stdout,
102        Ok(_) => return "⚠ 无 CI 运行记录".into(),
103        Err(_) => return "⚠ gh CLI 未安装".into(),
104    };
105
106    let out = String::from_utf8_lossy(&output);
107    // JSON: [{"conclusion":"success","displayTitle":"CI","headBranch":"main","number":42}]
108    let conclusion = out
109        .split("\"conclusion\":")
110        .nth(1)
111        .and_then(|s| s.split('"').nth(1))
112        .unwrap_or("");
113    let title = out
114        .split("\"displayTitle\":")
115        .nth(1)
116        .and_then(|s| s.split('"').nth(1))
117        .unwrap_or("");
118    let branch = out
119        .split("\"headBranch\":")
120        .nth(1)
121        .and_then(|s| s.split('"').nth(1))
122        .unwrap_or("?");
123    let number = out
124        .split("\"number\":")
125        .nth(1)
126        .and_then(|s| s.split(',').next())
127        .unwrap_or("?");
128
129    if conclusion.is_empty() {
130        return "⚠ 无 CI 运行记录".into();
131    }
132    match conclusion {
133        "success" => format!("✅ {} ({} #{})", title, branch, number),
134        "failure" => format!("❌ {} ({} #{})", title, branch, number),
135        "cancelled" => format!("🔶 {} 已取消", title),
136        s => format!("⏳ {} ({}) - {}", title, branch, s),
137    }
138}
139
140fn check_syntax(lang: &contract::Language, dir: &Path) -> String {
141    let (cmd, args, label) = match lang {
142        contract::Language::Rust => {
143            let mp = dir.join("Cargo.toml");
144            if !mp.exists() {
145                return "—".into();
146            }
147            let mp_s = mp.to_string_lossy().to_string();
148            (
149                "cargo",
150                vec!["check".into(), "--manifest-path".into(), mp_s],
151                "cargo check",
152            )
153        }
154        contract::Language::Python => {
155            if !dir.join("pyproject.toml").exists() {
156                return "—".into();
157            }
158            ("uv".into(), vec!["check".into()], "uv check")
159        }
160        contract::Language::Go => {
161            if !dir.join("go.mod").exists() {
162                return "—".into();
163            }
164            ("go".into(), vec!["vet".into(), "./...".into()], "go vet")
165        }
166        contract::Language::Dart => {
167            if !dir.join("pubspec.yaml").exists() {
168                return "—".into();
169            }
170            ("dart".into(), vec!["analyze".into()], "dart analyze")
171        }
172        contract::Language::TypeScript => {
173            if !dir.join("package.json").exists() {
174                return "—".into();
175            }
176            (
177                "npx".into(),
178                vec!["tsc".into(), "--noEmit".into()],
179                "tsc --noEmit",
180            )
181        }
182        contract::Language::Unknown(_) => return "⚠ 语言未知,跳过".into(),
183    };
184    match std::process::Command::new(&cmd)
185        .args(&args)
186        .current_dir(dir)
187        .output()
188    {
189        Ok(o) if o.status.success() => format!("✅ {} 通过", label),
190        Ok(_) => format!("❌ {} 失败", label),
191        Err(_) => format!("⚠ {} 未安装", cmd),
192    }
193}
194
195fn is_working_tree_dirty(repo_path: &Path) -> bool {
196    match std::process::Command::new("git")
197        .args(["status", "--porcelain"])
198        .current_dir(repo_path)
199        .output()
200    {
201        Ok(o) => !o.stdout.is_empty(),
202        Err(_) => false,
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_print_scope_all_ok() {
212        let d = tempfile::tempdir().unwrap();
213        let c = contract::load(d.path());
214        let vs = contract::VersionStatus {
215            tag_version: Some("0.1.0".into()),
216            config_version: Some("0.1.0".into()),
217            consistent: true,
218        };
219        let release = contract::StageRelease::default();
220        print_scope(
221            "test",
222            d.path(),
223            &contract::Language::Rust,
224            &vs,
225            &release,
226            &c,
227            None,
228        );
229    }
230
231    #[test]
232    fn test_is_working_tree_dirty_empty_repo() {
233        let d = tempfile::tempdir().unwrap();
234        assert!(!is_working_tree_dirty(d.path()));
235    }
236
237    #[test]
238    fn test_detect_no_contract_yaml() {
239        let d = tempfile::tempdir().unwrap();
240        let c = contract::load(d.path());
241        assert!(c.scopes.is_empty());
242    }
243}