Skip to main content

quanttide_devops/contract/
model.rs

1use serde::de::{Deserializer, MapAccess, Visitor};
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::path::Path;
5
6// ── Contract ──────────────────────────────────────────────────────────
7
8/// 完整契约,对应 `.quanttide/devops/contract.yaml`。
9///
10/// 按四维架构组织:Stage(时序)、Platform(载体)、Source(事实源)、Scope(边界)。
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub struct Contract {
14    #[serde(default)]
15    pub stages: Stage,
16    #[serde(default)]
17    pub platform: Platform,
18    #[serde(default)]
19    pub sources: Source,
20    #[serde(default, deserialize_with = "deserialize_scopes")]
21    pub scopes: Vec<Scope>,
22}
23
24// ── Stages(时序维度)────────────────────────────────────────────────
25
26/// 生命周期阶段配置。
27///
28/// 不规定"怎么做",只规定"什么时候检查什么"。
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub struct Stage {
32    #[serde(default)]
33    pub build: StageBuild,
34    #[serde(default)]
35    pub test: StageTest,
36    #[serde(default)]
37    pub release: StageRelease,
38}
39
40impl Default for Stage {
41    fn default() -> Self {
42        Self {
43            build: StageBuild { command: None },
44            test: StageTest {
45                command: None,
46                threshold: 70.0,
47            },
48            release: StageRelease {
49                changelog: "CHANGELOG.md".into(),
50                pre_publish: Vec::new(),
51            },
52        }
53    }
54}
55
56/// 构建阶段。
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub struct StageBuild {
60    #[serde(default)]
61    pub command: Option<String>,
62}
63
64/// 测试阶段。
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub struct StageTest {
68    #[serde(default)]
69    pub command: Option<String>,
70    #[serde(default = "default_threshold")]
71    pub threshold: f64,
72}
73
74impl Default for StageTest {
75    fn default() -> Self {
76        Self {
77            command: None,
78            threshold: 70.0,
79        }
80    }
81}
82
83const fn default_threshold() -> f64 {
84    70.0
85}
86
87/// 发布阶段。
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub struct StageRelease {
91    #[serde(default = "default_changelog")]
92    pub changelog: String,
93    #[serde(default)]
94    pub pre_publish: Vec<String>,
95}
96
97fn default_changelog() -> String {
98    "CHANGELOG.md".into()
99}
100
101impl Default for StageRelease {
102    fn default() -> Self {
103        Self {
104            changelog: "CHANGELOG.md".into(),
105            pre_publish: Vec::new(),
106        }
107    }
108}
109
110// ── Platforms(载体维度)──────────────────────────────────────────────
111
112/// 外部治理载体配置。
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub struct Platform {
116    #[serde(default)]
117    pub source_control: SourceControl,
118    #[serde(default)]
119    pub pipeline: Pipeline,
120    #[serde(default)]
121    pub artifact_registry: Registry,
122}
123
124impl Default for Platform {
125    fn default() -> Self {
126        Self {
127            source_control: SourceControl::Github,
128            pipeline: Pipeline::GithubActions,
129            artifact_registry: Registry::None,
130        }
131    }
132}
133
134/// 源代码管理平台。
135#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum SourceControl {
138    #[default]
139    Github,
140    Gitlab,
141    Gitee,
142}
143
144/// Pipeline 平台。
145#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum Pipeline {
148    #[default]
149    #[serde(rename = "github_actions")]
150    GithubActions,
151    #[serde(rename = "gitlab_ci")]
152    GitlabCi,
153    Jenkins,
154}
155
156/// 制品库类型。
157///
158/// 既可用于全局 `Platforms.artifact_registry`,也可用于 scope 级别覆盖。
159#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum Registry {
162    Crates,
163    #[serde(rename = "pypi")]
164    PyPI,
165    #[serde(rename = "pubdev")]
166    PubDev,
167    Npm,
168    #[serde(rename = "github_releases")]
169    GitHubReleases,
170    Docker,
171    #[default]
172    #[serde(other)]
173    None,
174}
175
176// ── Sources(事实源维度)──────────────────────────────────────────────
177
178/// 事实源配置。
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub struct Source {
182    #[serde(default)]
183    pub version: VersionSource,
184}
185
186impl Default for Source {
187    fn default() -> Self {
188        Self {
189            version: VersionSource {
190                source_type: SourceType::Auto,
191                path: None,
192            },
193        }
194    }
195}
196
197/// 版本号来源配置。
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub struct VersionSource {
201    /// YAML key 为 `type`(Rust 保留字,故字段命名避开)。
202    #[serde(default, rename = "type")]
203    pub source_type: SourceType,
204    #[serde(default)]
205    pub path: Option<String>,
206}
207
208impl Default for VersionSource {
209    fn default() -> Self {
210        Self {
211            source_type: SourceType::Auto,
212            path: None,
213        }
214    }
215}
216
217/// 版本号读取来源。
218#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum SourceType {
221    Cargo,
222    Pyproject,
223    /// 不从配置文件读版本,只从 git tag 读。
224    TagOnly,
225    Pubspec,
226    #[serde(rename = "package.json")]
227    PackageJson,
228    /// 自动检测。
229    #[default]
230    Auto,
231}
232
233// ── Scopes(上下文维度)───────────────────────────────────────────────
234
235/// 作用域(上下文维度)。
236///
237/// 通过 scope 为不同组件挂载不同的 Stage、Platform、Source 组合。
238#[derive(Debug, Clone, Serialize)]
239#[serde(rename_all = "snake_case")]
240pub struct Scope {
241    pub name: String,
242    pub dir: String,
243    #[serde(default)]
244    pub language: Language,
245    #[serde(default)]
246    pub framework: String,
247    #[serde(default)]
248    pub build_tool: BuildTool,
249    #[serde(default)]
250    pub registry: Registry,
251    #[serde(default)]
252    pub release: StageRelease,
253    #[serde(default)]
254    pub test_threshold: Option<f64>,
255    #[serde(default)]
256    pub ci_workflow: Option<String>,
257}
258
259/// 编程语言。
260#[derive(Debug, Clone, PartialEq, Serialize)]
261#[serde(rename_all = "snake_case")]
262pub enum Language {
263    Rust,
264    Python,
265    Go,
266    Dart,
267    #[serde(rename = "typescript")]
268    TypeScript,
269    Unknown(String),
270}
271
272impl Default for Language {
273    fn default() -> Self {
274        Self::Unknown("auto".into())
275    }
276}
277
278/// 构建工具。
279#[derive(Debug, Clone, PartialEq, Serialize)]
280#[serde(rename_all = "snake_case")]
281pub enum BuildTool {
282    Cargo,
283    Uv,
284    Go,
285    Flutter,
286    Npm,
287    Unknown(String),
288}
289
290impl Default for BuildTool {
291    fn default() -> Self {
292        Self::Unknown("auto".into())
293    }
294}
295
296// ── 自定义反序列化(Language / BuildTool)────────────────────────────
297
298impl<'de> Deserialize<'de> for Language {
299    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
300    where
301        D: Deserializer<'de>,
302    {
303        let s = String::deserialize(deserializer)?;
304        Ok(match s.as_str() {
305            "rust" => Language::Rust,
306            "python" => Language::Python,
307            "go" => Language::Go,
308            "dart" => Language::Dart,
309            "typescript" | "ts" | "node" => Language::TypeScript,
310            other => Language::Unknown(other.to_string()),
311        })
312    }
313}
314
315impl<'de> Deserialize<'de> for BuildTool {
316    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
317    where
318        D: Deserializer<'de>,
319    {
320        let s = String::deserialize(deserializer)?;
321        Ok(match s.as_str() {
322            "cargo" => BuildTool::Cargo,
323            "uv" | "poetry" | "pdm" => BuildTool::Uv,
324            "go" => BuildTool::Go,
325            "flutter" => BuildTool::Flutter,
326            "npm" | "pnpm" | "yarn" | "bun" => BuildTool::Npm,
327            other => BuildTool::Unknown(other.to_string()),
328        })
329    }
330}
331
332// ── 自定义反序列化(scopes: map → Vec<Scope>)────────────────────────
333
334/// YAML 中的 scope 原始配置(map 格式的中间表示)。
335#[derive(Debug, Deserialize)]
336#[serde(rename_all = "snake_case")]
337struct ScopeConfig {
338    dir: String,
339    #[serde(default)]
340    language: Option<Language>,
341    #[serde(default)]
342    framework: Option<String>,
343    #[serde(default)]
344    build_tool: Option<BuildTool>,
345    #[serde(default)]
346    registry: Option<Registry>,
347    #[serde(default)]
348    release: Option<StageRelease>,
349    #[serde(default)]
350    test_threshold: Option<f64>,
351    #[serde(default)]
352    ci_workflow: Option<String>,
353}
354
355fn deserialize_scopes<'de, D>(deserializer: D) -> Result<Vec<Scope>, D::Error>
356where
357    D: Deserializer<'de>,
358{
359    /// 访问器:将 `{ name: { dir, ... } }` 转为 `[Scope, ...]`。
360    struct ScopesVisitor;
361
362    impl<'de> Visitor<'de> for ScopesVisitor {
363        type Value = Vec<Scope>;
364
365        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
366            f.write_str("作用域映射")
367        }
368
369        fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
370        where
371            M: MapAccess<'de>,
372        {
373            let mut scopes = Vec::new();
374            while let Some((name, config)) = access.next_entry::<String, ScopeConfig>()? {
375                scopes.push(Scope {
376                    name,
377                    dir: config.dir,
378                    language: config.language.unwrap_or(Language::Unknown("auto".into())),
379                    framework: config.framework.unwrap_or_default(),
380                    build_tool: config
381                        .build_tool
382                        .unwrap_or(BuildTool::Unknown("auto".into())),
383                    registry: config.registry.unwrap_or(Registry::None),
384                    release: config.release.unwrap_or_default(),
385                    test_threshold: config.test_threshold,
386                    ci_workflow: config.ci_workflow,
387                });
388            }
389            Ok(scopes)
390        }
391    }
392
393    deserializer.deserialize_map(ScopesVisitor)
394}
395
396// ── 便捷访问器 ────────────────────────────────────────────────────────
397
398impl Contract {
399    /// 获取 scope 的发布配置(scope 级覆盖 → 全局默认)。
400    pub fn scope_release<'a>(&'a self, scope: &'a Scope) -> &'a StageRelease {
401        let has_custom =
402            !scope.release.pre_publish.is_empty() || scope.release.changelog != "CHANGELOG.md";
403        if has_custom {
404            &scope.release
405        } else {
406            &self.stages.release
407        }
408    }
409
410    /// 获取 scope 的测试阈值。
411    pub fn scope_test_threshold(&self, scope: &Scope) -> f64 {
412        scope.test_threshold.unwrap_or(self.stages.test.threshold)
413    }
414
415    /// 根据路径查找匹配的 scope(最长前缀匹配)。
416    ///
417    /// 例如当前在 `src/cli/sub` 时,`cli` scope(dir: `src/cli`)
418    /// 比 root scope(dir: `.`)优先级高。
419    pub fn find_scope_by_path(&self, current_dir: &Path) -> Option<&Scope> {
420        let current_str = current_dir.to_string_lossy();
421        self.scopes
422            .iter()
423            .filter(|s| current_str.starts_with(&s.dir) || s.dir == ".")
424            .max_by_key(|s| s.dir.len())
425    }
426
427    /// 语言探测:scope 声明了具体语言则返回,否则按目录文件推测。
428    pub fn resolve_language(&self, scope: &Scope, scope_dir: &Path) -> Language {
429        match &scope.language {
430            Language::Unknown(_) => detect_language_by_files(scope_dir),
431            lang => lang.clone(),
432        }
433    }
434}
435
436/// 根据目录下的标志文件推测编程语言。
437pub fn detect_language_by_files(dir: &Path) -> Language {
438    if dir.join("Cargo.toml").exists() {
439        Language::Rust
440    } else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
441        Language::Python
442    } else if dir.join("go.mod").exists() {
443        Language::Go
444    } else if dir.join("pubspec.yaml").exists() {
445        Language::Dart
446    } else if dir.join("package.json").exists() {
447        Language::TypeScript
448    } else {
449        Language::Unknown("无法识别".into())
450    }
451}
452
453// ═══════════════════════════════════════════════════════════════════════
454// 测试
455// ═══════════════════════════════════════════════════════════════════════
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use serde_yaml;
461
462    fn parse_yaml(s: &str) -> Contract {
463        serde_yaml::from_str(s).expect("YAML 应能解析")
464    }
465
466    // ── 完整契约 ──────────────────────────────────────────────────
467
468    #[test]
469    fn test_full_contract() {
470        let yaml = r#"
471stages:
472  build:
473    command: cargo build
474  test:
475    command: cargo test
476    threshold: 80.0
477  release:
478    changelog: CHANGELOG.md
479    pre_publish:
480      - cargo publish
481
482platform:
483  source_control: github
484  pipeline: github_actions
485  artifact_registry: crates
486
487sources:
488  version:
489    type: cargo
490
491scopes:
492  cli:
493    dir: src/cli
494    language: rust
495    build_tool: cargo
496    registry: crates
497    test_threshold: 90.0
498  web:
499    dir: src/web
500    language: typescript
501    build_tool: npm
502"#;
503        let c: Contract = parse_yaml(yaml);
504        assert_eq!(c.stages.build.command.as_deref(), Some("cargo build"));
505        assert_eq!(c.stages.test.threshold, 80.0);
506        assert_eq!(c.stages.test.command.as_deref(), Some("cargo test"));
507        assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
508        assert_eq!(
509            c.stages.release.pre_publish,
510            vec!["cargo publish".to_string()]
511        );
512
513        assert_eq!(c.platform.source_control, SourceControl::Github);
514        assert_eq!(c.platform.pipeline, Pipeline::GithubActions);
515        assert_eq!(c.platform.artifact_registry, Registry::Crates);
516
517        assert_eq!(c.sources.version.source_type, SourceType::Cargo);
518
519        assert_eq!(c.scopes.len(), 2);
520
521        let cli = &c.scopes[0];
522        assert_eq!(cli.name, "cli");
523        assert_eq!(cli.dir, "src/cli");
524        assert_eq!(cli.language, Language::Rust);
525        assert_eq!(cli.build_tool, BuildTool::Cargo);
526        assert_eq!(cli.registry, Registry::Crates);
527        assert_eq!(cli.test_threshold, Some(90.0));
528
529        let web = &c.scopes[1];
530        assert_eq!(web.name, "web");
531        assert_eq!(web.language, Language::TypeScript);
532        assert_eq!(web.build_tool, BuildTool::Npm);
533    }
534
535    // ── 最小契约(全默认值) ──────────────────────────────────────
536
537    #[test]
538    fn test_empty_contract() {
539        let yaml = r#"
540stages:
541scopes:
542"#;
543        let c: Contract = parse_yaml(yaml);
544        assert_eq!(c.stages.build.command, None);
545        assert_eq!(c.stages.test.threshold, 70.0);
546        assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
547        assert_eq!(c.platform.source_control, SourceControl::Github);
548        assert_eq!(c.sources.version.source_type, SourceType::Auto);
549        assert!(c.scopes.is_empty());
550    }
551
552    #[test]
553    fn test_fully_empty_yaml() {
554        let c: Contract = serde_yaml::from_str("").unwrap_or_default();
555        assert_eq!(c.stages.test.threshold, 70.0);
556        assert!(c.scopes.is_empty());
557    }
558
559    // ── Language 解析 ─────────────────────────────────────────────
560
561    #[test]
562    fn test_language_parse() {
563        let c: Contract = parse_yaml(
564            r#"
565scopes:
566  a:
567    dir: .
568    language: rust
569  b:
570    dir: .
571    language: typescript
572  c:
573    dir: .
574    language: ts
575  d:
576    dir: .
577    language: node
578  e:
579    dir: .
580    language: unknown_lang
581"#,
582        );
583        assert_eq!(c.scopes[0].language, Language::Rust);
584        assert_eq!(c.scopes[1].language, Language::TypeScript);
585        assert_eq!(c.scopes[2].language, Language::TypeScript);
586        assert_eq!(c.scopes[3].language, Language::TypeScript);
587        assert_eq!(
588            c.scopes[4].language,
589            Language::Unknown("unknown_lang".into())
590        );
591    }
592
593    // ── Registry 解析 ─────────────────────────────────────────────
594
595    #[test]
596    fn test_registry_parse() {
597        let c: Contract = parse_yaml(
598            r#"
599platform:
600  artifact_registry: pypi
601scopes:
602  s:
603    dir: .
604    registry: github_releases
605"#,
606        );
607        assert_eq!(c.platform.artifact_registry, Registry::PyPI);
608        assert_eq!(c.scopes[0].registry, Registry::GitHubReleases);
609    }
610
611    // ── SourceType 解析 ───────────────────────────────────────────
612
613    #[test]
614    fn test_source_type() {
615        let c: Contract = parse_yaml(
616            r#"
617sources:
618  version:
619    type: package.json
620"#,
621        );
622        assert_eq!(c.sources.version.source_type, SourceType::PackageJson);
623    }
624
625    // ── 便捷访问器 ────────────────────────────────────────────────
626
627    #[test]
628    fn test_scope_release_fallback() {
629        let c: Contract = parse_yaml(
630            r#"
631stages:
632  release:
633    changelog: CHANGELOG.md
634    pre_publish:
635      - cargo publish
636scopes:
637  cli:
638    dir: src/cli
639    language: rust
640"#,
641        );
642        let cli = &c.scopes[0];
643        let rel = c.scope_release(cli);
644        assert_eq!(rel.pre_publish, vec!["cargo publish".to_string()]);
645    }
646
647    #[test]
648    fn test_scope_release_override() {
649        let c: Contract = parse_yaml(
650            r#"
651stages:
652  release:
653    changelog: CHANGELOG.md
654scopes:
655  cli:
656    dir: src/cli
657    language: rust
658    release:
659      changelog: docs/CHANGELOG.md
660"#,
661        );
662        let cli = &c.scopes[0];
663        let rel = c.scope_release(cli);
664        assert_eq!(rel.changelog, "docs/CHANGELOG.md");
665    }
666
667    #[test]
668    fn test_scope_test_threshold() {
669        let c: Contract = parse_yaml(
670            r#"
671stages:
672  test:
673    threshold: 70.0
674scopes:
675  a:
676    dir: .
677  b:
678    dir: .
679    test_threshold: 90.0
680"#,
681        );
682        assert_eq!(c.scope_test_threshold(&c.scopes[0]), 70.0);
683        assert_eq!(c.scope_test_threshold(&c.scopes[1]), 90.0);
684    }
685
686    // ── find_scope_by_path ────────────────────────────────────────
687
688    #[test]
689    fn test_find_scope_by_path() {
690        let c: Contract = parse_yaml(
691            r#"
692scopes:
693  root:
694    dir: .
695  cli:
696    dir: src/cli
697  web:
698    dir: src/web
699"#,
700        );
701        assert_eq!(
702            c.find_scope_by_path(std::path::Path::new("src/cli/sub"))
703                .map(|s| s.name.as_str()),
704            Some("cli")
705        );
706        assert_eq!(
707            c.find_scope_by_path(std::path::Path::new("src/web"))
708                .map(|s| s.name.as_str()),
709            Some("web")
710        );
711        assert_eq!(
712            c.find_scope_by_path(std::path::Path::new("unknown"))
713                .map(|s| s.name.as_str()),
714            Some("root")
715        );
716    }
717
718    // ── resolve_language ──────────────────────────────────────────
719
720    #[test]
721    fn test_resolve_language_declared() {
722        let c: Contract = parse_yaml(
723            r#"
724scopes:
725  cli:
726    dir: .
727    language: rust
728"#,
729        );
730        let lang = c.resolve_language(&c.scopes[0], std::path::Path::new("/tmp"));
731        assert_eq!(lang, Language::Rust);
732    }
733
734    #[test]
735    fn test_resolve_language_auto() {
736        let d = tempfile::tempdir().unwrap();
737        std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
738        let c: Contract = parse_yaml(
739            r#"
740scopes:
741  cli:
742    dir: .
743"#,
744        );
745        let lang = c.resolve_language(&c.scopes[0], d.path());
746        assert_eq!(lang, Language::Rust);
747    }
748
749    // ── detect_language_by_files ──────────────────────────────────
750
751    #[test]
752    fn test_detect_by_files() {
753        let d = tempfile::tempdir().unwrap();
754        assert_eq!(
755            detect_language_by_files(d.path()),
756            Language::Unknown("无法识别".into())
757        );
758        std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
759        assert_eq!(detect_language_by_files(d.path()), Language::Rust);
760        std::fs::write(d.path().join("go.mod"), "").unwrap();
761        // Cargo.toml 优先(顺序检测)
762        assert_eq!(detect_language_by_files(d.path()), Language::Rust);
763    }
764}