Skip to main content

quanttide_devops/contract/
core.rs

1use super::{platform::*, scope::*, source::*, stage::*};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5// ── Contract ──────────────────────────────────────────────────────────
6
7/// 完整契约,对应 `.quanttide/devops/contract.yaml`。
8///
9/// 按四维架构组织:Stage(时序)、Platform(载体)、Source(事实源)、Scope(边界)。
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub struct Contract {
13    #[serde(default)]
14    pub stages: Stage,
15    #[serde(default)]
16    pub platform: Platform,
17    #[serde(default)]
18    pub sources: Source,
19    #[serde(default, deserialize_with = "deserialize_scopes")]
20    pub scopes: Vec<Scope>,
21}
22
23// ── 便捷访问器 ────────────────────────────────────────────────────────
24
25impl Contract {
26    /// 获取 scope 的发布配置(scope 级覆盖 → 全局默认)。
27    pub fn scope_release<'a>(&'a self, scope: &'a Scope) -> &'a StageRelease {
28        let has_custom =
29            !scope.release.pre_publish.is_empty() || scope.release.changelog != "CHANGELOG.md";
30        if has_custom {
31            &scope.release
32        } else {
33            &self.stages.release
34        }
35    }
36
37    /// 获取 scope 的测试阈值。
38    pub fn scope_test_threshold(&self, scope: &Scope) -> f64 {
39        scope.test_threshold.unwrap_or(self.stages.test.threshold)
40    }
41
42    /// 根据路径查找匹配的 scope(最长前缀匹配)。
43    ///
44    /// 例如当前在 `src/cli/sub` 时,`cli` scope(dir: `src/cli`)
45    /// 比 root scope(dir: `.`)优先级高。
46    pub fn find_scope_by_path(&self, current_dir: &Path) -> Option<&Scope> {
47        let current_str = current_dir.to_string_lossy();
48        self.scopes
49            .iter()
50            .filter(|s| current_str.starts_with(&s.dir) || s.dir == ".")
51            .max_by_key(|s| s.dir.len())
52    }
53
54    /// 语言探测:scope 声明了具体语言则返回,否则按目录文件推测。
55    pub fn resolve_language(&self, scope: &Scope, scope_dir: &Path) -> Language {
56        match &scope.language {
57            Language::Unknown(_) => crate::source::config_file::detect_languages(scope_dir)
58                .into_iter()
59                .next()
60                .unwrap_or(Language::Unknown("无法识别".into())),
61            lang => lang.clone(),
62        }
63    }
64
65    /// 验算契约:检查 scope 配置是否合法。
66    ///
67    /// 返回所有问题的描述列表,空表示合法。
68    ///
69    /// ```
70    /// use std::path::Path;
71    /// use quanttide_devops::contract::Contract;
72    ///
73    /// let c = Contract::default();
74    /// let errors = c.validate(Path::new("/tmp/nonexistent"));
75    /// assert!(errors.is_empty()); // 空契约→无 scope 可检查
76    /// ```
77    pub fn validate(&self, repo_path: &Path) -> Vec<String> {
78        let mut errors = Vec::new();
79        for scope in &self.scopes {
80            let dir = repo_path.join(&scope.dir);
81            if !dir.exists() {
82                errors.push(format!("scope '{}' 目录不存在: {}", scope.name, scope.dir));
83            }
84        }
85        errors
86    }
87
88    /// 根据目录下的配置文件自动推测仓库结构,生成契约。
89    ///
90    /// 扫描 `src/`、`packages/`、`apps/` 下的子目录,检测每个子目录的编程语言并创建 scope。
91    /// 如果根目录也存在已知配置文件,添加一个 `(root)` scope。
92    ///
93    /// ```
94    /// use std::path::Path;
95    /// use quanttide_devops::contract::Contract;
96    ///
97    /// let c = Contract::auto_detect(Path::new("/tmp/nonexistent"));
98    /// assert!(c.scopes.is_empty());
99    /// ```
100    pub fn auto_detect(repo_path: &Path) -> Self {
101        let root_langs = crate::source::config_file::detect_languages(repo_path);
102
103        let mut scopes: Vec<Scope> = Vec::new();
104        for base in &["src", "packages", "apps"] {
105            let base_dir = repo_path.join(base);
106            if !base_dir.is_dir() {
107                continue;
108            }
109            if let Ok(entries) = std::fs::read_dir(&base_dir) {
110                for entry in entries.flatten() {
111                    let sub = entry.path();
112                    if !sub.is_dir() {
113                        continue;
114                    }
115                    let name = match sub.file_name() {
116                        Some(n) => n.to_string_lossy().to_string(),
117                        None => continue,
118                    };
119                    let sub_langs = crate::source::config_file::detect_languages(&sub);
120                    if sub_langs.is_empty() {
121                        continue;
122                    }
123                    // 子目录通常只有一种语言,取第一个
124                    let sub_lang = sub_langs.into_iter().next().unwrap();
125                    scopes.push(Scope {
126                        name,
127                        dir: format!("{}/{}", base, &sub.file_name().unwrap().to_string_lossy()),
128                        language: sub_lang.clone(),
129                        build_tool: sub_lang.default_build_tool(),
130                        framework: String::new(),
131                        registry: sub_lang.default_registry(),
132                        release: StageRelease::default(),
133                        test_threshold: None,
134                        ci_workflow: None,
135                    });
136                }
137            }
138        }
139
140        if let Some(root_lang) = root_langs.into_iter().next() {
141            scopes.insert(
142                0,
143                Scope {
144                    name: "(root)".into(),
145                    dir: ".".into(),
146                    language: root_lang.clone(),
147                    build_tool: root_lang.default_build_tool(),
148                    framework: String::new(),
149                    registry: root_lang.default_registry(),
150                    release: StageRelease::default(),
151                    test_threshold: None,
152                    ci_workflow: None,
153                },
154            );
155        }
156
157        Self {
158            stages: Stage {
159                build: StageBuild {
160                    command: Some("cargo build".into()),
161                },
162                test: StageTest {
163                    command: Some("cargo test".into()),
164                    ..StageTest::default()
165                },
166                release: StageRelease::default(),
167            },
168            scopes,
169            ..Self::default()
170        }
171    }
172}
173
174// ═══════════════════════════════════════════════════════════════════════
175// 测试
176// ═══════════════════════════════════════════════════════════════════════
177// ═══════════════════════════════════════════════════════════════════════
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use serde_yaml;
183
184    fn parse_yaml(s: &str) -> Contract {
185        serde_yaml::from_str(s).expect("YAML 应能解析")
186    }
187
188    // ── 完整契约 ──────────────────────────────────────────────────
189
190    #[test]
191    fn test_full_contract() {
192        let yaml = r#"
193stages:
194  build:
195    command: cargo build
196  test:
197    command: cargo test
198    threshold: 80.0
199  release:
200    changelog: CHANGELOG.md
201    pre_publish:
202      - cargo publish
203
204platform:
205  source_control: github
206  pipeline: github_actions
207  artifact_registry: crates
208
209sources:
210  version:
211    type: cargo
212
213scopes:
214  cli:
215    dir: src/cli
216    language: rust
217    build_tool: cargo
218    registry: crates
219    test_threshold: 90.0
220  web:
221    dir: src/web
222    language: typescript
223    build_tool: npm
224"#;
225        let c: Contract = parse_yaml(yaml);
226        assert_eq!(c.stages.build.command.as_deref(), Some("cargo build"));
227        assert_eq!(c.stages.test.threshold, 80.0);
228        assert_eq!(c.stages.test.command.as_deref(), Some("cargo test"));
229        assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
230        assert_eq!(
231            c.stages.release.pre_publish,
232            vec!["cargo publish".to_string()]
233        );
234
235        assert_eq!(c.platform.source_control, SourceControl::Github);
236        assert_eq!(c.platform.pipeline, Pipeline::GithubActions);
237        assert_eq!(c.platform.artifact_registry, Registry::Crates);
238
239        assert_eq!(c.sources.version.source_type, SourceType::Cargo);
240
241        assert_eq!(c.scopes.len(), 2);
242
243        let cli = &c.scopes[0];
244        assert_eq!(cli.name, "cli");
245        assert_eq!(cli.dir, "src/cli");
246        assert_eq!(cli.language, Language::Rust);
247        assert_eq!(cli.build_tool, BuildTool::Cargo);
248        assert_eq!(cli.registry, Registry::Crates);
249        assert_eq!(cli.test_threshold, Some(90.0));
250
251        let web = &c.scopes[1];
252        assert_eq!(web.name, "web");
253        assert_eq!(web.language, Language::TypeScript);
254        assert_eq!(web.build_tool, BuildTool::Npm);
255    }
256
257    // ── 最小契约(全默认值) ──────────────────────────────────────
258
259    #[test]
260    fn test_empty_contract() {
261        let yaml = r#"
262stages:
263scopes:
264"#;
265        let c: Contract = parse_yaml(yaml);
266        assert_eq!(c.stages.build.command, None);
267        assert_eq!(c.stages.test.threshold, 80.0);
268        assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
269        assert_eq!(c.platform.source_control, SourceControl::Github);
270        assert_eq!(c.sources.version.source_type, SourceType::Auto);
271        assert!(c.scopes.is_empty());
272    }
273
274    #[test]
275    fn test_fully_empty_yaml() {
276        let c: Contract = serde_yaml::from_str("").unwrap_or_default();
277        assert_eq!(c.stages.test.threshold, 80.0);
278        assert!(c.scopes.is_empty());
279    }
280
281    // ── Language 解析 ─────────────────────────────────────────────
282
283    #[test]
284    fn test_language_parse() {
285        let c: Contract = parse_yaml(
286            r#"
287scopes:
288  a:
289    dir: .
290    language: rust
291  b:
292    dir: .
293    language: typescript
294  c:
295    dir: .
296    language: ts
297  d:
298    dir: .
299    language: node
300  e:
301    dir: .
302    language: unknown_lang
303"#,
304        );
305        assert_eq!(c.scopes[0].language, Language::Rust);
306        assert_eq!(c.scopes[1].language, Language::TypeScript);
307        assert_eq!(c.scopes[2].language, Language::TypeScript);
308        assert_eq!(c.scopes[3].language, Language::TypeScript);
309        assert_eq!(
310            c.scopes[4].language,
311            Language::Unknown("unknown_lang".into())
312        );
313    }
314
315    // ── Registry 解析 ─────────────────────────────────────────────
316
317    #[test]
318    fn test_registry_parse() {
319        let c: Contract = parse_yaml(
320            r#"
321platform:
322  artifact_registry: pypi
323scopes:
324  s:
325    dir: .
326    registry: github_releases
327"#,
328        );
329        assert_eq!(c.platform.artifact_registry, Registry::PyPI);
330        assert_eq!(c.scopes[0].registry, Registry::GitHubReleases);
331    }
332
333    // ── SourceType 解析 ───────────────────────────────────────────
334
335    #[test]
336    fn test_source_type() {
337        let c: Contract = parse_yaml(
338            r#"
339sources:
340  version:
341    type: package.json
342"#,
343        );
344        assert_eq!(c.sources.version.source_type, SourceType::PackageJson);
345    }
346
347    // ── 便捷访问器 ────────────────────────────────────────────────
348
349    #[test]
350    fn test_scope_release_fallback() {
351        let c: Contract = parse_yaml(
352            r#"
353stages:
354  release:
355    changelog: CHANGELOG.md
356    pre_publish:
357      - cargo publish
358scopes:
359  cli:
360    dir: src/cli
361    language: rust
362"#,
363        );
364        let cli = &c.scopes[0];
365        let rel = c.scope_release(cli);
366        assert_eq!(rel.pre_publish, vec!["cargo publish".to_string()]);
367    }
368
369    #[test]
370    fn test_scope_release_override() {
371        let c: Contract = parse_yaml(
372            r#"
373stages:
374  release:
375    changelog: CHANGELOG.md
376scopes:
377  cli:
378    dir: src/cli
379    language: rust
380    release:
381      changelog: docs/CHANGELOG.md
382"#,
383        );
384        let cli = &c.scopes[0];
385        let rel = c.scope_release(cli);
386        assert_eq!(rel.changelog, "docs/CHANGELOG.md");
387    }
388
389    #[test]
390    fn test_scope_test_threshold() {
391        let c: Contract = parse_yaml(
392            r#"
393stages:
394  test:
395    threshold: 70.0
396scopes:
397  a:
398    dir: .
399  b:
400    dir: .
401    test_threshold: 90.0
402"#,
403        );
404        assert_eq!(c.scope_test_threshold(&c.scopes[0]), 70.0);
405        assert_eq!(c.scope_test_threshold(&c.scopes[1]), 90.0);
406    }
407
408    // ── find_scope_by_path ────────────────────────────────────────
409
410    #[test]
411    fn test_find_scope_by_path() {
412        let c: Contract = parse_yaml(
413            r#"
414scopes:
415  root:
416    dir: .
417  cli:
418    dir: src/cli
419  web:
420    dir: src/web
421"#,
422        );
423        assert_eq!(
424            c.find_scope_by_path(std::path::Path::new("src/cli/sub"))
425                .map(|s| s.name.as_str()),
426            Some("cli")
427        );
428        assert_eq!(
429            c.find_scope_by_path(std::path::Path::new("src/web"))
430                .map(|s| s.name.as_str()),
431            Some("web")
432        );
433        assert_eq!(
434            c.find_scope_by_path(std::path::Path::new("unknown"))
435                .map(|s| s.name.as_str()),
436            Some("root")
437        );
438    }
439
440    // ── resolve_language ──────────────────────────────────────────
441
442    #[test]
443    fn test_resolve_language_declared() {
444        let c: Contract = parse_yaml(
445            r#"
446scopes:
447  cli:
448    dir: .
449    language: rust
450"#,
451        );
452        let lang = c.resolve_language(&c.scopes[0], std::path::Path::new("/tmp"));
453        assert_eq!(lang, Language::Rust);
454    }
455
456    #[test]
457    fn test_resolve_language_auto() {
458        let d = tempfile::tempdir().unwrap();
459        std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
460        let c: Contract = parse_yaml(
461            r#"
462scopes:
463  cli:
464    dir: .
465"#,
466        );
467        let lang = c.resolve_language(&c.scopes[0], d.path());
468        assert_eq!(lang, Language::Rust);
469    }
470}