Skip to main content

qtcloud_devops_cli/
contract.rs

1/// 契约模块。按照四维架构(Stages / Platforms / Sources / Scopes)设计。
2///
3/// 参考:docs/essay/contract/index.md — 契约化 DevOps 建模
4use std::path::Path;
5
6// ═══════════════════════════════════════════════════════════════════════
7// 四维架构模型
8// ═══════════════════════════════════════════════════════════════════════
9
10/// 完整契约。
11#[derive(Debug)]
12pub struct Contract {
13    /// 生命周期阶段配置。
14    pub stages: Stages,
15    /// 外部治理载体配置。
16    pub platforms: Platforms,
17    /// 事实源配置。
18    pub sources: Sources,
19    /// 作用域列表。
20    pub scopes: Vec<Scope>,
21}
22
23/// Stages(时序维度):定义价值流的节拍。
24///
25/// 不规定"怎么做",只规定"什么时候检查什么"。
26#[derive(Debug, Clone)]
27pub struct Stages {
28    /// 构建阶段配置。
29    pub build: StageBuild,
30    /// 测试阶段配置。
31    pub test: StageTest,
32    /// 发布阶段配置。
33    pub release: StageRelease,
34}
35
36impl Default for Stages {
37    fn default() -> Self {
38        Self {
39            build: StageBuild { command: None },
40            test: StageTest {
41                command: None,
42                threshold: 70.0,
43            },
44            release: StageRelease {
45                changelog: "CHANGELOG.md".into(),
46                pre_publish: Vec::new(),
47            },
48        }
49    }
50}
51
52#[derive(Debug, Clone)]
53pub struct StageBuild {
54    pub command: Option<String>,
55}
56
57#[derive(Debug, Clone)]
58pub struct StageTest {
59    pub command: Option<String>,
60    pub threshold: f64,
61}
62
63#[derive(Debug, Clone)]
64pub struct StageRelease {
65    pub changelog: String,
66    pub pre_publish: Vec<String>,
67}
68
69/// Platforms(载体维度):定义能力的空间。
70///
71/// 指 GitHub、Kubernetes、Artifactory 等外部治理载体。负责"外部合规"。
72///
73/// 当前默认值锁定为 `github + github_actions`。
74/// 如需支持 GitLab、自建 CI 等,将默认值改为可配置即可。
75#[derive(Debug, Clone)]
76pub struct Platforms {
77    /// 源代码管理平台。
78    pub source_control: String,
79    /// CI/CD 平台。
80    pub ci: String,
81    /// 制品库。
82    pub artifact_registry: Registry,
83}
84
85impl Default for Platforms {
86    fn default() -> Self {
87        Self {
88            source_control: "github".into(),
89            ci: "github_actions".into(),
90            artifact_registry: Registry::None,
91        }
92    }
93}
94
95/// Sources(事实源维度):定义真相的本质。
96///
97/// 指 Git(代码源)、配置文件(版本源)等核心内容引擎。负责"内在完整"。
98#[derive(Debug, Clone)]
99pub struct Sources {
100    /// 版本号来源。
101    pub version: VersionSource,
102}
103
104impl Default for Sources {
105    fn default() -> Self {
106        Self {
107            version: VersionSource {
108                source_type: SourceType::Auto,
109                path: None,
110            },
111        }
112    }
113}
114
115#[derive(Debug, Clone)]
116pub struct VersionSource {
117    pub source_type: SourceType,
118    pub path: Option<String>,
119}
120
121#[derive(Debug, Clone, PartialEq)]
122pub enum SourceType {
123    /// cargo(Cargo.toml)
124    Cargo,
125    /// pyproject.toml(PEP 621 / Poetry / PDM)
126    Pyproject,
127    /// 不从配置文件读版本,只从 git tag 读
128    TagOnly,
129    /// pubspec.yaml(Dart/Flutter)
130    Pubspec,
131    /// Node/TypeScript(package.json)
132    PackageJson,
133    /// 自动检测
134    Auto,
135}
136
137/// Scopes(上下文维度):定义规则的边界。
138///
139/// 通过 scope 为不同组件挂载不同的 Stages、Platforms、Sources 组合。
140#[derive(Debug, Clone)]
141pub struct Scope {
142    pub name: String,
143    pub dir: String,
144    /// 语言与框架信息(属于 Sources 维度,但按 scope 声明)。
145    pub language: Language,
146    pub framework: String,
147    pub build_tool: BuildTool,
148    /// 该 scope 的制品库(覆盖全局 Platforms)。
149    pub registry: Registry,
150    /// 该 scope 的发布配置(覆盖全局 Stages.release)。
151    pub release: StageRelease,
152    /// 该 scope 的测试阈值(覆盖全局 Stages.test.threshold)。
153    pub test_threshold: Option<f64>,
154    /// CI workflow 名称。未设置时按 build-{scope} 约定推导。
155    pub ci_workflow: Option<String>,
156}
157
158// ── 辅枚举 ────────────────────────────────────────────────────────────
159
160#[derive(Debug, Clone, PartialEq)]
161pub enum Language {
162    Rust,
163    Python,
164    Go,
165    Dart,
166    TypeScript,
167    Unknown(String),
168}
169
170impl Language {
171    pub fn is_supported(&self) -> bool {
172        !matches!(self, Language::Unknown(_))
173    }
174
175    pub fn name(&self) -> &str {
176        match self {
177            Language::Rust => "Rust",
178            Language::Python => "Python",
179            Language::Go => "Go",
180            Language::Dart => "Dart",
181            Language::TypeScript => "TypeScript",
182            Language::Unknown(s) => s,
183        }
184    }
185}
186
187#[derive(Debug, Clone, PartialEq)]
188pub enum BuildTool {
189    Cargo,
190    Uv,
191    Go,
192    Flutter,
193    Npm,
194    Unknown(String),
195}
196
197impl BuildTool {
198    pub fn is_supported(&self) -> bool {
199        !matches!(self, BuildTool::Unknown(_))
200    }
201
202    pub fn name(&self) -> &str {
203        match self {
204            BuildTool::Cargo => "cargo",
205            BuildTool::Uv => "uv",
206            BuildTool::Go => "go build",
207            BuildTool::Flutter => "flutter build",
208            BuildTool::Npm => "npm",
209            BuildTool::Unknown(s) => s,
210        }
211    }
212}
213
214#[derive(Debug, Clone, PartialEq)]
215pub enum Registry {
216    Crates,
217    PyPI,
218    PubDev,
219    Npm,
220    GitHubReleases,
221    Docker,
222    None,
223}
224
225impl Registry {
226    pub fn name(&self) -> &str {
227        match self {
228            Registry::Crates => "crates.io",
229            Registry::PyPI => "PyPI",
230            Registry::PubDev => "pub.dev",
231            Registry::Npm => "npm",
232            Registry::GitHubReleases => "GitHub Releases",
233            Registry::Docker => "Docker",
234            Registry::None => "无",
235        }
236    }
237}
238
239/// 版本一致性状态。
240#[derive(Debug)]
241pub struct VersionStatus {
242    pub tag_version: Option<String>,
243    pub config_version: Option<String>,
244    pub consistent: bool,
245    /// 所有配置文件的版本号明细。(文件名, 版本号)
246    pub config_files: Vec<(String, Option<String>)>,
247}
248
249// ═══════════════════════════════════════════════════════════════════════
250// 加载与解析
251// ═══════════════════════════════════════════════════════════════════════
252
253/// 从 `.quanttide/devops/contract.yaml` 加载完整契约。
254pub fn load(repo_path: &Path) -> Contract {
255    let path = repo_path.join(".quanttide/devops/contract.yaml");
256    let content = match std::fs::read_to_string(&path) {
257        Ok(c) => c,
258        Err(_) => {
259            eprintln!("  ℹ contract.yaml 不存在,使用默认契约");
260            return default_contract();
261        }
262    };
263    parse(&content)
264}
265
266fn parse(content: &str) -> Contract {
267    // 尝试新格式(四维架构)
268    if let Ok(parsed) = serde_yaml::from_str::<ContractYaml>(content) {
269        return parsed.into_contract();
270    }
271    if serde_yaml::from_str::<serde_yaml::Value>(content).is_ok() {
272        eprintln!("⚠ contract.yaml: 无法按新格式解析,使用默认值");
273    }
274    default_contract()
275}
276
277fn default_contract() -> Contract {
278    Contract {
279        stages: Stages::default(),
280        platforms: Platforms::default(),
281        sources: Sources::default(),
282        scopes: Vec::new(),
283    }
284}
285
286// ═══════════════════════════════════════════════════════════════════════
287// 便捷访问
288// ═══════════════════════════════════════════════════════════════════════
289
290/// 获取 scope 的发布配置(scope 级覆盖 → 全局默认)。
291pub fn scope_release<'a>(contract: &'a Contract, scope: &'a Scope) -> &'a StageRelease {
292    let has_custom =
293        !scope.release.pre_publish.is_empty() || scope.release.changelog != "CHANGELOG.md";
294    if has_custom {
295        &scope.release
296    } else {
297        &contract.stages.release
298    }
299}
300
301/// 获取 scope 的测试阈值。
302pub fn scope_test_threshold(contract: &Contract, scope: &Scope) -> f64 {
303    scope
304        .test_threshold
305        .unwrap_or(contract.stages.test.threshold)
306}
307
308// ═══════════════════════════════════════════════════════════════════════
309// 语言检测
310// ═══════════════════════════════════════════════════════════════════════
311
312pub fn resolve_language(scope: &Scope, scope_dir: &Path) -> Language {
313    match &scope.language {
314        Language::Unknown(_) => detect_by_files(scope_dir),
315        lang => lang.clone(),
316    }
317}
318
319pub fn detect_by_files(dir: &Path) -> Language {
320    if dir.join("Cargo.toml").exists() {
321        Language::Rust
322    } else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
323        Language::Python
324    } else if dir.join("go.mod").exists() {
325        Language::Go
326    } else if dir.join("pubspec.yaml").exists() {
327        Language::Dart
328    } else if dir.join("package.json").exists() {
329        Language::TypeScript
330    } else {
331        Language::Unknown("无法识别".into())
332    }
333}
334
335// ═══════════════════════════════════════════════════════════════════════
336// 版本状态
337// ═══════════════════════════════════════════════════════════════════════
338
339/// 检查 scope 下所有已知配置文件的版本,判断与 tag 是否一致。
340pub fn version_status(repo_path: &Path, scope: &Scope) -> VersionStatus {
341    let tag_version = latest_tag_for_scope(repo_path, &scope.name);
342    let scope_dir = repo_path.join(&scope.dir);
343    let config_files = read_all_config_versions(&scope_dir);
344    let config_version = config_files
345        .iter()
346        .find(|(_, v)| v.is_some())
347        .and_then(|(_, v)| v.clone());
348    let consistent = match &tag_version {
349        Some(t) => config_files.iter().all(|(_, v)| match v {
350            Some(cv) => cv == t,
351            None => true, // 文件存在但无版本字段,不阻断
352        }),
353        None => config_version.is_none(),
354    };
355    VersionStatus {
356        tag_version,
357        config_version,
358        consistent,
359        config_files,
360    }
361}
362
363/// 读取 scope 目录下所有已知配置文件的版本号。
364pub fn read_all_config_versions(dir: &Path) -> Vec<(String, Option<String>)> {
365    let checks: &[(&str, fn(&str) -> Option<String>)] = &[
366        ("Cargo.toml", |c| extract_kv_version(c, "version")),
367        ("pyproject.toml", |c| extract_kv_version(c, "version")),
368        ("package.json", extract_json_version),
369        ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
370    ];
371    checks
372        .iter()
373        .filter_map(|(name, extract)| {
374            let path = dir.join(name);
375            if path.exists() {
376                let content = std::fs::read_to_string(&path).ok()?;
377                Some((name.to_string(), extract(&content)))
378            } else {
379                None
380            }
381        })
382        .collect()
383}
384
385fn extract_kv_version(content: &str, key: &str) -> Option<String> {
386    let p = format!("{} = \"", key);
387    for line in content.lines() {
388        let t = line.trim();
389        if let Some(r) = t.strip_prefix(&p) {
390            if let Some(e) = r.find('"') {
391                let v = r[..e].to_string();
392                if !v.is_empty() {
393                    return Some(v);
394                }
395            }
396        }
397    }
398    None
399}
400
401fn extract_json_version(content: &str) -> Option<String> {
402    for line in content.lines() {
403        let t = line.trim();
404        if let Some(r) = t.strip_prefix("\"version\":") {
405            let v = r.trim().trim_matches('"').trim_matches(',').trim();
406            if !v.is_empty() {
407                return Some(v.to_string());
408            }
409        }
410    }
411    None
412}
413
414fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
415    let p = format!("{}:", key);
416    for line in content.lines() {
417        let t = line.trim();
418        if let Some(r) = t.strip_prefix(&p) {
419            let v = r.trim();
420            if !v.is_empty() && !v.starts_with('#') {
421                return Some(v.to_string());
422            }
423        }
424    }
425    None
426}
427
428fn latest_tag_for_scope(repo_path: &Path, scope_name: &str) -> Option<String> {
429    let output = std::process::Command::new("git")
430        .args(["tag", "--sort=-version:refname"])
431        .current_dir(repo_path)
432        .output()
433        .ok()?;
434    if !output.status.success() {
435        return None;
436    }
437    let prefix = format!("{}/", scope_name);
438    let tags: Vec<&str> = std::str::from_utf8(&output.stdout)
439        .ok()?
440        .lines()
441        .filter(|t| t.starts_with(&prefix) || !t.contains('/'))
442        .collect();
443    let scoped = tags.iter().find(|t| t.starts_with(&prefix));
444    match scoped {
445        Some(t) => Some(normalize_version(t)),
446        None => tags.first().map(|t| normalize_version(t)),
447    }
448}
449
450fn normalize_version(version: &str) -> String {
451    let after_scope = version.split('/').last().unwrap_or(version);
452    after_scope
453        .strip_prefix('v')
454        .unwrap_or(after_scope)
455        .to_string()
456}
457
458// ═══════════════════════════════════════════════════════════════════════
459// YAML 数据结构(私有)
460// ═══════════════════════════════════════════════════════════════════════
461
462#[derive(Debug, serde::Deserialize)]
463struct ContractYaml {
464    #[serde(default)]
465    stages: Option<StagesYaml>,
466    #[serde(default)]
467    platforms: Option<PlatformsYaml>,
468    #[serde(default)]
469    sources: Option<SourcesYaml>,
470    #[serde(default)]
471    scopes: Option<std::collections::BTreeMap<String, ScopeYaml>>,
472}
473
474#[derive(Debug, serde::Deserialize)]
475struct StagesYaml {
476    #[serde(default)]
477    build: Option<BuildYaml>,
478    #[serde(default)]
479    test: Option<TestYaml>,
480    #[serde(default)]
481    release: Option<ReleaseYaml>,
482}
483
484#[derive(Debug, serde::Deserialize)]
485struct BuildYaml {
486    command: Option<String>,
487}
488
489#[derive(Debug, serde::Deserialize)]
490struct TestYaml {
491    command: Option<String>,
492    #[serde(default)]
493    threshold: Option<f64>,
494}
495
496#[derive(Debug, serde::Deserialize)]
497struct ReleaseYaml {
498    #[serde(default)]
499    changelog: Option<String>,
500    #[serde(default)]
501    pre_publish: Option<Vec<String>>,
502}
503
504#[derive(Debug, serde::Deserialize)]
505struct PlatformsYaml {
506    #[serde(default)]
507    source_control: Option<String>,
508    #[serde(default)]
509    ci: Option<String>,
510    #[serde(default)]
511    artifact_registry: Option<String>,
512}
513
514#[derive(Debug, serde::Deserialize)]
515struct SourcesYaml {
516    #[serde(default)]
517    version: Option<VersionSourceYaml>,
518}
519
520#[derive(Debug, serde::Deserialize)]
521struct VersionSourceYaml {
522    #[serde(rename = "type")]
523    source_type: Option<String>,
524    path: Option<String>,
525}
526
527#[derive(Debug, serde::Deserialize)]
528struct ScopeYaml {
529    dir: String,
530    #[serde(default)]
531    language: Option<String>,
532    #[serde(default)]
533    framework: Option<String>,
534    #[serde(default)]
535    build_tool: Option<String>,
536    #[serde(default)]
537    registry: Option<String>,
538    #[serde(default)]
539    release: Option<ReleaseYaml>,
540    #[serde(default)]
541    test_threshold: Option<f64>,
542    #[serde(default)]
543    ci_workflow: Option<String>,
544}
545
546impl ContractYaml {
547    fn into_contract(self) -> Contract {
548        let stages = self
549            .stages
550            .map(|s| Stages {
551                build: StageBuild {
552                    command: s.build.and_then(|b| b.command),
553                },
554                test: StageTest {
555                    command: s.test.as_ref().and_then(|t| t.command.clone()),
556                    threshold: s.test.as_ref().and_then(|t| t.threshold).unwrap_or(70.0),
557                },
558                release: s
559                    .release
560                    .map(|r| StageRelease {
561                        changelog: r.changelog.unwrap_or_else(|| "CHANGELOG.md".into()),
562                        pre_publish: r.pre_publish.unwrap_or_default(),
563                    })
564                    .unwrap_or_default(),
565            })
566            .unwrap_or_default();
567
568        let platforms = self
569            .platforms
570            .map(|p| Platforms {
571                source_control: p.source_control.unwrap_or_else(|| "github".into()),
572                ci: p.ci.unwrap_or_else(|| "github_actions".into()),
573                artifact_registry: parse_registry(p.artifact_registry.as_deref()),
574            })
575            .unwrap_or_default();
576
577        let sources = self
578            .sources
579            .map(|s| Sources {
580                version: s
581                    .version
582                    .map(|v| VersionSource {
583                        source_type: parse_source_type(v.source_type.as_deref()),
584                        path: v.path,
585                    })
586                    .unwrap_or_default(),
587            })
588            .unwrap_or_default();
589
590        let scopes = self
591            .scopes
592            .unwrap_or_default()
593            .into_iter()
594            .map(|(name, cfg)| {
595                let lang = match cfg.language.as_deref() {
596                    Some("rust") => Language::Rust,
597                    Some("python") => Language::Python,
598                    Some("go") => Language::Go,
599                    Some("dart") => Language::Dart,
600                    Some("typescript") | Some("ts") | Some("node") => Language::TypeScript,
601                    Some(other) => Language::Unknown(other.into()),
602                    None => Language::Unknown("auto".into()),
603                };
604                let build_tool = match cfg.build_tool.as_deref() {
605                    Some("cargo") => BuildTool::Cargo,
606                    Some("uv") => BuildTool::Uv,
607                    Some("go") => BuildTool::Go,
608                    Some("flutter") => BuildTool::Flutter,
609                    Some("npm") => BuildTool::Npm,
610                    Some(other) => BuildTool::Unknown(other.into()),
611                    None => BuildTool::Unknown("auto".into()),
612                };
613                let release = cfg
614                    .release
615                    .map(|r| StageRelease {
616                        changelog: r.changelog.unwrap_or_else(|| "CHANGELOG.md".into()),
617                        pre_publish: r.pre_publish.unwrap_or_default(),
618                    })
619                    .unwrap_or_default();
620                Scope {
621                    name,
622                    dir: cfg.dir,
623                    language: lang,
624                    framework: cfg.framework.unwrap_or_default(),
625                    build_tool,
626                    registry: parse_registry(cfg.registry.as_deref()),
627                    release,
628                    test_threshold: cfg.test_threshold,
629                    ci_workflow: cfg.ci_workflow.clone(),
630                }
631            })
632            .collect();
633
634        Contract {
635            stages,
636            platforms,
637            sources,
638            scopes,
639        }
640    }
641}
642
643fn parse_registry(s: Option<&str>) -> Registry {
644    match s {
645        Some("crates") => Registry::Crates,
646        Some("pypi") => Registry::PyPI,
647        Some("pubdev") => Registry::PubDev,
648        Some("npm") => Registry::Npm,
649        Some("github") | Some("github_releases") => Registry::GitHubReleases,
650        Some("docker") => Registry::Docker,
651        _ => Registry::None,
652    }
653}
654
655fn parse_source_type(s: Option<&str>) -> SourceType {
656    match s {
657        Some("cargo") => SourceType::Cargo,
658        Some("pyproject") => SourceType::Pyproject,
659        Some("tag") => SourceType::TagOnly,
660        Some("pubspec") => SourceType::Pubspec,
661        Some("package.json") | Some("node") | Some("typescript") => SourceType::PackageJson,
662        _ => SourceType::Auto,
663    }
664}
665
666impl Default for StageRelease {
667    fn default() -> Self {
668        Self {
669            changelog: "CHANGELOG.md".into(),
670            pre_publish: Vec::new(),
671        }
672    }
673}
674
675impl Default for VersionSource {
676    fn default() -> Self {
677        Self {
678            source_type: SourceType::Auto,
679            path: None,
680        }
681    }
682}
683
684// ═══════════════════════════════════════════════════════════════════════
685// 向下兼容 API
686// ═══════════════════════════════════════════════════════════════════════
687
688/// 快速加载 scope 列表(简化版,兼容旧调用方)。
689pub fn load_scopes(repo_path: &Path) -> Vec<Scope> {
690    load(repo_path).scopes
691}
692
693/// 快速检测语言(简化版,兼容旧调用方)。
694pub fn detect_language(dir: &Path) -> Language {
695    detect_by_files(dir)
696}
697
698/// 根据当前工作目录查找匹配的 scope。
699///
700/// 按 dir 路径前缀最长匹配。例如当前在 `src/cli/sub` 时,
701/// `cli` scope(dir: `src/cli`)比 root scope(dir: `.`)优先级高。
702pub fn find_scope_by_path<'a>(scopes: &'a [Scope], current_dir: &Path) -> Option<&'a Scope> {
703    let current_str = current_dir.to_string_lossy();
704    scopes
705        .iter()
706        .filter(|s| current_str.starts_with(&s.dir) || s.dir == ".")
707        .max_by_key(|s| s.dir.len())
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    // ── 新格式:四维架构 ──────────────────────────────────────────────
715
716    #[test]
717    fn test_load_new_format_full() {
718        let d = tempfile::tempdir().unwrap();
719        let dir = d.path().join(".quanttide/devops");
720        std::fs::create_dir_all(&dir).unwrap();
721        std::fs::write(
722            dir.join("contract.yaml"),
723            r#"
724stages:
725  build:
726    command: cargo build --release
727  test:
728    command: cargo test
729    threshold: 80
730  release:
731    changelog: CHANGELOG.md
732    pre_publish:
733      - scripts/preflight.sh
734
735platforms:
736  source_control: github
737  ci: github_actions
738  artifact_registry: crates
739
740sources:
741  version:
742    type: cargo
743    path: Cargo.toml
744
745scopes:
746  cli:
747    dir: src/cli
748    language: rust
749    framework: clap
750    build_tool: cargo
751    registry: crates
752  studio:
753    dir: src/studio
754    language: dart
755    framework: flutter
756    build_tool: flutter
757    registry: pubdev
758    release:
759      changelog: src/studio/CHANGELOG.md
760"#,
761        )
762        .unwrap();
763
764        let c = load(d.path());
765
766        // Stages
767        assert_eq!(
768            c.stages.build.command.as_deref(),
769            Some("cargo build --release")
770        );
771        assert_eq!(c.stages.test.threshold, 80.0);
772        assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
773        assert_eq!(c.stages.release.pre_publish.len(), 1);
774
775        // Platforms
776        assert_eq!(c.platforms.source_control, "github");
777        assert_eq!(c.platforms.artifact_registry, Registry::Crates);
778
779        // Sources
780        assert_eq!(c.sources.version.source_type, SourceType::Cargo);
781
782        // Scopes
783        assert_eq!(c.scopes.len(), 2);
784        assert_eq!(c.scopes[0].name, "cli");
785        assert_eq!(c.scopes[0].language, Language::Rust);
786        assert_eq!(c.scopes[0].registry, Registry::Crates);
787        assert_eq!(c.scopes[1].name, "studio");
788        assert_eq!(c.scopes[1].language, Language::Dart);
789        assert_eq!(c.scopes[1].release.changelog, "src/studio/CHANGELOG.md");
790    }
791
792    #[test]
793    fn test_load_new_format_minimal() {
794        let d = tempfile::tempdir().unwrap();
795        let dir = d.path().join(".quanttide/devops");
796        std::fs::create_dir_all(&dir).unwrap();
797        std::fs::write(
798            dir.join("contract.yaml"),
799            "scopes:\n  cli:\n    dir: src/cli\n",
800        )
801        .unwrap();
802
803        let c = load(d.path());
804        assert_eq!(c.scopes.len(), 1);
805        assert_eq!(c.scopes[0].name, "cli");
806        // 未声明的用默认值
807        assert_eq!(c.stages.test.threshold, 70.0);
808        assert_eq!(c.platforms.source_control, "github");
809    }
810
811    #[test]
812    fn test_load_no_file() {
813        let d = tempfile::tempdir().unwrap();
814        let c = load(d.path());
815        assert!(c.scopes.is_empty());
816    }
817
818    // ── 便捷函数 ────────────────────────────────────────────────────
819
820    #[test]
821    fn test_resolve_language_declared() {
822        let s = Scope {
823            name: "cli".into(),
824            dir: ".".into(),
825            language: Language::Rust,
826            framework: String::new(),
827            build_tool: BuildTool::Cargo,
828            registry: Registry::Crates,
829            release: StageRelease::default(),
830            test_threshold: None,
831            ci_workflow: None,
832        };
833        assert_eq!(resolve_language(&s, Path::new("/tmp")), Language::Rust);
834    }
835
836    #[test]
837    fn test_scope_test_threshold_custom() {
838        let mut c = default_contract();
839        c.stages.test.threshold = 70.0;
840        let s = Scope {
841            name: "cli".into(),
842            dir: ".".into(),
843            language: Language::Rust,
844            framework: String::new(),
845            build_tool: BuildTool::Cargo,
846            registry: Registry::Crates,
847            release: StageRelease::default(),
848            test_threshold: Some(90.0),
849            ci_workflow: None,
850        };
851        assert_eq!(scope_test_threshold(&c, &s), 90.0);
852    }
853
854    #[test]
855    fn test_scope_test_threshold_global() {
856        let mut c = default_contract();
857        c.stages.test.threshold = 70.0;
858        let s = Scope {
859            name: "cli".into(),
860            dir: ".".into(),
861            language: Language::Rust,
862            framework: String::new(),
863            build_tool: BuildTool::Cargo,
864            registry: Registry::Crates,
865            release: StageRelease::default(),
866            test_threshold: None,
867            ci_workflow: None,
868        };
869        assert_eq!(scope_test_threshold(&c, &s), 70.0);
870    }
871
872    // ── 语言检测 ────────────────────────────────────────────────────
873
874    #[test]
875    fn test_detect_by_files_rust() {
876        let d = tempfile::tempdir().unwrap();
877        std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
878        assert_eq!(detect_by_files(d.path()), Language::Rust);
879    }
880
881    #[test]
882    fn test_detect_by_files_unknown() {
883        let d = tempfile::tempdir().unwrap();
884        assert!(matches!(detect_by_files(d.path()), Language::Unknown(_)));
885    }
886
887    // ── 版本号 ──────────────────────────────────────────────────────
888
889    #[test]
890    fn test_normalize_version_v_prefix() {
891        assert_eq!(normalize_version("v1.2.3"), "1.2.3");
892    }
893
894    #[test]
895    fn test_normalize_version_scoped() {
896        assert_eq!(normalize_version("cli/v0.1.0"), "0.1.0");
897    }
898
899    #[test]
900    fn test_read_all_config_versions_cargo_only() {
901        let d = tempfile::tempdir().unwrap();
902        std::fs::write(
903            d.path().join("Cargo.toml"),
904            "[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
905        )
906        .unwrap();
907        let files = read_all_config_versions(d.path());
908        assert_eq!(files.len(), 1);
909        assert_eq!(files[0].0, "Cargo.toml");
910        assert_eq!(files[0].1.as_deref(), Some("0.1.0"));
911    }
912
913    #[test]
914    fn test_read_all_config_versions_multi() {
915        let d = tempfile::tempdir().unwrap();
916        std::fs::write(
917            d.path().join("Cargo.toml"),
918            "[package]\nversion = \"0.2.0\"\n",
919        )
920        .unwrap();
921        std::fs::write(
922            d.path().join("pyproject.toml"),
923            "[project]\nversion = \"0.2.0\"\n",
924        )
925        .unwrap();
926        let files = read_all_config_versions(d.path());
927        assert_eq!(files.len(), 2);
928        assert!(files.iter().all(|(_, v)| v.as_deref() == Some("0.2.0")));
929    }
930
931    #[test]
932    fn test_read_all_config_versions_mismatch() {
933        let d = tempfile::tempdir().unwrap();
934        std::fs::write(
935            d.path().join("Cargo.toml"),
936            "[package]\nversion = \"0.2.0\"\n",
937        )
938        .unwrap();
939        std::fs::write(
940            d.path().join("pyproject.toml"),
941            "[project]\nversion = \"0.1.0\"\n",
942        )
943        .unwrap();
944        let files = read_all_config_versions(d.path());
945        assert_eq!(files.len(), 2);
946        assert_ne!(files[0].1, files[1].1);
947    }
948
949    // ── 边缘测试 ────────────────────────────────────────────────────
950
951    #[test]
952    fn test_unknown_language_in_yaml() {
953        let content =
954            "stages:\n  test:\n    threshold: 70\nscopes:\n  ziggy:\n    dir: src/ziggy\n    language: zig\n";
955        let c = parse(content);
956        assert_eq!(c.scopes.len(), 1);
957        assert_eq!(c.scopes[0].language, Language::Unknown("zig".into()));
958    }
959
960    #[test]
961    fn test_normalize_version_rc() {
962        assert_eq!(normalize_version("v1.0.0-rc.1"), "1.0.0-rc.1");
963    }
964
965    #[test]
966    fn test_normalize_version_strips_v_only() {
967        assert_eq!(normalize_version("v0.0.1"), "0.0.1");
968        assert_eq!(normalize_version("0.0.1"), "0.0.1");
969    }
970
971    #[test]
972    fn test_normalize_version_scoped_with_rc() {
973        assert_eq!(normalize_version("cli/v1.0.0-rc.1"), "1.0.0-rc.1");
974    }
975
976    #[test]
977    fn test_find_scope_by_path_exact_match() {
978        let scopes = vec![
979            Scope {
980                name: "root".into(),
981                dir: ".".into(),
982                language: Language::Unknown("auto".into()),
983                ..scope_default()
984            },
985            Scope {
986                name: "cli".into(),
987                dir: "src/cli".into(),
988                language: Language::Rust,
989                ..scope_default()
990            },
991        ];
992        let found = find_scope_by_path(&scopes, Path::new("src/cli"));
993        assert_eq!(found.map(|s| s.name.as_str()), Some("cli"));
994    }
995
996    #[test]
997    fn test_find_scope_by_path_subdir() {
998        let scopes = vec![
999            Scope {
1000                name: "root".into(),
1001                dir: ".".into(),
1002                language: Language::Unknown("auto".into()),
1003                ..scope_default()
1004            },
1005            Scope {
1006                name: "cli".into(),
1007                dir: "src/cli".into(),
1008                language: Language::Rust,
1009                ..scope_default()
1010            },
1011        ];
1012        let found = find_scope_by_path(&scopes, Path::new("src/cli/sub/foo"));
1013        assert_eq!(found.map(|s| s.name.as_str()), Some("cli"));
1014    }
1015
1016    #[test]
1017    fn test_find_scope_by_path_root_fallback() {
1018        let scopes = vec![
1019            Scope {
1020                name: "root".into(),
1021                dir: ".".into(),
1022                language: Language::Unknown("auto".into()),
1023                ..scope_default()
1024            },
1025            Scope {
1026                name: "cli".into(),
1027                dir: "src/cli".into(),
1028                language: Language::Rust,
1029                ..scope_default()
1030            },
1031        ];
1032        let found = find_scope_by_path(&scopes, Path::new("docs"));
1033        assert_eq!(found.map(|s| s.name.as_str()), Some("root"));
1034    }
1035
1036    #[test]
1037    fn test_find_scope_by_path_no_match() {
1038        let scopes = vec![];
1039        let found = find_scope_by_path(&scopes, Path::new("src/cli"));
1040        assert!(found.is_none());
1041    }
1042
1043    fn scope_default() -> Scope {
1044        Scope {
1045            name: String::new(),
1046            dir: ".".into(),
1047            language: Language::Unknown("auto".into()),
1048            framework: String::new(),
1049            build_tool: BuildTool::Unknown("auto".into()),
1050            registry: Registry::None,
1051            release: StageRelease::default(),
1052            test_threshold: None,
1053            ci_workflow: None,
1054        }
1055    }
1056}