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