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(_)) if vs.consistent => println!("    version:    ✅ {}(一致)", t),
76        (Some(t), Some(_)) => println!("    version:    ⚠ {}(配置不一致)", t),
77        (Some(t), None) => println!("    version:    tag {}(无配置文件)", t),
78        (None, Some(_)) => println!("    version:    有配置版本(无 tag)"),
79        (None, None) => println!("    version:    暂无发布"),
80    }
81    for (fname, ver) in &vs.config_files {
82        match (ver, &vs.tag_version) {
83            (Some(v), Some(t)) if v == t => {
84                println!("      {:<15} {} ✅", format!("{}:", fname), v)
85            }
86            (Some(v), Some(_)) => println!(
87                "      {:<15} {} ❌(期望 {})",
88                format!("{}:", fname),
89                v,
90                vs.tag_version.as_deref().unwrap_or("?")
91            ),
92            (Some(v), None) => println!("      {:<15} {}(无 tag)", format!("{}:", fname), v),
93            (None, _) => println!("      {:<15} (未找到版本字段)", format!("{}:", fname)),
94        }
95    }
96    println!("    registry:   {}", c.platforms.artifact_registry.name());
97    println!("    changelog:  {}", release.changelog);
98}
99
100fn check_ci(scope: &str, ci_workflow: Option<&str>) -> String {
101    // workflow 名称:scope 的 ci_workflow 字段优先,无则按约定 build-{scope}
102    let workflow = ci_workflow.unwrap_or(scope);
103    let output = match std::process::Command::new("gh")
104        .args([
105            "run",
106            "list",
107            "--limit",
108            "1",
109            "--workflow",
110            workflow,
111            "--json",
112            "conclusion,displayTitle,headBranch,number",
113        ])
114        .output()
115    {
116        Ok(o) if o.status.success() => o.stdout,
117        Ok(_) => return "⚠ 无 CI 运行记录".into(),
118        Err(_) => return "⚠ gh CLI 未安装".into(),
119    };
120
121    let out = String::from_utf8_lossy(&output);
122    // JSON: [{"conclusion":"success","displayTitle":"CI","headBranch":"main","number":42}]
123    let conclusion = out
124        .split("\"conclusion\":")
125        .nth(1)
126        .and_then(|s| s.split('"').nth(1))
127        .unwrap_or("");
128    let title = out
129        .split("\"displayTitle\":")
130        .nth(1)
131        .and_then(|s| s.split('"').nth(1))
132        .unwrap_or("");
133    let branch = out
134        .split("\"headBranch\":")
135        .nth(1)
136        .and_then(|s| s.split('"').nth(1))
137        .unwrap_or("?");
138    let number = out
139        .split("\"number\":")
140        .nth(1)
141        .and_then(|s| s.split(',').next())
142        .unwrap_or("?");
143
144    if conclusion.is_empty() {
145        return "⚠ 无 CI 运行记录".into();
146    }
147    match conclusion {
148        "success" => format!("✅ {} ({} #{})", title, branch, number),
149        "failure" => format!("❌ {} ({} #{})", title, branch, number),
150        "cancelled" => format!("🔶 {} 已取消", title),
151        s => format!("⏳ {} ({}) - {}", title, branch, s),
152    }
153}
154
155fn check_syntax(lang: &contract::Language, dir: &Path) -> String {
156    let (cmd, args, label) = match lang {
157        contract::Language::Rust => {
158            let mp = dir.join("Cargo.toml");
159            if !mp.exists() {
160                return "—".into();
161            }
162            let mp_s = mp.to_string_lossy().to_string();
163            (
164                "cargo",
165                vec!["check".into(), "--manifest-path".into(), mp_s],
166                "cargo check",
167            )
168        }
169        contract::Language::Python => {
170            if !dir.join("pyproject.toml").exists() {
171                return "—".into();
172            }
173            ("uv".into(), vec!["check".into()], "uv check")
174        }
175        contract::Language::Go => {
176            if !dir.join("go.mod").exists() {
177                return "—".into();
178            }
179            ("go".into(), vec!["vet".into(), "./...".into()], "go vet")
180        }
181        contract::Language::Dart => {
182            if !dir.join("pubspec.yaml").exists() {
183                return "—".into();
184            }
185            ("dart".into(), vec!["analyze".into()], "dart analyze")
186        }
187        contract::Language::TypeScript => {
188            if !dir.join("package.json").exists() {
189                return "—".into();
190            }
191            (
192                "npx".into(),
193                vec!["tsc".into(), "--noEmit".into()],
194                "tsc --noEmit",
195            )
196        }
197        contract::Language::Unknown(_) => return "⚠ 语言未知,跳过".into(),
198    };
199    match std::process::Command::new(&cmd)
200        .args(&args)
201        .current_dir(dir)
202        .output()
203    {
204        Ok(o) if o.status.success() => format!("✅ {} 通过", label),
205        Ok(_) => format!("❌ {} 失败", label),
206        Err(_) => format!("⚠ {} 未安装", cmd),
207    }
208}
209
210fn is_working_tree_dirty(repo_path: &Path) -> bool {
211    match std::process::Command::new("git")
212        .args(["status", "--porcelain"])
213        .current_dir(repo_path)
214        .output()
215    {
216        Ok(o) => !o.stdout.is_empty(),
217        Err(_) => false,
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_print_scope_all_ok() {
227        let d = tempfile::tempdir().unwrap();
228        let c = contract::load(d.path());
229        let vs = contract::VersionStatus {
230            tag_version: Some("0.1.0".into()),
231            config_version: Some("0.1.0".into()),
232            consistent: true,
233            config_files: vec![("Cargo.toml".into(), Some("0.1.0".into()))],
234        };
235        let release = contract::StageRelease::default();
236        print_scope(
237            "test",
238            d.path(),
239            &contract::Language::Rust,
240            &vs,
241            &release,
242            &c,
243            None,
244        );
245    }
246
247    #[test]
248    fn test_is_working_tree_dirty_empty_repo() {
249        let d = tempfile::tempdir().unwrap();
250        assert!(!is_working_tree_dirty(d.path()));
251    }
252
253    #[test]
254    fn test_detect_no_contract_yaml() {
255        let d = tempfile::tempdir().unwrap();
256        let c = contract::load(d.path());
257        assert!(c.scopes.is_empty());
258    }
259}