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#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
137#[serde(rename_all = "snake_case")]
138pub enum SourceControl {
139    #[default]
140    Github,
141    Gitlab,
142    Gitee,
143}
144
145/// Pipeline 平台。
146#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
147#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
148#[serde(rename_all = "snake_case")]
149pub enum Pipeline {
150    #[default]
151    #[serde(rename = "github_actions")]
152    GithubActions,
153    #[serde(rename = "gitlab_ci")]
154    GitlabCi,
155    Jenkins,
156}
157
158/// 制品库类型。
159///
160/// 既可用于全局 `Platforms.artifact_registry`,也可用于 scope 级别覆盖。
161#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
162#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
163#[serde(rename_all = "snake_case")]
164pub enum Registry {
165    Crates,
166    #[serde(rename = "pypi")]
167    PyPI,
168    #[serde(rename = "pubdev")]
169    PubDev,
170    Npm,
171    #[serde(rename = "github_releases")]
172    GitHubReleases,
173    Docker,
174    #[default]
175    #[serde(other)]
176    None,
177}
178
179impl fmt::Display for Registry {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        match self {
182            Self::Crates => write!(f, "crates.io"),
183            Self::PyPI => write!(f, "PyPI"),
184            Self::PubDev => write!(f, "pub.dev"),
185            Self::Npm => write!(f, "npm"),
186            Self::GitHubReleases => write!(f, "GitHub Releases"),
187            Self::Docker => write!(f, "Docker"),
188            Self::None => write!(f, "(none)"),
189        }
190    }
191}
192
193// ── Sources(事实源维度)──────────────────────────────────────────────
194
195/// 事实源配置。
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub struct Source {
199    #[serde(default)]
200    pub version: VersionSource,
201}
202
203impl Default for Source {
204    fn default() -> Self {
205        Self {
206            version: VersionSource {
207                source_type: SourceType::Auto,
208                path: None,
209            },
210        }
211    }
212}
213
214/// 版本号来源配置。
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub struct VersionSource {
218    /// YAML key 为 `type`(Rust 保留字,故字段命名避开)。
219    #[serde(default, rename = "type")]
220    pub source_type: SourceType,
221    #[serde(default)]
222    pub path: Option<String>,
223}
224
225impl Default for VersionSource {
226    fn default() -> Self {
227        Self {
228            source_type: SourceType::Auto,
229            path: None,
230        }
231    }
232}
233
234/// 版本号读取来源。
235#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
236#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
237#[serde(rename_all = "snake_case")]
238pub enum SourceType {
239    Cargo,
240    Pyproject,
241    /// 不从配置文件读版本,只从 git tag 读。
242    TagOnly,
243    Pubspec,
244    #[serde(rename = "package.json")]
245    PackageJson,
246    /// 自动检测。
247    #[default]
248    Auto,
249}
250
251impl SourceType {
252    /// 根据目录下的文件自动检测版本源类型。
253    pub fn detect(dir: &Path) -> Self {
254        if dir.join("Cargo.toml").exists() {
255            Self::Cargo
256        } else if dir.join("pyproject.toml").exists() {
257            Self::Pyproject
258        } else if dir.join("pubspec.yaml").exists() {
259            Self::Pubspec
260        } else if dir.join("package.json").exists() {
261            Self::PackageJson
262        } else {
263            Self::TagOnly
264        }
265    }
266}
267
268// ── Scopes(上下文维度)───────────────────────────────────────────────
269
270/// 作用域(上下文维度)。
271///
272/// 通过 scope 为不同组件挂载不同的 Stage、Platform、Source 组合。
273#[derive(Debug, Clone, Serialize)]
274#[serde(rename_all = "snake_case")]
275pub struct Scope {
276    pub name: String,
277    pub dir: String,
278    #[serde(default)]
279    pub language: Language,
280    #[serde(default)]
281    pub framework: String,
282    #[serde(default)]
283    pub build_tool: BuildTool,
284    #[serde(default)]
285    pub registry: Registry,
286    #[serde(default)]
287    pub release: StageRelease,
288    #[serde(default)]
289    pub test_threshold: Option<f64>,
290    #[serde(default)]
291    pub ci_workflow: Option<String>,
292}
293
294/// 编程语言。
295#[derive(Debug, Clone, PartialEq, Serialize)]
296#[serde(rename_all = "snake_case")]
297pub enum Language {
298    Rust,
299    Python,
300    Go,
301    Dart,
302    #[serde(rename = "typescript")]
303    TypeScript,
304    Unknown(String),
305}
306
307impl Default for Language {
308    fn default() -> Self {
309        Self::Unknown("auto".into())
310    }
311}
312
313impl Language {
314    /// 返回语言的显示名称。
315    pub fn as_str(&self) -> &str {
316        match self {
317            Self::Rust => "rust",
318            Self::Python => "python",
319            Self::Go => "go",
320            Self::Dart => "dart",
321            Self::TypeScript => "typescript",
322            Self::Unknown(s) => s,
323        }
324    }
325}
326
327/// 构建工具。
328#[derive(Debug, Clone, PartialEq, Serialize)]
329#[serde(rename_all = "snake_case")]
330pub enum BuildTool {
331    Cargo,
332    Uv,
333    Go,
334    Flutter,
335    Npm,
336    Unknown(String),
337}
338
339impl Default for BuildTool {
340    fn default() -> Self {
341        Self::Unknown("auto".into())
342    }
343}
344
345impl BuildTool {
346    /// 返回构建工具的显示名称。
347    pub fn as_str(&self) -> &str {
348        match self {
349            Self::Cargo => "cargo",
350            Self::Uv => "uv",
351            Self::Go => "go",
352            Self::Flutter => "flutter",
353            Self::Npm => "npm",
354            Self::Unknown(s) => s,
355        }
356    }
357}
358
359// ── 自定义反序列化(Language / BuildTool)────────────────────────────
360
361impl<'de> Deserialize<'de> for Language {
362    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
363    where
364        D: Deserializer<'de>,
365    {
366        let s = String::deserialize(deserializer)?;
367        Ok(match s.as_str() {
368            "rust" => Language::Rust,
369            "python" => Language::Python,
370            "go" => Language::Go,
371            "dart" => Language::Dart,
372            "typescript" | "ts" | "node" => Language::TypeScript,
373            other => Language::Unknown(other.to_string()),
374        })
375    }
376}
377
378impl<'de> Deserialize<'de> for BuildTool {
379    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
380    where
381        D: Deserializer<'de>,
382    {
383        let s = String::deserialize(deserializer)?;
384        Ok(match s.as_str() {
385            "cargo" => BuildTool::Cargo,
386            "uv" | "poetry" | "pdm" => BuildTool::Uv,
387            "go" => BuildTool::Go,
388            "flutter" => BuildTool::Flutter,
389            "npm" | "pnpm" | "yarn" | "bun" => BuildTool::Npm,
390            other => BuildTool::Unknown(other.to_string()),
391        })
392    }
393}
394
395// ── 自定义反序列化(scopes: map → Vec<Scope>)────────────────────────
396
397/// YAML 中的 scope 原始配置(map 格式的中间表示)。
398#[derive(Debug, Deserialize)]
399#[serde(rename_all = "snake_case")]
400struct ScopeConfig {
401    dir: String,
402    #[serde(default)]
403    language: Option<Language>,
404    #[serde(default)]
405    framework: Option<String>,
406    #[serde(default)]
407    build_tool: Option<BuildTool>,
408    #[serde(default)]
409    registry: Option<Registry>,
410    #[serde(default)]
411    release: Option<StageRelease>,
412    #[serde(default)]
413    test_threshold: Option<f64>,
414    #[serde(default)]
415    ci_workflow: Option<String>,
416}
417
418fn deserialize_scopes<'de, D>(deserializer: D) -> Result<Vec<Scope>, D::Error>
419where
420    D: Deserializer<'de>,
421{
422    /// 访问器:将 `{ name: { dir, ... } }` 转为 `[Scope, ...]`。
423    struct ScopesVisitor;
424
425    impl<'de> Visitor<'de> for ScopesVisitor {
426        type Value = Vec<Scope>;
427
428        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
429            f.write_str("作用域映射")
430        }
431
432        fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
433        where
434            M: MapAccess<'de>,
435        {
436            let mut scopes = Vec::new();
437            while let Some((name, config)) = access.next_entry::<String, ScopeConfig>()? {
438                scopes.push(Scope {
439                    name,
440                    dir: config.dir,
441                    language: config.language.unwrap_or(Language::Unknown("auto".into())),
442                    framework: config.framework.unwrap_or_default(),
443                    build_tool: config
444                        .build_tool
445                        .unwrap_or(BuildTool::Unknown("auto".into())),
446                    registry: config.registry.unwrap_or(Registry::None),
447                    release: config.release.unwrap_or_default(),
448                    test_threshold: config.test_threshold,
449                    ci_workflow: config.ci_workflow,
450                });
451            }
452            Ok(scopes)
453        }
454    }
455
456    deserializer.deserialize_map(ScopesVisitor)
457}
458
459// ── 便捷访问器 ────────────────────────────────────────────────────────
460
461impl Contract {
462    /// 获取 scope 的发布配置(scope 级覆盖 → 全局默认)。
463    pub fn scope_release<'a>(&'a self, scope: &'a Scope) -> &'a StageRelease {
464        let has_custom =
465            !scope.release.pre_publish.is_empty() || scope.release.changelog != "CHANGELOG.md";
466        if has_custom {
467            &scope.release
468        } else {
469            &self.stages.release
470        }
471    }
472
473    /// 获取 scope 的测试阈值。
474    pub fn scope_test_threshold(&self, scope: &Scope) -> f64 {
475        scope.test_threshold.unwrap_or(self.stages.test.threshold)
476    }
477
478    /// 根据路径查找匹配的 scope(最长前缀匹配)。
479    ///
480    /// 例如当前在 `src/cli/sub` 时,`cli` scope(dir: `src/cli`)
481    /// 比 root scope(dir: `.`)优先级高。
482    pub fn find_scope_by_path(&self, current_dir: &Path) -> Option<&Scope> {
483        let current_str = current_dir.to_string_lossy();
484        self.scopes
485            .iter()
486            .filter(|s| current_str.starts_with(&s.dir) || s.dir == ".")
487            .max_by_key(|s| s.dir.len())
488    }
489
490    /// 语言探测:scope 声明了具体语言则返回,否则按目录文件推测。
491    pub fn resolve_language(&self, scope: &Scope, scope_dir: &Path) -> Language {
492        match &scope.language {
493            Language::Unknown(_) => detect_language_by_files(scope_dir),
494            lang => lang.clone(),
495        }
496    }
497
498    /// 验算契约:检查 scope 配置是否合法。
499    ///
500    /// 返回所有问题的描述列表,空表示合法。
501    ///
502    /// ```
503    /// use std::path::Path;
504    /// use quanttide_devops::contract::Contract;
505    ///
506    /// let c = Contract::default();
507    /// let errors = c.validate(Path::new("/tmp/nonexistent"));
508    /// assert!(errors.is_empty()); // 空契约→无 scope 可检查
509    /// ```
510    pub fn validate(&self, repo_path: &Path) -> Vec<String> {
511        let mut errors = Vec::new();
512        for scope in &self.scopes {
513            let dir = repo_path.join(&scope.dir);
514            if !dir.exists() {
515                errors.push(format!("scope '{}' 目录不存在: {}", scope.name, scope.dir));
516            }
517        }
518        errors
519    }
520}
521
522/// 根据目录下的标志文件推测编程语言。
523pub fn detect_language_by_files(dir: &Path) -> Language {
524    if dir.join("Cargo.toml").exists() {
525        Language::Rust
526    } else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
527        Language::Python
528    } else if dir.join("go.mod").exists() {
529        Language::Go
530    } else if dir.join("pubspec.yaml").exists() {
531        Language::Dart
532    } else if dir.join("package.json").exists() {
533        Language::TypeScript
534    } else {
535        Language::Unknown("无法识别".into())
536    }
537}
538
539// ═══════════════════════════════════════════════════════════════════════
540// 测试
541// ═══════════════════════════════════════════════════════════════════════
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use serde_yaml;
547
548    fn parse_yaml(s: &str) -> Contract {
549        serde_yaml::from_str(s).expect("YAML 应能解析")
550    }
551
552    // ── 完整契约 ──────────────────────────────────────────────────
553
554    #[test]
555    fn test_full_contract() {
556        let yaml = r#"
557stages:
558  build:
559    command: cargo build
560  test:
561    command: cargo test
562    threshold: 80.0
563  release:
564    changelog: CHANGELOG.md
565    pre_publish:
566      - cargo publish
567
568platform:
569  source_control: github
570  pipeline: github_actions
571  artifact_registry: crates
572
573sources:
574  version:
575    type: cargo
576
577scopes:
578  cli:
579    dir: src/cli
580    language: rust
581    build_tool: cargo
582    registry: crates
583    test_threshold: 90.0
584  web:
585    dir: src/web
586    language: typescript
587    build_tool: npm
588"#;
589        let c: Contract = parse_yaml(yaml);
590        assert_eq!(c.stages.build.command.as_deref(), Some("cargo build"));
591        assert_eq!(c.stages.test.threshold, 80.0);
592        assert_eq!(c.stages.test.command.as_deref(), Some("cargo test"));
593        assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
594        assert_eq!(
595            c.stages.release.pre_publish,
596            vec!["cargo publish".to_string()]
597        );
598
599        assert_eq!(c.platform.source_control, SourceControl::Github);
600        assert_eq!(c.platform.pipeline, Pipeline::GithubActions);
601        assert_eq!(c.platform.artifact_registry, Registry::Crates);
602
603        assert_eq!(c.sources.version.source_type, SourceType::Cargo);
604
605        assert_eq!(c.scopes.len(), 2);
606
607        let cli = &c.scopes[0];
608        assert_eq!(cli.name, "cli");
609        assert_eq!(cli.dir, "src/cli");
610        assert_eq!(cli.language, Language::Rust);
611        assert_eq!(cli.build_tool, BuildTool::Cargo);
612        assert_eq!(cli.registry, Registry::Crates);
613        assert_eq!(cli.test_threshold, Some(90.0));
614
615        let web = &c.scopes[1];
616        assert_eq!(web.name, "web");
617        assert_eq!(web.language, Language::TypeScript);
618        assert_eq!(web.build_tool, BuildTool::Npm);
619    }
620
621    // ── 最小契约(全默认值) ──────────────────────────────────────
622
623    #[test]
624    fn test_empty_contract() {
625        let yaml = r#"
626stages:
627scopes:
628"#;
629        let c: Contract = parse_yaml(yaml);
630        assert_eq!(c.stages.build.command, None);
631        assert_eq!(c.stages.test.threshold, 70.0);
632        assert_eq!(c.stages.release.changelog, "CHANGELOG.md");
633        assert_eq!(c.platform.source_control, SourceControl::Github);
634        assert_eq!(c.sources.version.source_type, SourceType::Auto);
635        assert!(c.scopes.is_empty());
636    }
637
638    #[test]
639    fn test_fully_empty_yaml() {
640        let c: Contract = serde_yaml::from_str("").unwrap_or_default();
641        assert_eq!(c.stages.test.threshold, 70.0);
642        assert!(c.scopes.is_empty());
643    }
644
645    // ── Language 解析 ─────────────────────────────────────────────
646
647    #[test]
648    fn test_language_parse() {
649        let c: Contract = parse_yaml(
650            r#"
651scopes:
652  a:
653    dir: .
654    language: rust
655  b:
656    dir: .
657    language: typescript
658  c:
659    dir: .
660    language: ts
661  d:
662    dir: .
663    language: node
664  e:
665    dir: .
666    language: unknown_lang
667"#,
668        );
669        assert_eq!(c.scopes[0].language, Language::Rust);
670        assert_eq!(c.scopes[1].language, Language::TypeScript);
671        assert_eq!(c.scopes[2].language, Language::TypeScript);
672        assert_eq!(c.scopes[3].language, Language::TypeScript);
673        assert_eq!(
674            c.scopes[4].language,
675            Language::Unknown("unknown_lang".into())
676        );
677    }
678
679    // ── Registry 解析 ─────────────────────────────────────────────
680
681    #[test]
682    fn test_registry_parse() {
683        let c: Contract = parse_yaml(
684            r#"
685platform:
686  artifact_registry: pypi
687scopes:
688  s:
689    dir: .
690    registry: github_releases
691"#,
692        );
693        assert_eq!(c.platform.artifact_registry, Registry::PyPI);
694        assert_eq!(c.scopes[0].registry, Registry::GitHubReleases);
695    }
696
697    // ── SourceType 解析 ───────────────────────────────────────────
698
699    #[test]
700    fn test_source_type() {
701        let c: Contract = parse_yaml(
702            r#"
703sources:
704  version:
705    type: package.json
706"#,
707        );
708        assert_eq!(c.sources.version.source_type, SourceType::PackageJson);
709    }
710
711    // ── 便捷访问器 ────────────────────────────────────────────────
712
713    #[test]
714    fn test_scope_release_fallback() {
715        let c: Contract = parse_yaml(
716            r#"
717stages:
718  release:
719    changelog: CHANGELOG.md
720    pre_publish:
721      - cargo publish
722scopes:
723  cli:
724    dir: src/cli
725    language: rust
726"#,
727        );
728        let cli = &c.scopes[0];
729        let rel = c.scope_release(cli);
730        assert_eq!(rel.pre_publish, vec!["cargo publish".to_string()]);
731    }
732
733    #[test]
734    fn test_scope_release_override() {
735        let c: Contract = parse_yaml(
736            r#"
737stages:
738  release:
739    changelog: CHANGELOG.md
740scopes:
741  cli:
742    dir: src/cli
743    language: rust
744    release:
745      changelog: docs/CHANGELOG.md
746"#,
747        );
748        let cli = &c.scopes[0];
749        let rel = c.scope_release(cli);
750        assert_eq!(rel.changelog, "docs/CHANGELOG.md");
751    }
752
753    #[test]
754    fn test_scope_test_threshold() {
755        let c: Contract = parse_yaml(
756            r#"
757stages:
758  test:
759    threshold: 70.0
760scopes:
761  a:
762    dir: .
763  b:
764    dir: .
765    test_threshold: 90.0
766"#,
767        );
768        assert_eq!(c.scope_test_threshold(&c.scopes[0]), 70.0);
769        assert_eq!(c.scope_test_threshold(&c.scopes[1]), 90.0);
770    }
771
772    // ── find_scope_by_path ────────────────────────────────────────
773
774    #[test]
775    fn test_find_scope_by_path() {
776        let c: Contract = parse_yaml(
777            r#"
778scopes:
779  root:
780    dir: .
781  cli:
782    dir: src/cli
783  web:
784    dir: src/web
785"#,
786        );
787        assert_eq!(
788            c.find_scope_by_path(std::path::Path::new("src/cli/sub"))
789                .map(|s| s.name.as_str()),
790            Some("cli")
791        );
792        assert_eq!(
793            c.find_scope_by_path(std::path::Path::new("src/web"))
794                .map(|s| s.name.as_str()),
795            Some("web")
796        );
797        assert_eq!(
798            c.find_scope_by_path(std::path::Path::new("unknown"))
799                .map(|s| s.name.as_str()),
800            Some("root")
801        );
802    }
803
804    // ── resolve_language ──────────────────────────────────────────
805
806    #[test]
807    fn test_resolve_language_declared() {
808        let c: Contract = parse_yaml(
809            r#"
810scopes:
811  cli:
812    dir: .
813    language: rust
814"#,
815        );
816        let lang = c.resolve_language(&c.scopes[0], std::path::Path::new("/tmp"));
817        assert_eq!(lang, Language::Rust);
818    }
819
820    #[test]
821    fn test_resolve_language_auto() {
822        let d = tempfile::tempdir().unwrap();
823        std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
824        let c: Contract = parse_yaml(
825            r#"
826scopes:
827  cli:
828    dir: .
829"#,
830        );
831        let lang = c.resolve_language(&c.scopes[0], d.path());
832        assert_eq!(lang, Language::Rust);
833    }
834
835    // ── detect_language_by_files ──────────────────────────────────
836
837    #[test]
838    fn test_detect_by_files() {
839        let d = tempfile::tempdir().unwrap();
840        assert_eq!(
841            detect_language_by_files(d.path()),
842            Language::Unknown("无法识别".into())
843        );
844        std::fs::write(d.path().join("Cargo.toml"), "").unwrap();
845        assert_eq!(detect_language_by_files(d.path()), Language::Rust);
846        std::fs::write(d.path().join("go.mod"), "").unwrap();
847        // Cargo.toml 优先(顺序检测)
848        assert_eq!(detect_language_by_files(d.path()), Language::Rust);
849    }
850}