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