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 = c.scope_release(&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 = c.resolve_language(scope, &scope_dir);
36            let vs = contract::version_status(repo_path, scope);
37            let release = c.scope_release(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.as_str());
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.platform.artifact_registry);
97    println!("    changelog:  {}", release.changelog);
98}
99
100/// 解析 CI workflow 名称。ci_workflow 优先,无则按约定 build-{scope}。
101pub fn resolve_workflow(scope: &str, ci_workflow: Option<&str>) -> String {
102    match ci_workflow {
103        Some(w) => w.to_string(),
104        None => format!("build-{}", scope),
105    }
106}
107
108fn check_ci(scope: &str, ci_workflow: Option<&str>) -> String {
109    let workflow = resolve_workflow(scope, ci_workflow);
110    let output = match std::process::Command::new("gh")
111        .args([
112            "run",
113            "list",
114            "--limit",
115            "1",
116            "--workflow",
117            &workflow,
118            "--json",
119            "conclusion,displayTitle,headBranch,number",
120        ])
121        .output()
122    {
123        Ok(o) if o.status.success() => o.stdout,
124        Ok(_) => return "⚠ 无 CI 运行记录".into(),
125        Err(_) => return "⚠ gh CLI 未安装".into(),
126    };
127
128    let out = String::from_utf8_lossy(&output);
129    // JSON: [{"conclusion":"success","displayTitle":"CI","headBranch":"main","number":42}]
130    let conclusion = out
131        .split("\"conclusion\":")
132        .nth(1)
133        .and_then(|s| s.split('"').nth(1))
134        .unwrap_or("");
135    let title = out
136        .split("\"displayTitle\":")
137        .nth(1)
138        .and_then(|s| s.split('"').nth(1))
139        .unwrap_or("");
140    let branch = out
141        .split("\"headBranch\":")
142        .nth(1)
143        .and_then(|s| s.split('"').nth(1))
144        .unwrap_or("?");
145    let number: String = out
146        .split("\"number\":")
147        .nth(1)
148        .map(|s| s.chars().take_while(|c| c.is_ascii_digit()).collect())
149        .filter(|s: &String| !s.is_empty())
150        .unwrap_or_else(|| "?".into());
151
152    if conclusion.is_empty() {
153        return "⚠ 无 CI 运行记录".into();
154    }
155    match conclusion {
156        "success" => format!("✅ {} ({} #{})", title, branch, number),
157        "failure" => format!("❌ {} ({} #{})", title, branch, number),
158        "cancelled" => format!("🔶 {} 已取消", title),
159        s => format!("⏳ {} ({}) - {}", title, branch, s),
160    }
161}
162
163fn check_syntax(lang: &contract::Language, dir: &Path) -> String {
164    let (cmd, args, label) = match lang {
165        contract::Language::Rust => {
166            let mp = dir.join("Cargo.toml");
167            if !mp.exists() {
168                return "—".into();
169            }
170            let mp_s = mp.to_string_lossy().to_string();
171            (
172                "cargo",
173                vec!["check".into(), "--manifest-path".into(), mp_s],
174                "cargo check",
175            )
176        }
177        contract::Language::Python => {
178            if !dir.join("pyproject.toml").exists() {
179                return "—".into();
180            }
181            ("uv".into(), vec!["check".into()], "uv check")
182        }
183        contract::Language::Go => {
184            if !dir.join("go.mod").exists() {
185                return "—".into();
186            }
187            ("go".into(), vec!["vet".into(), "./...".into()], "go vet")
188        }
189        contract::Language::Dart => {
190            if !dir.join("pubspec.yaml").exists() {
191                return "—".into();
192            }
193            ("dart".into(), vec!["analyze".into()], "dart analyze")
194        }
195        contract::Language::TypeScript => {
196            if !dir.join("package.json").exists() {
197                return "—".into();
198            }
199            (
200                "npx".into(),
201                vec!["tsc".into(), "--noEmit".into()],
202                "tsc --noEmit",
203            )
204        }
205        contract::Language::Unknown(_) => return "⚠ 语言未知,跳过".into(),
206    };
207    match std::process::Command::new(&cmd)
208        .args(&args)
209        .current_dir(dir)
210        .output()
211    {
212        Ok(o) if o.status.success() => format!("✅ {} 通过", label),
213        Ok(_) => format!("❌ {} 失败", label),
214        Err(_) => format!("⚠ {} 未安装", cmd),
215    }
216}
217
218fn is_working_tree_dirty(repo_path: &Path) -> bool {
219    match std::process::Command::new("git")
220        .args(["status", "--porcelain"])
221        .current_dir(repo_path)
222        .output()
223    {
224        Ok(o) => !o.stdout.is_empty(),
225        Err(_) => false,
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_print_scope_all_ok() {
235        let d = tempfile::tempdir().unwrap();
236        let c = contract::load(d.path());
237        let vs = contract::VersionStatus {
238            tag_version: Some("0.1.0".into()),
239            config_version: Some("0.1.0".into()),
240            consistent: true,
241            config_files: vec![("Cargo.toml".into(), Some("0.1.0".into()))],
242        };
243        let release = contract::StageRelease::default();
244        print_scope(
245            "test",
246            d.path(),
247            &contract::Language::Rust,
248            &vs,
249            &release,
250            &c,
251            None,
252        );
253    }
254
255    #[test]
256    fn test_is_working_tree_dirty_empty_repo() {
257        let d = tempfile::tempdir().unwrap();
258        assert!(!is_working_tree_dirty(d.path()));
259    }
260
261    #[test]
262    fn test_resolve_workflow_default() {
263        assert_eq!(resolve_workflow("cli", None), "build-cli");
264        assert_eq!(resolve_workflow("studio", None), "build-studio");
265    }
266
267    #[test]
268    fn test_resolve_workflow_custom() {
269        assert_eq!(resolve_workflow("cli", Some("my-pipeline")), "my-pipeline");
270        assert_eq!(resolve_workflow("cli", Some("release-ci")), "release-ci");
271    }
272
273    #[test]
274    fn test_detect_no_contract_yaml() {
275        let d = tempfile::tempdir().unwrap();
276        let c = contract::load(d.path());
277        assert!(c.scopes.is_empty());
278    }
279}