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
247// ═══════════════════════════════════════════════════════════════════════
248// 加载与解析
249// ═══════════════════════════════════════════════════════════════════════
250
251/// 从 `.quanttide/devops/contract.yaml` 加载完整契约。
252pub fn load(repo_path: &Path) -> Contract {
253    let path = repo_path.join(".quanttide/devops/contract.yaml");
254    let content = match std::fs::read_to_string(&path) {
255        Ok(c) => c,
256        Err(_) => {
257            eprintln!("  ℹ contract.yaml 不存在,使用默认契约");
258            return default_contract();
259        }
260    };
261    parse(&content)
262}
263
264fn parse(content: &str) -> Contract {
265    // 尝试新格式(四维架构)
266    if let Ok(parsed) = serde_yaml::from_str::<ContractYaml>(content) {
267        return parsed.into_contract();
268    }
269    if serde_yaml::from_str::<serde_yaml::Value>(content).is_ok() {
270        eprintln!("⚠ contract.yaml: 无法按新格式解析,使用默认值");
271    }
272    default_contract()
273}
274
275fn default_contract() -> Contract {
276    Contract {
277        stages: Stages::default(),
278        platforms: Platforms::default(),
279        sources: Sources::default(),
280        scopes: Vec::new(),
281    }
282}
283
284// ═══════════════════════════════════════════════════════════════════════
285// 便捷访问
286// ═══════════════════════════════════════════════════════════════════════
287
288/// 获取 scope 的发布配置(scope 级覆盖 → 全局默认)。
289pub fn scope_release<'a>(contract: &'a Contract, scope: &'a Scope) -> &'a StageRelease {
290    let has_custom =
291        !scope.release.pre_publish.is_empty() || scope.release.changelog != "CHANGELOG.md";
292    if has_custom {
293        &scope.release
294    } else {
295        &contract.stages.release
296    }
297}
298
299/// 获取 scope 的测试阈值。
300pub fn scope_test_threshold(contract: &Contract, scope: &Scope) -> f64 {
301    scope
302        .test_threshold
303        .unwrap_or(contract.stages.test.threshold)
304}
305
306// ═══════════════════════════════════════════════════════════════════════
307// 语言检测
308// ═══════════════════════════════════════════════════════════════════════
309
310pub fn resolve_language(scope: &Scope, scope_dir: &Path) -> Language {
311    match &scope.language {
312        Language::Unknown(_) => detect_by_files(scope_dir),
313        lang => lang.clone(),
314    }
315}
316
317pub fn detect_by_files(dir: &Path) -> Language {
318    if dir.join("Cargo.toml").exists() {
319        Language::Rust
320    } else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
321        Language::Python
322    } else if dir.join("go.mod").exists() {
323        Language::Go
324    } else if dir.join("pubspec.yaml").exists() {
325        Language::Dart
326    } else if dir.join("package.json").exists() {
327        Language::TypeScript
328    } else {
329        Language::Unknown("无法识别".into())
330    }
331}
332
333// ═══════════════════════════════════════════════════════════════════════
334// 版本状态
335// ═══════════════════════════════════════════════════════════════════════
336
337pub fn version_status(repo_path: &Path, scope: &Scope) -> VersionStatus {
338    let tag_version = latest_tag_for_scope(repo_path, &scope.name);
339    let scope_dir = repo_path.join(&scope.dir);
340    let config_version = read_config_version(&scope_dir, &scope.language);
341    let consistent = match (&tag_version, &config_version) {
342        (Some(t), Some(c)) => t == c,
343        (None, None) => true,
344        _ => false,
345    };
346    VersionStatus {
347        tag_version,
348        config_version,
349        consistent,
350    }
351}
352
353fn latest_tag_for_scope(repo_path: &Path, scope_name: &str) -> Option<String> {
354    let output = std::process::Command::new("git")
355        .args(["tag", "--sort=-version:refname"])
356        .current_dir(repo_path)
357        .output()
358        .ok()?;
359    if !output.status.success() {
360        return None;
361    }
362    let prefix = format!("{}/", scope_name);
363    let tags: Vec<&str> = std::str::from_utf8(&output.stdout)
364        .ok()?
365        .lines()
366        .filter(|t| t.starts_with(&prefix) || !t.contains('/'))
367        .collect();
368    let scoped = tags.iter().find(|t| t.starts_with(&prefix));
369    match scoped {
370        Some(t) => Some(normalize_version(t)),
371        None => tags.first().map(|t| normalize_version(t)),
372    }
373}
374
375fn normalize_version(version: &str) -> String {
376    let after_scope = version.split('/').last().unwrap_or(version);
377    after_scope
378        .strip_prefix('v')
379        .unwrap_or(after_scope)
380        .to_string()
381}
382
383fn read_config_version(dir: &Path, lang: &Language) -> Option<String> {
384    let filename = match lang {
385        Language::Rust => "Cargo.toml",
386        Language::Python => "pyproject.toml",
387        Language::TypeScript => "package.json",
388        Language::Dart => "pubspec.yaml",
389        _ => return None,
390    };
391    let path = dir.join(filename);
392    let content = std::fs::read_to_string(path).ok()?;
393    for line in content.lines() {
394        let t = line.trim();
395        if t.starts_with("version = \"") {
396            if let Some(v) = t.strip_prefix("version = \"") {
397                if let Some(end) = v.find('"') {
398                    return Some(v[..end].to_string());
399                }
400            }
401        }
402        if t.starts_with("\"version\":") {
403            if let Some(rest) = t.strip_prefix("\"version\":") {
404                let v = rest.trim().trim_matches(',').trim_matches('"');
405                if !v.is_empty() {
406                    return Some(v.to_string());
407                }
408            }
409        }
410    }
411    None
412}
413
414// ═══════════════════════════════════════════════════════════════════════
415// YAML 数据结构(私有)
416// ═══════════════════════════════════════════════════════════════════════
417
418#[derive(Debug, serde::Deserialize)]
419struct ContractYaml {
420    #[serde(default)]
421    stages: Option<StagesYaml>,
422    #[serde(default)]
423    platforms: Option<PlatformsYaml>,
424    #[serde(default)]
425    sources: Option<SourcesYaml>,
426    #[serde(default)]
427    scopes: Option<std::collections::BTreeMap<String, ScopeYaml>>,
428}
429
430#[derive(Debug, serde::Deserialize)]
431struct StagesYaml {
432    #[serde(default)]
433    build: Option<BuildYaml>,
434    #[serde(default)]
435    test: Option<TestYaml>,
436    #[serde(default)]
437    release: Option<ReleaseYaml>,
438}
439
440#[derive(Debug, serde::Deserialize)]
441struct BuildYaml {
442    command: Option<String>,
443}
444
445#[derive(Debug, serde::Deserialize)]
446struct TestYaml {
447    command: Option<String>,
448    #[serde(default)]
449    threshold: Option<f64>,
450}
451
452#[derive(Debug, serde::Deserialize)]
453struct ReleaseYaml {
454    #[serde(default)]
455    changelog: Option<String>,
456    #[serde(default)]
457    pre_publish: Option<Vec<String>>,
458}
459
460#[derive(Debug, serde::Deserialize)]
461struct PlatformsYaml {
462    #[serde(default)]
463    source_control: Option<String>,
464    #[serde(default)]
465    ci: Option<String>,
466    #[serde(default)]
467    artifact_registry: Option<String>,
468}
469
470#[derive(Debug, serde::Deserialize)]
471struct SourcesYaml {
472    #[serde(default)]
473    version: Option<VersionSourceYaml>,
474}
475
476#[derive(Debug, serde::Deserialize)]
477struct VersionSourceYaml {
478    #[serde(rename = "type")]
479    source_type: Option<String>,
480    path: Option<String>,
481}
482
483#[derive(Debug, serde::Deserialize)]
484struct ScopeYaml {
485    dir: String,
486    #[serde(default)]
487    language: Option<String>,
488    #[serde(default)]
489    framework: Option<String>,
490    #[serde(default)]
491    build_tool: Option<String>,
492    #[serde(default)]
493    registry: Option<String>,
494    #[serde(default)]
495    release: Option<ReleaseYaml>,
496    #[serde(default)]
497    test_threshold: Option<f64>,
498    #[serde(default)]
499    ci_workflow: Option<String>,
500}
501
502impl ContractYaml {
503    fn into_contract(self) -> Contract {
504        let stages = self
505            .stages
506            .map(|s| Stages {
507                build: StageBuild {
508                    command: s.build.and_then(|b| b.command),
509                },
510                test: StageTest {
511                    command: s.test.as_ref().and_then(|t| t.command.clone()),
512                    threshold: s.test.as_ref().and_then(|t| t.threshold).unwrap_or(70.0),
513                },
514                release: s
515                    .release
516                    .map(|r| StageRelease {
517                        changelog: r.changelog.unwrap_or_else(|| "CHANGELOG.md".into()),
518                        pre_publish: r.pre_publish.unwrap_or_default(),
519                    })
520                    .unwrap_or_default(),
521            })
522            .unwrap_or_default();
523
524        let platforms = self
525            .platforms
526            .map(|p| Platforms {
527                source_control: p.source_control.unwrap_or_else(|| "github".into()),
528                ci: p.ci.unwrap_or_else(|| "github_actions".into()),
529                artifact_registry: parse_registry(p.artifact_registry.as_deref()),
530            })
531            .unwrap_or_default();
532
533        let sources = self
534            .sources
535            .map(|s| Sources {
536                version: s
537                    .version
538                    .map(|v| VersionSource {
539                        source_type: parse_source_type(v.source_type.as_deref()),
540                        path: v.path,
541                    })
542                    .unwrap_or_default(),
543            })
544            .unwrap_or_default();
545
546        let scopes = self
547            .scopes
548            .unwrap_or_default()
549            .into_iter()
550            .map(|(name, cfg)| {
551                let lang = match cfg.language.as_deref() {
552                    Some("rust") => Language::Rust,
553                    Some("python") => Language::Python,
554                    Some("go") => Language::Go,
555                    Some("dart") => Language::Dart,
556                    Some("typescript") | Some("ts") | Some("node") => Language::TypeScript,
557                    Some(other) => Language::Unknown(other.into()),
558                    None => Language::Unknown("auto".into()),
559                };
560                let build_tool = match cfg.build_tool.as_deref() {
561                    Some("cargo") => BuildTool::Cargo,
562                    Some("uv") => BuildTool::Uv,
563                    Some("go") => BuildTool::Go,
564                    Some("flutter") => BuildTool::Flutter,
565                    Some("npm") => BuildTool::Npm,
566                    Some(other) => BuildTool::Unknown(other.into()),
567                    None => BuildTool::Unknown("auto".into()),
568                };
569                let release = cfg
570                    .release
571                    .map(|r| StageRelease {
572                        changelog: r.changelog.unwrap_or_else(|| "CHANGELOG.md".into()),
573                        pre_publish: r.pre_publish.unwrap_or_default(),
574                    })
575                    .unwrap_or_default();
576                Scope {
577                    name,
578                    dir: cfg.dir,
579                    language: lang,
580                    framework: cfg.framework.unwrap_or_default(),
581                    build_tool,
582                    registry: parse_registry(cfg.registry.as_deref()),
583                    release,
584                    test_threshold: cfg.test_threshold,
585                    ci_workflow: cfg.ci_workflow.clone(),
586                }
587            })
588            .collect();
589
590        Contract {
591            stages,
592            platforms,
593            sources,
594            scopes,
595        }
596    }
597}
598
599fn parse_registry(s: Option<&str>) -> Registry {
600    match s {
601        Some("crates") => Registry::Crates,
602        Some("pypi") => Registry::PyPI,
603        Some("pubdev") => Registry::PubDev,
604        Some("npm") => Registry::Npm,
605        Some("github") | Some("github_releases") => Registry::GitHubReleases,
606        Some("docker") => Registry::Docker,
607        _ => Registry::None,
608    }
609}
610
611fn parse_source_type(s: Option<&str>) -> SourceType {
612    match s {
613        Some("cargo") => SourceType::Cargo,
614        Some("pyproject") => SourceType::Pyproject,
615        Some("tag") => SourceType::TagOnly,
616        Some("pubspec") => SourceType::Pubspec,
617        Some("package.json") | Some("node") | Some("typescript") => SourceType::PackageJson,
618        _ => SourceType::Auto,
619    }
620}
621
622impl Default for StageRelease {
623    fn default() -> Self {
624        Self {
625            changelog: "CHANGELOG.md".into(),
626            pre_publish: Vec::new(),
627        }
628    }
629}
630
631impl Default for VersionSource {
632    fn default() -> Self {
633        Self {
634            source_type: SourceType::Auto,
635            path: None,
636        }
637    }
638}
639
640// ═══════════════════════════════════════════════════════════════════════
641// 向下兼容 API
642// ═══════════════════════════════════════════════════════════════════════
643
644/// 快速加载 scope 列表(简化版,兼容旧调用方)。
645pub fn load_scopes(repo_path: &Path) -> Vec<Scope> {
646    load(repo_path).scopes
647}
648
649/// 快速检测语言(简化版,兼容旧调用方)。
650pub fn detect_language(dir: &Path) -> Language {
651    detect_by_files(dir)
652}
653
654/// 根据当前工作目录查找匹配的 scope。
655///
656/// 按 dir 路径前缀最长匹配。例如当前在 `src/cli/sub` 时,
657/// `cli` scope(dir: `src/cli`)比 root scope(dir: `.`)优先级高。
658pub fn find_scope_by_path<'a>(scopes: &'a [Scope], current_dir: &Path) -> Option<&'a Scope> {
659    let current_str = current_dir.to_string_lossy();
660    scopes
661        .iter()
662        .filter(|s| current_str.starts_with(&s.dir) || s.dir == ".")
663        .max_by_key(|s| s.dir.len())
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    // ── 新格式:四维架构 ──────────────────────────────────────────────
671
672    #[test]
673    fn test_load_new_format_full() {
674        let d = tempfile::tempdir().unwrap();
675        let dir = d.path().join(".quanttide/devops");
676        std::fs::create_dir_all(&dir).unwrap();
677        std::fs::write(
678            dir.join("contract.yaml"),
679            r#"
680stages:
681  build:
682    command: cargo build --release
683  test:
684    command: cargo test
685    threshold: 80
686  release:
687    changelog: CHANGELOG.md
688    pre_publish:
689      - scripts/preflight.sh
690
691platforms:
692  source_control: github
693  ci: github_actions
694  artifact_registry: crates
695
696sources:
697  version:
698    type: cargo
699    path: Cargo.toml
700
701scopes:
702  cli:
703    dir: src/cli
704    language: rust
705    framework: clap
706    build_tool: cargo
707    registry: crates
708  studio:
709    dir: src/studio
710    language: dart
711    framework: flutter
712    build_tool: flutter
713    registry: pubdev
714    release:
715      changelog: src/studio/CHANGELOG.md
716"#,
717        )
718        .unwrap();
719
720        let c = load(d.path());
721
722        // Stages
723        assert_eq!(
724            c.stages.build.command.as_deref(),
725            Some("cargo build --release")
726        );
727        assert_eq!(c.stages.test.threshold, 80.0);
728        assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
729        assert_eq!(c.stages.release.pre_publish.len(), 1);
730
731        // Platforms
732        assert_eq!(c.platforms.source_control, "github");
733        assert_eq!(c.platforms.artifact_registry, Registry::Crates);
734
735        // Sources
736        assert_eq!(c.sources.version.source_type, SourceType::Cargo);
737
738        // Scopes
739        assert_eq!(c.scopes.len(), 2);
740        assert_eq!(c.scopes[0].name, "cli");
741        assert_eq!(c.scopes[0].language, Language::Rust);
742        assert_eq!(c.scopes[0].registry, Registry::Crates);
743        assert_eq!(c.scopes[1].name, "studio");
744        assert_eq!(c.scopes[1].language, Language::Dart);
745        assert_eq!(c.scopes[1].release.changelog, "src/studio/CHANGELOG.md");
746    }
747
748    #[test]
749    fn test_load_new_format_minimal() {
750        let d = tempfile::tempdir().unwrap();
751        let dir = d.path().join(".quanttide/devops");
752        std::fs::create_dir_all(&dir).unwrap();
753        std::fs::write(
754            dir.join("contract.yaml"),
755            "scopes:\n  cli:\n    dir: src/cli\n",
756        )
757        .unwrap();
758
759        let c = load(d.path());
760        assert_eq!(c.scopes.len(), 1);
761        assert_eq!(c.scopes[0].name, "cli");
762        // 未声明的用默认值
763        assert_eq!(c.stages.test.threshold, 70.0);
764        assert_eq!(c.platforms.source_control, "github");
765    }
766
767    #[test]
768    fn test_load_no_file() {
769        let d = tempfile::tempdir().unwrap();
770        let c = load(d.path());
771        assert!(c.scopes.is_empty());
772    }
773
774    // ── 便捷函数 ────────────────────────────────────────────────────
775
776    #[test]
777    fn test_resolve_language_declared() {
778        let s = Scope {
779            name: "cli".into(),
780            dir: ".".into(),
781            language: Language::Rust,
782            framework: String::new(),
783            build_tool: BuildTool::Cargo,
784            registry: Registry::Crates,
785            release: StageRelease::default(),
786            test_threshold: None,
787            ci_workflow: None,
788        };
789        assert_eq!(resolve_language(&s, Path::new("/tmp")), Language::Rust);
790    }
791
792    #[test]
793    fn test_scope_test_threshold_custom() {
794        let mut c = default_contract();
795        c.stages.test.threshold = 70.0;
796        let s = Scope {
797            name: "cli".into(),
798            dir: ".".into(),
799            language: Language::Rust,
800            framework: String::new(),
801            build_tool: BuildTool::Cargo,
802            registry: Registry::Crates,
803            release: StageRelease::default(),
804            test_threshold: Some(90.0),
805            ci_workflow: None,
806        };
807        assert_eq!(scope_test_threshold(&c, &s), 90.0);
808    }
809
810    #[test]
811    fn test_scope_test_threshold_global() {
812        let mut c = default_contract();
813        c.stages.test.threshold = 70.0;
814        let s = Scope {
815            name: "cli".into(),
816            dir: ".".into(),
817            language: Language::Rust,
818            framework: String::new(),
819            build_tool: BuildTool::Cargo,
820            registry: Registry::Crates,
821            release: StageRelease::default(),
822            test_threshold: None,
823            ci_workflow: None,
824        };
825        assert_eq!(scope_test_threshold(&c, &s), 70.0);
826    }
827
828    // ── 语言检测 ────────────────────────────────────────────────────
829
830    #[test]
831    fn test_detect_by_files_rust() {
832        let d = tempfile::tempdir().unwrap();
833        std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
834        assert_eq!(detect_by_files(d.path()), Language::Rust);
835    }
836
837    #[test]
838    fn test_detect_by_files_unknown() {
839        let d = tempfile::tempdir().unwrap();
840        assert!(matches!(detect_by_files(d.path()), Language::Unknown(_)));
841    }
842
843    // ── 版本号 ──────────────────────────────────────────────────────
844
845    #[test]
846    fn test_normalize_version_v_prefix() {
847        assert_eq!(normalize_version("v1.2.3"), "1.2.3");
848    }
849
850    #[test]
851    fn test_normalize_version_scoped() {
852        assert_eq!(normalize_version("cli/v0.1.0"), "0.1.0");
853    }
854
855    #[test]
856    fn test_read_config_version_cargo() {
857        let d = tempfile::tempdir().unwrap();
858        std::fs::write(
859            d.path().join("Cargo.toml"),
860            "[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
861        )
862        .unwrap();
863        let v = read_config_version(d.path(), &Language::Rust);
864        assert_eq!(v.as_deref(), Some("0.1.0"));
865    }
866
867    // ── 边缘测试 ────────────────────────────────────────────────────
868
869    #[test]
870    fn test_unknown_language_in_yaml() {
871        let content =
872            "stages:\n  test:\n    threshold: 70\nscopes:\n  ziggy:\n    dir: src/ziggy\n    language: zig\n";
873        let c = parse(content);
874        assert_eq!(c.scopes.len(), 1);
875        assert_eq!(c.scopes[0].language, Language::Unknown("zig".into()));
876    }
877
878    #[test]
879    fn test_normalize_version_rc() {
880        assert_eq!(normalize_version("v1.0.0-rc.1"), "1.0.0-rc.1");
881    }
882
883    #[test]
884    fn test_normalize_version_strips_v_only() {
885        assert_eq!(normalize_version("v0.0.1"), "0.0.1");
886        assert_eq!(normalize_version("0.0.1"), "0.0.1");
887    }
888
889    #[test]
890    fn test_normalize_version_scoped_with_rc() {
891        assert_eq!(normalize_version("cli/v1.0.0-rc.1"), "1.0.0-rc.1");
892    }
893
894    #[test]
895    fn test_find_scope_by_path_exact_match() {
896        let scopes = vec![
897            Scope {
898                name: "root".into(),
899                dir: ".".into(),
900                language: Language::Unknown("auto".into()),
901                ..scope_default()
902            },
903            Scope {
904                name: "cli".into(),
905                dir: "src/cli".into(),
906                language: Language::Rust,
907                ..scope_default()
908            },
909        ];
910        let found = find_scope_by_path(&scopes, Path::new("src/cli"));
911        assert_eq!(found.map(|s| s.name.as_str()), Some("cli"));
912    }
913
914    #[test]
915    fn test_find_scope_by_path_subdir() {
916        let scopes = vec![
917            Scope {
918                name: "root".into(),
919                dir: ".".into(),
920                language: Language::Unknown("auto".into()),
921                ..scope_default()
922            },
923            Scope {
924                name: "cli".into(),
925                dir: "src/cli".into(),
926                language: Language::Rust,
927                ..scope_default()
928            },
929        ];
930        let found = find_scope_by_path(&scopes, Path::new("src/cli/sub/foo"));
931        assert_eq!(found.map(|s| s.name.as_str()), Some("cli"));
932    }
933
934    #[test]
935    fn test_find_scope_by_path_root_fallback() {
936        let scopes = vec![
937            Scope {
938                name: "root".into(),
939                dir: ".".into(),
940                language: Language::Unknown("auto".into()),
941                ..scope_default()
942            },
943            Scope {
944                name: "cli".into(),
945                dir: "src/cli".into(),
946                language: Language::Rust,
947                ..scope_default()
948            },
949        ];
950        let found = find_scope_by_path(&scopes, Path::new("docs"));
951        assert_eq!(found.map(|s| s.name.as_str()), Some("root"));
952    }
953
954    #[test]
955    fn test_find_scope_by_path_no_match() {
956        let scopes = vec![];
957        let found = find_scope_by_path(&scopes, Path::new("src/cli"));
958        assert!(found.is_none());
959    }
960
961    fn scope_default() -> Scope {
962        Scope {
963            name: String::new(),
964            dir: ".".into(),
965            language: Language::Unknown("auto".into()),
966            framework: String::new(),
967            build_tool: BuildTool::Unknown("auto".into()),
968            registry: Registry::None,
969            release: StageRelease::default(),
970            test_threshold: None,
971            ci_workflow: None,
972        }
973    }
974}