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// 测试
88// ═══════════════════════════════════════════════════════════════════════
89// ═══════════════════════════════════════════════════════════════════════
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use serde_yaml;
95
96    fn parse_yaml(s: &str) -> Contract {
97        serde_yaml::from_str(s).expect("YAML 应能解析")
98    }
99
100    // ── 完整契约 ──────────────────────────────────────────────────
101
102    #[test]
103    fn test_full_contract() {
104        let yaml = r#"
105stages:
106  build:
107    command: cargo build
108  test:
109    command: cargo test
110    threshold: 80.0
111  release:
112    changelog: CHANGELOG.md
113    pre_publish:
114      - cargo publish
115
116platform:
117  source_control: github
118  pipeline: github_actions
119  artifact_registry: crates
120
121sources:
122  version:
123    type: cargo
124
125scopes:
126  cli:
127    dir: src/cli
128    language: rust
129    build_tool: cargo
130    registry: crates
131    test_threshold: 90.0
132  web:
133    dir: src/web
134    language: typescript
135    build_tool: npm
136"#;
137        let c: Contract = parse_yaml(yaml);
138        assert_eq!(c.stages.build.command.as_deref(), Some("cargo build"));
139        assert_eq!(c.stages.test.threshold, 80.0);
140        assert_eq!(c.stages.test.command.as_deref(), Some("cargo test"));
141        assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
142        assert_eq!(
143            c.stages.release.pre_publish,
144            vec!["cargo publish".to_string()]
145        );
146
147        assert_eq!(c.platform.source_control, SourceControl::Github);
148        assert_eq!(c.platform.pipeline, Pipeline::GithubActions);
149        assert_eq!(c.platform.artifact_registry, Registry::Crates);
150
151        assert_eq!(c.sources.version.source_type, SourceType::Cargo);
152
153        assert_eq!(c.scopes.len(), 2);
154
155        let cli = &c.scopes[0];
156        assert_eq!(cli.name, "cli");
157        assert_eq!(cli.dir, "src/cli");
158        assert_eq!(cli.language, Language::Rust);
159        assert_eq!(cli.build_tool, BuildTool::Cargo);
160        assert_eq!(cli.registry, Registry::Crates);
161        assert_eq!(cli.test_threshold, Some(90.0));
162
163        let web = &c.scopes[1];
164        assert_eq!(web.name, "web");
165        assert_eq!(web.language, Language::TypeScript);
166        assert_eq!(web.build_tool, BuildTool::Npm);
167    }
168
169    // ── 最小契约(全默认值) ──────────────────────────────────────
170
171    #[test]
172    fn test_empty_contract() {
173        let yaml = r#"
174stages:
175scopes:
176"#;
177        let c: Contract = parse_yaml(yaml);
178        assert_eq!(c.stages.build.command, None);
179        assert_eq!(c.stages.test.threshold, 80.0);
180        assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
181        assert_eq!(c.platform.source_control, SourceControl::Github);
182        assert_eq!(c.sources.version.source_type, SourceType::Auto);
183        assert!(c.scopes.is_empty());
184    }
185
186    #[test]
187    fn test_fully_empty_yaml() {
188        let c: Contract = serde_yaml::from_str("").unwrap_or_default();
189        assert_eq!(c.stages.test.threshold, 80.0);
190        assert!(c.scopes.is_empty());
191    }
192
193    // ── Language 解析 ─────────────────────────────────────────────
194
195    #[test]
196    fn test_language_parse() {
197        let c: Contract = parse_yaml(
198            r#"
199scopes:
200  a:
201    dir: .
202    language: rust
203  b:
204    dir: .
205    language: typescript
206  c:
207    dir: .
208    language: ts
209  d:
210    dir: .
211    language: node
212  e:
213    dir: .
214    language: unknown_lang
215"#,
216        );
217        assert_eq!(c.scopes[0].language, Language::Rust);
218        assert_eq!(c.scopes[1].language, Language::TypeScript);
219        assert_eq!(c.scopes[2].language, Language::TypeScript);
220        assert_eq!(c.scopes[3].language, Language::TypeScript);
221        assert_eq!(
222            c.scopes[4].language,
223            Language::Unknown("unknown_lang".into())
224        );
225    }
226
227    // ── Registry 解析 ─────────────────────────────────────────────
228
229    #[test]
230    fn test_registry_parse() {
231        let c: Contract = parse_yaml(
232            r#"
233platform:
234  artifact_registry: pypi
235scopes:
236  s:
237    dir: .
238    registry: github_releases
239"#,
240        );
241        assert_eq!(c.platform.artifact_registry, Registry::PyPI);
242        assert_eq!(c.scopes[0].registry, Registry::GitHubReleases);
243    }
244
245    // ── SourceType 解析 ───────────────────────────────────────────
246
247    #[test]
248    fn test_source_type() {
249        let c: Contract = parse_yaml(
250            r#"
251sources:
252  version:
253    type: package.json
254"#,
255        );
256        assert_eq!(c.sources.version.source_type, SourceType::PackageJson);
257    }
258
259    // ── 便捷访问器 ────────────────────────────────────────────────
260
261    #[test]
262    fn test_scope_release_fallback() {
263        let c: Contract = parse_yaml(
264            r#"
265stages:
266  release:
267    changelog: CHANGELOG.md
268    pre_publish:
269      - cargo publish
270scopes:
271  cli:
272    dir: src/cli
273    language: rust
274"#,
275        );
276        let cli = &c.scopes[0];
277        let rel = c.scope_release(cli);
278        assert_eq!(rel.pre_publish, vec!["cargo publish".to_string()]);
279    }
280
281    #[test]
282    fn test_scope_release_override() {
283        let c: Contract = parse_yaml(
284            r#"
285stages:
286  release:
287    changelog: CHANGELOG.md
288scopes:
289  cli:
290    dir: src/cli
291    language: rust
292    release:
293      changelog: docs/CHANGELOG.md
294"#,
295        );
296        let cli = &c.scopes[0];
297        let rel = c.scope_release(cli);
298        assert_eq!(rel.changelog, "docs/CHANGELOG.md");
299    }
300
301    #[test]
302    fn test_scope_test_threshold() {
303        let c: Contract = parse_yaml(
304            r#"
305stages:
306  test:
307    threshold: 70.0
308scopes:
309  a:
310    dir: .
311  b:
312    dir: .
313    test_threshold: 90.0
314"#,
315        );
316        assert_eq!(c.scope_test_threshold(&c.scopes[0]), 70.0);
317        assert_eq!(c.scope_test_threshold(&c.scopes[1]), 90.0);
318    }
319
320    // ── find_scope_by_path ────────────────────────────────────────
321
322    #[test]
323    fn test_find_scope_by_path() {
324        let c: Contract = parse_yaml(
325            r#"
326scopes:
327  root:
328    dir: .
329  cli:
330    dir: src/cli
331  web:
332    dir: src/web
333"#,
334        );
335        assert_eq!(
336            c.find_scope_by_path(std::path::Path::new("src/cli/sub"))
337                .map(|s| s.name.as_str()),
338            Some("cli")
339        );
340        assert_eq!(
341            c.find_scope_by_path(std::path::Path::new("src/web"))
342                .map(|s| s.name.as_str()),
343            Some("web")
344        );
345        assert_eq!(
346            c.find_scope_by_path(std::path::Path::new("unknown"))
347                .map(|s| s.name.as_str()),
348            Some("root")
349        );
350    }
351
352    // ── resolve_language ──────────────────────────────────────────
353
354    #[test]
355    fn test_resolve_language_declared() {
356        let c: Contract = parse_yaml(
357            r#"
358scopes:
359  cli:
360    dir: .
361    language: rust
362"#,
363        );
364        let lang = c.resolve_language(&c.scopes[0], std::path::Path::new("/tmp"));
365        assert_eq!(lang, Language::Rust);
366    }
367
368    #[test]
369    fn test_resolve_language_auto() {
370        let d = tempfile::tempdir().unwrap();
371        std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
372        let c: Contract = parse_yaml(
373            r#"
374scopes:
375  cli:
376    dir: .
377"#,
378        );
379        let lang = c.resolve_language(&c.scopes[0], d.path());
380        assert_eq!(lang, Language::Rust);
381    }
382}