1use std::path::Path;
5
6#[derive(Debug)]
12pub struct Contract {
13 pub stages: Stages,
15 pub platforms: Platforms,
17 pub sources: Sources,
19 pub scopes: Vec<Scope>,
21}
22
23#[derive(Debug, Clone)]
27pub struct Stages {
28 pub build: StageBuild,
30 pub test: StageTest,
32 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#[derive(Debug, Clone)]
76pub struct Platforms {
77 pub source_control: String,
79 pub ci: String,
81 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#[derive(Debug, Clone)]
99pub struct Sources {
100 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,
125 Pyproject,
127 TagOnly,
129 Pubspec,
131 PackageJson,
133 Auto,
135}
136
137#[derive(Debug, Clone)]
141pub struct Scope {
142 pub name: String,
143 pub dir: String,
144 pub language: Language,
146 pub framework: String,
147 pub build_tool: BuildTool,
148 pub registry: Registry,
150 pub release: StageRelease,
152 pub test_threshold: Option<f64>,
154 pub ci_workflow: Option<String>,
156}
157
158#[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#[derive(Debug)]
241pub struct VersionStatus {
242 pub tag_version: Option<String>,
243 pub config_version: Option<String>,
244 pub consistent: bool,
245 pub config_files: Vec<(String, Option<String>)>,
247}
248
249pub 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 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
286pub 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
301pub fn scope_test_threshold(contract: &Contract, scope: &Scope) -> f64 {
303 scope
304 .test_threshold
305 .unwrap_or(contract.stages.test.threshold)
306}
307
308pub 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
335pub 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, }),
353 None => config_version.is_none(),
354 };
355 VersionStatus {
356 tag_version,
357 config_version,
358 consistent,
359 config_files,
360 }
361}
362
363pub 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#[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
684pub fn load_scopes(repo_path: &Path) -> Vec<Scope> {
690 load(repo_path).scopes
691}
692
693pub fn detect_language(dir: &Path) -> Language {
695 detect_by_files(dir)
696}
697
698pub 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 #[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 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 assert_eq!(c.platforms.source_control, "github");
777 assert_eq!(c.platforms.artifact_registry, Registry::Crates);
778
779 assert_eq!(c.sources.version.source_type, SourceType::Cargo);
781
782 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 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 #[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 #[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 #[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 #[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}