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