Skip to main content

qtcloud_devops_cli/
contract.rs

1/// 契约模块 — 基于 `quanttide-devops` toolkit 的适配层。
2pub use quanttide_devops::contract::{
3    detect_language_by_files, normalize_version, read_all_config_versions, validate_version,
4    BuildTool, Contract, Language, Pipeline, Platform, Registry, Scope, SourceControl, SourceType,
5    Stage, StageBuild, StageRelease, StageTest, VersionSource,
6};
7pub use quanttide_devops::source::git::{GitSourceError, VersionStatus};
8
9use std::path::Path;
10
11// ═══════════════════════════════════════════════════════════════════════
12// 加载(保留向后兼容的行为)
13// ═══════════════════════════════════════════════════════════════════════
14
15pub fn load(repo_path: &Path) -> Contract {
16    match quanttide_devops::contract::load(repo_path) {
17        Ok(c) => c,
18        Err(_) => auto_detect_contract(repo_path),
19    }
20}
21
22/// 无 contract.yaml 时自动推测仓库结构生成契约。
23fn auto_detect_contract(repo_path: &Path) -> Contract {
24    let root_lang = detect_language_by_files(repo_path);
25    let mut scopes: Vec<Scope> = Vec::new();
26
27    // 扫描常见 scope 子目录
28    for base in &["src", "packages", "apps"] {
29        let base_dir = repo_path.join(base);
30        if !base_dir.is_dir() {
31            continue;
32        }
33        if let Ok(entries) = std::fs::read_dir(&base_dir) {
34            for entry in entries.flatten() {
35                let sub = entry.path();
36                if !sub.is_dir() {
37                    continue;
38                }
39                let name = sub
40                    .file_name()
41                    .map(|n| n.to_string_lossy().to_string())
42                    .unwrap_or_default();
43                let sub_lang = detect_language_by_files(&sub);
44                if matches!(sub_lang, Language::Unknown(_)) {
45                    continue;
46                }
47                let dir = format!("{}/{}", base, name);
48                scopes.push(Scope {
49                    name,
50                    dir,
51                    language: sub_lang.clone(),
52                    framework: String::new(),
53                    build_tool: infer_build_tool(&sub_lang),
54                    registry: Registry::Crates,
55                    release: StageRelease::default(),
56                    test_threshold: None,
57                    ci_workflow: None,
58                });
59            }
60        }
61    }
62
63    // 根目录 scope(优先级最低,find_scope_by_path 以 dir 长度排序)
64    if !matches!(root_lang, Language::Unknown(_)) {
65        scopes.insert(
66            0,
67            Scope {
68                name: "(root)".into(),
69                dir: ".".into(),
70                language: root_lang.clone(),
71                framework: String::new(),
72                build_tool: infer_build_tool(&root_lang),
73                registry: Registry::Crates,
74                release: StageRelease::default(),
75                test_threshold: None,
76                ci_workflow: None,
77            },
78        );
79    }
80
81    Contract {
82        stages: Stage {
83            build: StageBuild {
84                command: Some("cargo build".into()),
85            },
86            test: StageTest {
87                command: Some("cargo test".into()),
88                ..StageTest::default()
89            },
90            release: StageRelease {
91                changelog: "CHANGELOG.md".into(),
92                pre_publish: Vec::new(),
93            },
94        },
95        scopes,
96        ..Contract::default()
97    }
98}
99
100fn infer_build_tool(lang: &Language) -> BuildTool {
101    match lang {
102        Language::Rust => BuildTool::Cargo,
103        Language::Python => BuildTool::Uv,
104        Language::Go => BuildTool::Go,
105        Language::Dart => BuildTool::Flutter,
106        Language::TypeScript => BuildTool::Npm,
107        Language::Unknown(_) => BuildTool::Unknown("auto".into()),
108    }
109}
110
111pub fn load_scopes(repo_path: &Path) -> Vec<Scope> {
112    load(repo_path).scopes
113}
114
115pub fn detect_by_files(dir: &Path) -> Language {
116    detect_language_by_files(dir)
117}
118
119/// 检测目录下的所有语言(不限于优先级最高的一个)。
120pub fn detect_all_languages(dir: &Path) -> Vec<String> {
121    let mut langs = Vec::new();
122    if dir.join("Cargo.toml").exists() {
123        langs.push("rust".into());
124    }
125    if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
126        langs.push("python".into());
127    }
128    if dir.join("go.mod").exists() {
129        langs.push("go".into());
130    }
131    if dir.join("pubspec.yaml").exists() {
132        langs.push("dart".into());
133    }
134    if dir.join("package.json").exists() {
135        langs.push("typescript".into());
136    }
137    langs
138}
139
140// ═══════════════════════════════════════════════════════════════════════
141// 版本状态(适配 toolkit 的 Result → 旧签名)
142// ═══════════════════════════════════════════════════════════════════════
143
144/// 检查 scope 版本一致性。失败时返回空的 VersionStatus。
145pub fn version_status(repo_path: &Path, scope: &Scope) -> VersionStatus {
146    quanttide_devops::source::git::version_status(repo_path, scope).unwrap_or_else(|e| {
147        eprintln!("  ⚠ 版本状态检查失败: {}", e);
148        VersionStatus {
149            tag_version: None,
150            config_version: None,
151            consistent: false,
152            config_files: vec![],
153        }
154    })
155}
156
157/// 显示当前契约的完整状态(调试/查看用)。
158pub fn status(repo_path: &Path) {
159    let mut stdout = std::io::stdout();
160    status_to(&mut stdout, repo_path).ok();
161}
162
163/// 写入指定 writer 的版本(可测试)。
164pub fn status_to(writer: &mut impl std::io::Write, repo_path: &Path) -> std::io::Result<()> {
165    let contract_path = repo_path.join(".quanttide/devops/contract.yaml");
166    let exists = contract_path.exists();
167
168    let c = load(repo_path);
169
170    writeln!(writer, "契约状态")?;
171    writeln!(writer, "{}", "-".repeat(40))?;
172
173    if exists {
174        writeln!(writer, "  配置文件:  {}", contract_path.display())?;
175        writeln!(writer, "  状态:      ✅ 已加载")?;
176    } else {
177        writeln!(writer, "  配置文件:  未找到,使用默认契约")?;
178        writeln!(writer, "  状态:      ⚠ 默认配置")?;
179    }
180    writeln!(writer)?;
181
182    // Stages
183    writeln!(writer, "  Stages:")?;
184    let b = &c.stages.build;
185    writeln!(
186        writer,
187        "    build:    {}",
188        b.command.as_deref().unwrap_or("—")
189    )?;
190    let t = &c.stages.test;
191    writeln!(
192        writer,
193        "    test:     {}(阈值 {}%)",
194        t.command.as_deref().unwrap_or("—"),
195        t.threshold
196    )?;
197    let r = &c.stages.release;
198    writeln!(
199        writer,
200        "    release:  {}(pre_publish: {:?})",
201        r.changelog, r.pre_publish
202    )?;
203    writeln!(writer)?;
204
205    // Platform
206    writeln!(writer, "  Platform:")?;
207    writeln!(
208        writer,
209        "    source_control:   {:?}",
210        c.platform.source_control
211    )?;
212    writeln!(writer, "    pipeline:         {:?}", c.platform.pipeline)?;
213    writeln!(
214        writer,
215        "    artifact_registry: {}",
216        c.platform.artifact_registry
217    )?;
218    writeln!(writer)?;
219
220    // Sources
221    writeln!(writer, "  Sources:")?;
222    writeln!(
223        writer,
224        "    version:  {:?} {:?}",
225        c.sources.version.source_type, c.sources.version.path
226    )?;
227    writeln!(writer)?;
228
229    // Scopes
230    writeln!(writer, "  Scopes:  {} 个", c.scopes.len())?;
231    if c.scopes.is_empty() {
232        writeln!(writer, "    未定义 scope")?;
233    } else {
234        for s in &c.scopes {
235            writeln!(
236                writer,
237                "    {:<12} dir: {:<24} {} / {}",
238                s.name,
239                s.dir,
240                s.language.as_str(),
241                s.build_tool.as_str()
242            )?;
243        }
244    }
245
246    // 语言汇总
247    let mut langs: Vec<String> = Vec::new();
248    for s in &c.scopes {
249        let scope_dir = repo_path.join(&s.dir);
250        let mut scope_langs = detect_all_languages(&scope_dir);
251        langs.append(&mut scope_langs);
252    }
253    langs.sort();
254    langs.dedup();
255    if !langs.is_empty() {
256        writeln!(writer)?;
257        writeln!(writer, "  语言:      {}", langs.join(", "))?;
258    }
259
260    Ok(())
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_version_status_git_error_returns_fallback() {
269        let scope = Scope {
270            name: "test".into(),
271            dir: ".".into(),
272            language: Language::Rust,
273            framework: String::new(),
274            build_tool: BuildTool::Cargo,
275            registry: Registry::None,
276            release: StageRelease::default(),
277            test_threshold: None,
278            ci_workflow: None,
279        };
280        let vs = version_status(Path::new("/nonexistent"), &scope);
281        assert!(!vs.consistent);
282        assert_eq!(vs.tag_version, None);
283        assert_eq!(vs.config_version, None);
284        assert!(vs.config_files.is_empty());
285    }
286
287    #[test]
288    fn test_status_to_with_contract() {
289        let d = tempfile::tempdir().unwrap();
290        // 创建 contract.yaml
291        let contract_dir = d.path().join(".quanttide/devops");
292        std::fs::create_dir_all(&contract_dir).unwrap();
293        std::fs::write(
294            contract_dir.join("contract.yaml"),
295            "scopes:\n  test:\n    dir: .\n    language: rust\n",
296        )
297        .unwrap();
298        let mut buf = Vec::new();
299        status_to(&mut buf, d.path()).unwrap();
300        let out = String::from_utf8_lossy(&buf);
301        assert!(out.contains("✅ 已加载"));
302        assert!(out.contains("test"));
303        assert!(out.contains("rust"));
304    }
305
306    #[test]
307    fn test_status_to_without_contract() {
308        let d = tempfile::tempdir().unwrap();
309        let mut buf = Vec::new();
310        status_to(&mut buf, d.path()).unwrap();
311        let out = String::from_utf8_lossy(&buf);
312        assert!(out.contains("⚠ 默认配置"));
313        assert!(out.contains("未定义 scope"));
314    }
315
316    #[test]
317    fn test_detect_all_languages_empty() {
318        let d = tempfile::tempdir().unwrap();
319        let langs = detect_all_languages(d.path());
320        assert!(langs.is_empty(), "空目录应返回空");
321    }
322
323    #[test]
324    fn test_detect_all_languages_multi() {
325        let d = tempfile::tempdir().unwrap();
326        std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
327        std::fs::write(d.path().join("pyproject.toml"), "").unwrap();
328        std::fs::write(d.path().join("package.json"), "{}").unwrap();
329        let mut langs = detect_all_languages(d.path());
330        langs.sort();
331        assert_eq!(langs, vec!["python", "rust", "typescript"]);
332    }
333
334    #[test]
335    fn test_detect_all_languages_edge() {
336        let d = tempfile::tempdir().unwrap();
337        std::fs::write(d.path().join("go.mod"), "").unwrap();
338        std::fs::write(d.path().join("pubspec.yaml"), "").unwrap();
339        std::fs::write(d.path().join("requirements.txt"), "").unwrap();
340        let mut langs = detect_all_languages(d.path());
341        langs.sort();
342        assert!(langs.contains(&"go".to_string()));
343        assert!(langs.contains(&"dart".to_string()));
344        assert!(langs.contains(&"python".to_string()));
345    }
346
347    #[test]
348    fn test_auto_detect_with_packages() {
349        let d = tempfile::tempdir().unwrap();
350        std::fs::create_dir_all(d.path().join("packages/foo")).unwrap();
351        std::fs::write(d.path().join("packages/foo/Cargo.toml"), "[package]\n").unwrap();
352        std::fs::create_dir_all(d.path().join("src/cli")).unwrap();
353        std::fs::write(d.path().join("src/cli/Cargo.toml"), "[package]\n").unwrap();
354        let c = auto_detect_contract(d.path());
355        assert_eq!(c.scopes.len(), 2, "应有 2 个 scope");
356        let names: Vec<&str> = c.scopes.iter().map(|s| s.name.as_str()).collect();
357        assert!(names.contains(&"foo"));
358        assert!(names.contains(&"cli"));
359    }
360
361    #[test]
362    fn test_auto_detect_empty_repo() {
363        let d = tempfile::tempdir().unwrap();
364        let c = auto_detect_contract(d.path());
365        assert!(c.scopes.is_empty(), "空目录 scopes 应为空");
366    }
367
368    #[test]
369    fn test_auto_detect_root_only() {
370        let d = tempfile::tempdir().unwrap();
371        std::fs::write(d.path().join("Cargo.toml"), "[package]\n").unwrap();
372        let c = auto_detect_contract(d.path());
373        assert!(!c.scopes.is_empty());
374        assert!(c.scopes.iter().any(|s| s.name == "(root)"));
375    }
376}