Skip to main content

quanttide_devops/contract/
core.rs

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