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}
246
247pub 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 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
284pub 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
299pub fn scope_test_threshold(contract: &Contract, scope: &Scope) -> f64 {
301 scope
302 .test_threshold
303 .unwrap_or(contract.stages.test.threshold)
304}
305
306pub 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
333pub 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#[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
640pub fn load_scopes(repo_path: &Path) -> Vec<Scope> {
646 load(repo_path).scopes
647}
648
649pub fn detect_language(dir: &Path) -> Language {
651 detect_by_files(dir)
652}
653
654pub 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 #[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 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 assert_eq!(c.platforms.source_control, "github");
733 assert_eq!(c.platforms.artifact_registry, Registry::Crates);
734
735 assert_eq!(c.sources.version.source_type, SourceType::Cargo);
737
738 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 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 #[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 #[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 #[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 #[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}