1use std::fs;
4use std::path::{Path, PathBuf};
5
6use std::fmt;
7
8use anyhow::{Context, Result};
9use tracing::debug;
10
11use crate::data::context::{
12 CommitRules, Ecosystem, FeatureContext, ProjectContext, ProjectConventions, ScopeDefinition,
13 ScopeRequirements,
14};
15use crate::utils::env::{EnvSource, SystemEnv};
16
17fn xdg_config_dir_with(env: &impl EnvSource, home: Option<&Path>) -> Option<PathBuf> {
31 if let Some(xdg_home) = env.var("XDG_CONFIG_HOME") {
32 if !xdg_home.is_empty() {
33 return Some(PathBuf::from(xdg_home).join("omni-dev"));
34 }
35 }
36
37 home.map(|home| home.join(".config").join("omni-dev"))
39}
40
41pub fn resolve_config_file(dir: &Path, filename: &str) -> PathBuf {
49 resolve_config_file_with(dir, filename, &SystemEnv, dirs::home_dir().as_deref())
50}
51
52fn resolve_config_file_with(
57 dir: &Path,
58 filename: &str,
59 env: &impl EnvSource,
60 home: Option<&Path>,
61) -> PathBuf {
62 let local_path = dir.join("local").join(filename);
63 if local_path.exists() {
64 return local_path;
65 }
66
67 let project_path = dir.join(filename);
68 if project_path.exists() {
69 return project_path;
70 }
71
72 if let Some(xdg_dir) = xdg_config_dir_with(env, home) {
74 let xdg_path = xdg_dir.join(filename);
75 if xdg_path.exists() {
76 return xdg_path;
77 }
78 }
79
80 if let Some(home_dir) = home {
82 let home_path = home_dir.join(".omni-dev").join(filename);
83 if home_path.exists() {
84 return home_path;
85 }
86 }
87
88 project_path
90}
91
92fn walk_up_find_config_dir(start: &Path) -> Option<PathBuf> {
98 let mut current = start.to_path_buf();
99 loop {
100 let candidate = current.join(".omni-dev");
101 if candidate.is_dir() {
102 return Some(candidate);
103 }
104 if current.join(".git").exists() {
106 break;
107 }
108 if !current.pop() {
109 break;
110 }
111 }
112 None
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum ConfigDirSource {
118 CliFlag,
120 EnvVar,
122 WalkUp,
124 Default,
126}
127
128impl fmt::Display for ConfigDirSource {
129 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130 match self {
131 Self::CliFlag => write!(f, "--context-dir"),
132 Self::EnvVar => write!(f, "OMNI_DEV_CONFIG_DIR"),
133 Self::WalkUp => write!(f, "walk-up"),
134 Self::Default => write!(f, "default"),
135 }
136 }
137}
138
139pub fn resolve_context_dir_with_source(override_dir: Option<&Path>) -> (PathBuf, ConfigDirSource) {
147 resolve_context_dir_with_source_env(override_dir, &SystemEnv)
148}
149
150fn resolve_context_dir_with_source_env(
155 override_dir: Option<&Path>,
156 env: &impl EnvSource,
157) -> (PathBuf, ConfigDirSource) {
158 if let Some(dir) = override_dir {
159 return (dir.to_path_buf(), ConfigDirSource::CliFlag);
160 }
161
162 if let Some(env_dir) = env.var("OMNI_DEV_CONFIG_DIR") {
163 if !env_dir.is_empty() {
164 return (PathBuf::from(env_dir), ConfigDirSource::EnvVar);
165 }
166 }
167
168 if let Ok(cwd) = std::env::current_dir() {
170 if let Some(config_dir) = walk_up_find_config_dir(&cwd) {
171 return (config_dir, ConfigDirSource::WalkUp);
172 }
173 }
174
175 (PathBuf::from(".omni-dev"), ConfigDirSource::Default)
176}
177
178pub fn resolve_context_dir(override_dir: Option<&Path>) -> PathBuf {
183 resolve_context_dir_with_source(override_dir).0
184}
185
186pub fn resolve_context_dir_with_source_at(
193 override_dir: Option<&Path>,
194 repo_root: &Path,
195) -> (PathBuf, ConfigDirSource) {
196 resolve_context_dir_with_source_at_env(override_dir, repo_root, &SystemEnv)
197}
198
199fn resolve_context_dir_with_source_at_env(
204 override_dir: Option<&Path>,
205 repo_root: &Path,
206 env: &impl EnvSource,
207) -> (PathBuf, ConfigDirSource) {
208 if let Some(dir) = override_dir {
209 return (dir.to_path_buf(), ConfigDirSource::CliFlag);
210 }
211
212 if let Some(env_dir) = env.var("OMNI_DEV_CONFIG_DIR") {
213 if !env_dir.is_empty() {
214 return (PathBuf::from(env_dir), ConfigDirSource::EnvVar);
215 }
216 }
217
218 if let Some(config_dir) = walk_up_find_config_dir(repo_root) {
220 return (config_dir, ConfigDirSource::WalkUp);
221 }
222
223 (repo_root.join(".omni-dev"), ConfigDirSource::Default)
224}
225
226pub fn resolve_context_dir_at(override_dir: Option<&Path>, repo_root: &Path) -> PathBuf {
228 resolve_context_dir_with_source_at(override_dir, repo_root).0
229}
230
231pub fn load_config_content(dir: &Path, filename: &str) -> Result<Option<String>> {
236 let path = resolve_config_file(dir, filename);
237 if path.exists() {
238 let content = fs::read_to_string(&path)
239 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
240 Ok(Some(content))
241 } else {
242 Ok(None)
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum ConfigSourceLabel {
249 LocalOverride(PathBuf),
251 Project(PathBuf),
253 Xdg(PathBuf),
255 Global(PathBuf),
257 NotFound,
259}
260
261impl fmt::Display for ConfigSourceLabel {
262 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263 match self {
264 Self::LocalOverride(p) => write!(f, "Local override: {}", p.display()),
265 Self::Project(p) => write!(f, "Project: {}", p.display()),
266 Self::Xdg(p) => write!(f, "Global (XDG): {}", p.display()),
267 Self::Global(p) => write!(f, "Global: {}", p.display()),
268 Self::NotFound => write!(f, "(not found)"),
269 }
270 }
271}
272
273pub fn config_source_label(dir: &Path, filename: &str) -> ConfigSourceLabel {
278 config_source_label_with(dir, filename, &SystemEnv, dirs::home_dir().as_deref())
279}
280
281fn config_source_label_with(
285 dir: &Path,
286 filename: &str,
287 env: &impl EnvSource,
288 home: Option<&Path>,
289) -> ConfigSourceLabel {
290 let local_path = dir.join("local").join(filename);
291 if local_path.exists() {
292 return ConfigSourceLabel::LocalOverride(local_path);
293 }
294
295 let project_path = dir.join(filename);
296 if project_path.exists() {
297 return ConfigSourceLabel::Project(project_path);
298 }
299
300 if let Some(xdg_dir) = xdg_config_dir_with(env, home) {
301 let xdg_path = xdg_dir.join(filename);
302 if xdg_path.exists() {
303 return ConfigSourceLabel::Xdg(xdg_path);
304 }
305 }
306
307 if let Some(home_dir) = home {
308 let home_path = home_dir.join(".omni-dev").join(filename);
309 if home_path.exists() {
310 return ConfigSourceLabel::Global(home_path);
311 }
312 }
313
314 ConfigSourceLabel::NotFound
315}
316
317pub fn load_project_scopes(context_dir: &Path, repo_path: &Path) -> Vec<ScopeDefinition> {
322 let mut scopes = load_project_scopes_only(context_dir);
323 merge_ecosystem_scopes(&mut scopes, repo_path);
324 scopes
325}
326
327pub fn load_project_scopes_only(context_dir: &Path) -> Vec<ScopeDefinition> {
336 let scopes_path = resolve_config_file(context_dir, "scopes.yaml");
337 if !scopes_path.exists() {
338 return vec![];
339 }
340 let scopes_yaml = match fs::read_to_string(&scopes_path) {
341 Ok(content) => content,
342 Err(e) => {
343 tracing::warn!("Cannot read scopes file {}: {e}", scopes_path.display());
344 return vec![];
345 }
346 };
347 match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
348 Ok(config) => config.scopes,
349 Err(e) => {
350 tracing::warn!(
351 "Ignoring malformed scopes file {}: {e}",
352 scopes_path.display()
353 );
354 vec![]
355 }
356 }
357}
358
359#[derive(Debug, Clone, Default, serde::Deserialize)]
366pub struct ScopesFile {
367 #[serde(default)]
369 pub scopes: Vec<ScopeDefinition>,
370 #[serde(default)]
372 pub allow: Vec<String>,
373}
374
375pub fn load_scopes_file_strict(context_dir: &Path) -> Result<ScopesFile> {
387 let scopes_path = resolve_config_file(context_dir, "scopes.yaml");
388 if !scopes_path.exists() {
389 return Ok(ScopesFile::default());
390 }
391 let raw = fs::read_to_string(&scopes_path)
392 .with_context(|| format!("Failed to read scopes file: {}", scopes_path.display()))?;
393 serde_yaml::from_str(&raw)
394 .with_context(|| format!("Failed to parse scopes file: {}", scopes_path.display()))
395}
396
397pub fn load_commit_rules(context_dir: &Path) -> CommitRules {
403 let rules_path = resolve_config_file(context_dir, "commit-rules.yaml");
404 if !rules_path.exists() {
405 return CommitRules::default();
406 }
407
408 let rules_yaml = match fs::read_to_string(&rules_path) {
409 Ok(content) => content,
410 Err(e) => {
411 let path = rules_path.display();
412 tracing::warn!("Cannot read commit rules file {path}: {e}");
413 return CommitRules::default();
414 }
415 };
416
417 match serde_yaml::from_str::<CommitRules>(&rules_yaml) {
418 Ok(rules) => rules,
419 Err(e) => {
420 let path = rules_path.display();
421 tracing::warn!("Ignoring malformed commit rules file {path}: {e}");
422 CommitRules::default()
423 }
424 }
425}
426
427pub(crate) fn merge_ecosystem_scopes(scopes: &mut Vec<ScopeDefinition>, repo_path: &Path) {
435 let ecosystem_scopes: Vec<(&str, &str, Vec<&str>)> = if repo_path.join("Cargo.toml").exists() {
436 vec![
437 (
438 "cargo",
439 "Cargo.toml and dependency management",
440 vec!["Cargo.toml", "Cargo.lock"],
441 ),
442 (
443 "lib",
444 "Library code and public API",
445 vec!["src/lib.rs", "src/**"],
446 ),
447 (
448 "cli",
449 "Command-line interface",
450 vec!["src/main.rs", "src/cli/**"],
451 ),
452 (
453 "core",
454 "Core application logic",
455 vec!["src/core/**", "src/lib/**"],
456 ),
457 ("test", "Test code", vec!["tests/**", "src/**/test*"]),
458 (
459 "docs",
460 "Documentation",
461 vec!["docs/**", "README.md", "**/*.md"],
462 ),
463 (
464 "ci",
465 "Continuous integration",
466 vec![".github/**", ".gitlab-ci.yml"],
467 ),
468 ]
469 } else if repo_path.join("package.json").exists() {
470 vec![
471 (
472 "deps",
473 "Dependencies and package.json",
474 vec!["package.json", "package-lock.json"],
475 ),
476 (
477 "config",
478 "Configuration files",
479 vec!["*.config.js", "*.config.json", ".env*"],
480 ),
481 (
482 "build",
483 "Build system and tooling",
484 vec!["webpack.config.js", "rollup.config.js"],
485 ),
486 (
487 "test",
488 "Test files",
489 vec!["test/**", "tests/**", "**/*.test.js"],
490 ),
491 (
492 "docs",
493 "Documentation",
494 vec!["docs/**", "README.md", "**/*.md"],
495 ),
496 ]
497 } else if repo_path.join("pyproject.toml").exists()
498 || repo_path.join("requirements.txt").exists()
499 {
500 vec![
501 (
502 "deps",
503 "Dependencies and requirements",
504 vec!["requirements.txt", "pyproject.toml", "setup.py"],
505 ),
506 (
507 "config",
508 "Configuration files",
509 vec!["*.ini", "*.cfg", "*.toml"],
510 ),
511 (
512 "test",
513 "Test files",
514 vec!["test/**", "tests/**", "**/*_test.py"],
515 ),
516 (
517 "docs",
518 "Documentation",
519 vec!["docs/**", "README.md", "**/*.md", "**/*.rst"],
520 ),
521 ]
522 } else if repo_path.join("go.mod").exists() {
523 vec![
524 (
525 "mod",
526 "Go modules and dependencies",
527 vec!["go.mod", "go.sum"],
528 ),
529 ("cmd", "Command-line applications", vec!["cmd/**"]),
530 ("pkg", "Library packages", vec!["pkg/**"]),
531 ("internal", "Internal packages", vec!["internal/**"]),
532 ("test", "Test files", vec!["**/*_test.go"]),
533 (
534 "docs",
535 "Documentation",
536 vec!["docs/**", "README.md", "**/*.md"],
537 ),
538 ]
539 } else if repo_path.join("pom.xml").exists() || repo_path.join("build.gradle").exists() {
540 vec![
541 (
542 "build",
543 "Build system",
544 vec!["pom.xml", "build.gradle", "build.gradle.kts"],
545 ),
546 (
547 "config",
548 "Configuration",
549 vec!["src/main/resources/**", "application.properties"],
550 ),
551 ("test", "Test files", vec!["src/test/**"]),
552 (
553 "docs",
554 "Documentation",
555 vec!["docs/**", "README.md", "**/*.md"],
556 ),
557 ]
558 } else {
559 vec![]
560 };
561
562 for (name, description, patterns) in ecosystem_scopes {
563 if !scopes.iter().any(|s| s.name == name) {
564 scopes.push(ScopeDefinition {
565 name: name.to_string(),
566 description: description.to_string(),
567 examples: vec![],
568 file_patterns: patterns.into_iter().map(String::from).collect(),
569 });
570 }
571 }
572}
573
574pub struct ProjectDiscovery {
576 repo_path: PathBuf,
577 context_dir: PathBuf,
578}
579
580impl ProjectDiscovery {
581 pub fn new(repo_path: PathBuf, context_dir: PathBuf) -> Self {
583 Self {
584 repo_path,
585 context_dir,
586 }
587 }
588
589 pub fn discover(&self) -> Result<ProjectContext> {
591 let mut context = ProjectContext::default();
592
593 let context_dir_path = if self.context_dir.is_absolute() {
595 self.context_dir.clone()
596 } else {
597 self.repo_path.join(&self.context_dir)
598 };
599 debug!(
600 context_dir = ?context_dir_path,
601 exists = context_dir_path.exists(),
602 "Looking for context directory"
603 );
604 debug!("Loading omni-dev config");
605 self.load_omni_dev_config(&mut context, &context_dir_path)?;
606 debug!("Config loading completed");
607
608 self.load_git_config(&mut context)?;
610
611 self.parse_documentation(&mut context)?;
613
614 self.detect_ecosystem(&mut context)?;
616
617 Ok(context)
618 }
619
620 fn load_omni_dev_config(&self, context: &mut ProjectContext, dir: &Path) -> Result<()> {
622 let guidelines_path = resolve_config_file(dir, "commit-guidelines.md");
624 debug!(
625 path = ?guidelines_path,
626 exists = guidelines_path.exists(),
627 "Checking for commit guidelines"
628 );
629 if guidelines_path.exists() {
630 let content = fs::read_to_string(&guidelines_path)?;
631 debug!(bytes = content.len(), "Loaded commit guidelines");
632 context.commit_guidelines = Some(content);
633 } else {
634 debug!("No commit guidelines file found");
635 }
636
637 let pr_guidelines_path = resolve_config_file(dir, "pr-guidelines.md");
639 debug!(
640 path = ?pr_guidelines_path,
641 exists = pr_guidelines_path.exists(),
642 "Checking for PR guidelines"
643 );
644 if pr_guidelines_path.exists() {
645 let content = fs::read_to_string(&pr_guidelines_path)?;
646 debug!(bytes = content.len(), "Loaded PR guidelines");
647 context.pr_guidelines = Some(content);
648 } else {
649 debug!("No PR guidelines file found");
650 }
651
652 let scopes_path = resolve_config_file(dir, "scopes.yaml");
654 if scopes_path.exists() {
655 let scopes_yaml = fs::read_to_string(&scopes_path)?;
656 match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
657 Ok(scopes_config) => {
658 context.valid_scopes = scopes_config.scopes;
659 }
660 Err(e) => {
661 tracing::warn!(
662 "Ignoring malformed scopes file {}: {e}",
663 scopes_path.display()
664 );
665 }
666 }
667 }
668
669 let local_contexts_dir = dir.join("local").join("context").join("feature-contexts");
671 let contexts_dir = dir.join("context").join("feature-contexts");
672
673 if contexts_dir.exists() {
675 self.load_feature_contexts(context, &contexts_dir)?;
676 }
677
678 if local_contexts_dir.exists() {
680 self.load_feature_contexts(context, &local_contexts_dir)?;
681 }
682
683 Ok(())
684 }
685
686 fn load_git_config(&self, _context: &mut ProjectContext) -> Result<()> {
688 Ok(())
690 }
691
692 fn parse_documentation(&self, context: &mut ProjectContext) -> Result<()> {
694 let contributing_path = self.repo_path.join("CONTRIBUTING.md");
696 if contributing_path.exists() {
697 let content = fs::read_to_string(contributing_path)?;
698 context.project_conventions = self.parse_contributing_conventions(&content)?;
699 }
700
701 let readme_path = self.repo_path.join("README.md");
703 if readme_path.exists() {
704 let content = fs::read_to_string(readme_path)?;
705 self.parse_readme_conventions(context, &content)?;
706 }
707
708 Ok(())
709 }
710
711 fn detect_ecosystem(&self, context: &mut ProjectContext) -> Result<()> {
713 context.ecosystem = if self.repo_path.join("Cargo.toml").exists() {
714 Ecosystem::Rust
715 } else if self.repo_path.join("package.json").exists() {
716 Ecosystem::Node
717 } else if self.repo_path.join("pyproject.toml").exists()
718 || self.repo_path.join("requirements.txt").exists()
719 {
720 Ecosystem::Python
721 } else if self.repo_path.join("go.mod").exists() {
722 Ecosystem::Go
723 } else if self.repo_path.join("pom.xml").exists()
724 || self.repo_path.join("build.gradle").exists()
725 {
726 Ecosystem::Java
727 } else {
728 Ecosystem::Generic
729 };
730
731 merge_ecosystem_scopes(&mut context.valid_scopes, &self.repo_path);
732
733 Ok(())
734 }
735
736 fn load_feature_contexts(
738 &self,
739 context: &mut ProjectContext,
740 contexts_dir: &Path,
741 ) -> Result<()> {
742 let entries = match fs::read_dir(contexts_dir) {
743 Ok(entries) => entries,
744 Err(e) => {
745 tracing::warn!(
746 "Cannot read feature contexts dir {}: {e}",
747 contexts_dir.display()
748 );
749 return Ok(());
750 }
751 };
752 for entry in entries.flatten() {
753 if let Some(name) = entry.file_name().to_str() {
754 if name.ends_with(".yaml") || name.ends_with(".yml") {
755 let content = fs::read_to_string(entry.path())?;
756 match serde_yaml::from_str::<FeatureContext>(&content) {
757 Ok(feature_context) => {
758 let feature_name = name
759 .trim_end_matches(".yaml")
760 .trim_end_matches(".yml")
761 .to_string();
762 context
763 .feature_contexts
764 .insert(feature_name, feature_context);
765 }
766 Err(e) => {
767 tracing::warn!(
768 "Ignoring malformed feature context {}: {e}",
769 entry.path().display()
770 );
771 }
772 }
773 }
774 }
775 }
776 Ok(())
777 }
778
779 fn parse_contributing_conventions(&self, content: &str) -> Result<ProjectConventions> {
781 let mut conventions = ProjectConventions::default();
782
783 let lines: Vec<&str> = content.lines().collect();
785 let mut in_commit_section = false;
786
787 for (i, line) in lines.iter().enumerate() {
788 let line_lower = line.to_lowercase();
789
790 if line_lower.contains("commit")
792 && (line_lower.contains("message") || line_lower.contains("format"))
793 {
794 in_commit_section = true;
795 continue;
796 }
797
798 if in_commit_section && line.starts_with('#') && !line_lower.contains("commit") {
800 in_commit_section = false;
801 }
802
803 if in_commit_section {
804 if line.contains("type(scope):") || line.contains("<type>(<scope>):") {
806 conventions.commit_format = Some("type(scope): description".to_string());
807 }
808
809 if line_lower.contains("signed-off-by") {
811 conventions
812 .required_trailers
813 .push("Signed-off-by".to_string());
814 }
815
816 if line_lower.contains("fixes") && line_lower.contains('#') {
817 conventions.required_trailers.push("Fixes".to_string());
818 }
819
820 if line.contains("feat") || line.contains("fix") || line.contains("docs") {
822 let types = extract_commit_types(line);
823 conventions.preferred_types.extend(types);
824 }
825
826 if line_lower.contains("scope") && i + 1 < lines.len() {
828 let scope_requirements = self.extract_scope_requirements(&lines[i..]);
829 conventions.scope_requirements = scope_requirements;
830 }
831 }
832 }
833
834 Ok(conventions)
835 }
836
837 fn parse_readme_conventions(&self, context: &mut ProjectContext, content: &str) -> Result<()> {
839 let lines: Vec<&str> = content.lines().collect();
841
842 for line in lines {
843 let _line_lower = line.to_lowercase();
844
845 if line.contains("src/") || line.contains("lib/") {
847 if let Some(scope) = extract_scope_from_structure(line) {
849 context.valid_scopes.push(ScopeDefinition {
850 name: scope.clone(),
851 description: format!("{scope} related changes"),
852 examples: vec![],
853 file_patterns: vec![format!("{}/**", scope)],
854 });
855 }
856 }
857 }
858
859 Ok(())
860 }
861
862 fn extract_scope_requirements(&self, lines: &[&str]) -> ScopeRequirements {
864 let mut requirements = ScopeRequirements::default();
865
866 for line in lines.iter().take(10) {
867 if line.starts_with("##") {
869 break;
870 }
871
872 let line_lower = line.to_lowercase();
873
874 if line_lower.contains("required") || line_lower.contains("must") {
875 requirements.required = true;
876 }
877
878 if line.contains(':')
880 && (line.contains("auth") || line.contains("api") || line.contains("ui"))
881 {
882 let scopes = extract_scopes_from_examples(line);
883 requirements.valid_scopes.extend(scopes);
884 }
885 }
886
887 requirements
888 }
889}
890
891#[derive(serde::Deserialize)]
893struct ScopesConfig {
894 scopes: Vec<ScopeDefinition>,
895}
896
897fn extract_commit_types(line: &str) -> Vec<String> {
899 let mut types = Vec::new();
900 let common_types = [
901 "feat", "fix", "docs", "style", "refactor", "test", "chore", "ci", "build", "perf",
902 ];
903
904 for &type_str in &common_types {
905 if line.to_lowercase().contains(type_str) {
906 types.push(type_str.to_string());
907 }
908 }
909
910 types
911}
912
913fn extract_scope_from_structure(line: &str) -> Option<String> {
915 if let Some(start) = line.find("src/") {
917 let after_src = &line[start + 4..];
918 if let Some(end) = after_src.find('/') {
919 return Some(after_src[..end].to_string());
920 }
921 }
922
923 None
924}
925
926fn extract_scopes_from_examples(line: &str) -> Vec<String> {
928 let mut scopes = Vec::new();
929 let common_scopes = ["auth", "api", "ui", "db", "config", "core", "cli", "web"];
930
931 for &scope in &common_scopes {
932 if line.to_lowercase().contains(scope) {
933 scopes.push(scope.to_string());
934 }
935 }
936
937 scopes
938}
939
940#[cfg(test)]
941#[allow(clippy::unwrap_used, clippy::expect_used)]
942mod tests {
943 use super::*;
944 use crate::test_support::env::MapEnv;
945 use tempfile::TempDir;
946
947 #[test]
950 fn local_override_wins() -> anyhow::Result<()> {
951 let dir = {
952 std::fs::create_dir_all("tmp")?;
953 TempDir::new_in("tmp")?
954 };
955 let base = dir.path();
956
957 std::fs::create_dir_all(base.join("local"))?;
959 std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
960 std::fs::write(base.join("scopes.yaml"), "project")?;
961
962 let resolved = resolve_config_file(base, "scopes.yaml");
963 assert_eq!(resolved, base.join("local").join("scopes.yaml"));
964 Ok(())
965 }
966
967 #[test]
968 fn project_fallback() -> anyhow::Result<()> {
969 let dir = {
970 std::fs::create_dir_all("tmp")?;
971 TempDir::new_in("tmp")?
972 };
973 let base = dir.path();
974
975 std::fs::write(base.join("scopes.yaml"), "project")?;
977
978 let resolved = resolve_config_file(base, "scopes.yaml");
979 assert_eq!(resolved, base.join("scopes.yaml"));
980 Ok(())
981 }
982
983 #[test]
984 fn returns_default_when_nothing_exists() {
985 let dir = {
986 std::fs::create_dir_all("tmp").ok();
987 TempDir::new_in("tmp").unwrap()
988 };
989 let base = dir.path();
990
991 let resolved = resolve_config_file(base, "scopes.yaml");
992 assert_ne!(resolved, base.join("local").join("scopes.yaml"));
997 }
998
999 #[test]
1002 fn rust_ecosystem_detected() -> anyhow::Result<()> {
1003 let dir = {
1004 std::fs::create_dir_all("tmp")?;
1005 TempDir::new_in("tmp")?
1006 };
1007 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1008
1009 let mut scopes = vec![];
1010 merge_ecosystem_scopes(&mut scopes, dir.path());
1011
1012 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
1013 assert!(names.contains(&"cargo"), "missing 'cargo' scope");
1014 assert!(names.contains(&"cli"), "missing 'cli' scope");
1015 assert!(names.contains(&"core"), "missing 'core' scope");
1016 assert!(names.contains(&"test"), "missing 'test' scope");
1017 assert!(names.contains(&"docs"), "missing 'docs' scope");
1018 assert!(names.contains(&"ci"), "missing 'ci' scope");
1019 Ok(())
1020 }
1021
1022 #[test]
1023 fn node_ecosystem_detected() -> anyhow::Result<()> {
1024 let dir = {
1025 std::fs::create_dir_all("tmp")?;
1026 TempDir::new_in("tmp")?
1027 };
1028 std::fs::write(dir.path().join("package.json"), "{}")?;
1029
1030 let mut scopes = vec![];
1031 merge_ecosystem_scopes(&mut scopes, dir.path());
1032
1033 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
1034 assert!(names.contains(&"deps"), "missing 'deps' scope");
1035 assert!(names.contains(&"config"), "missing 'config' scope");
1036 Ok(())
1037 }
1038
1039 #[test]
1040 fn go_ecosystem_detected() -> anyhow::Result<()> {
1041 let dir = {
1042 std::fs::create_dir_all("tmp")?;
1043 TempDir::new_in("tmp")?
1044 };
1045 std::fs::write(dir.path().join("go.mod"), "module example")?;
1046
1047 let mut scopes = vec![];
1048 merge_ecosystem_scopes(&mut scopes, dir.path());
1049
1050 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
1051 assert!(names.contains(&"mod"), "missing 'mod' scope");
1052 assert!(names.contains(&"cmd"), "missing 'cmd' scope");
1053 assert!(names.contains(&"pkg"), "missing 'pkg' scope");
1054 Ok(())
1055 }
1056
1057 #[test]
1058 fn existing_scope_not_overridden() -> anyhow::Result<()> {
1059 let dir = {
1060 std::fs::create_dir_all("tmp")?;
1061 TempDir::new_in("tmp")?
1062 };
1063 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1064
1065 let mut scopes = vec![ScopeDefinition {
1066 name: "cli".to_string(),
1067 description: "Custom CLI scope".to_string(),
1068 examples: vec![],
1069 file_patterns: vec!["custom/**".to_string()],
1070 }];
1071 merge_ecosystem_scopes(&mut scopes, dir.path());
1072
1073 let cli_scope = scopes.iter().find(|s| s.name == "cli").unwrap();
1075 assert_eq!(cli_scope.description, "Custom CLI scope");
1076 assert_eq!(cli_scope.file_patterns, vec!["custom/**"]);
1077 Ok(())
1078 }
1079
1080 #[test]
1081 fn no_marker_files_produces_empty() {
1082 let dir = {
1083 std::fs::create_dir_all("tmp").ok();
1084 TempDir::new_in("tmp").unwrap()
1085 };
1086 let mut scopes = vec![];
1087 merge_ecosystem_scopes(&mut scopes, dir.path());
1088 assert!(scopes.is_empty());
1089 }
1090
1091 #[test]
1094 fn load_project_scopes_with_yaml() -> anyhow::Result<()> {
1095 let dir = {
1096 std::fs::create_dir_all("tmp")?;
1097 TempDir::new_in("tmp")?
1098 };
1099 let config_dir = dir.path().join("config");
1100 std::fs::create_dir_all(&config_dir)?;
1101
1102 let scopes_yaml = r#"
1103scopes:
1104 - name: custom
1105 description: Custom scope
1106 examples: []
1107 file_patterns:
1108 - "src/custom/**"
1109"#;
1110 std::fs::write(config_dir.join("scopes.yaml"), scopes_yaml)?;
1111
1112 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1114
1115 let scopes = load_project_scopes(&config_dir, dir.path());
1116 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
1117 assert!(names.contains(&"custom"), "missing custom scope");
1118 assert!(names.contains(&"cargo"), "missing ecosystem scope");
1120 Ok(())
1121 }
1122
1123 #[test]
1124 fn load_project_scopes_no_file() -> anyhow::Result<()> {
1125 let dir = {
1126 std::fs::create_dir_all("tmp")?;
1127 TempDir::new_in("tmp")?
1128 };
1129 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1130
1131 let scopes = load_project_scopes(dir.path(), dir.path());
1132 assert!(!scopes.is_empty());
1134 Ok(())
1135 }
1136
1137 #[test]
1140 fn load_project_scopes_only_skips_ecosystem_merge() -> anyhow::Result<()> {
1141 let dir = {
1142 std::fs::create_dir_all("tmp")?;
1143 TempDir::new_in("tmp")?
1144 };
1145 let config_dir = dir.path().join("config");
1146 std::fs::create_dir_all(&config_dir)?;
1147
1148 let scopes_yaml = r#"
1149scopes:
1150 - name: custom
1151 description: Custom scope
1152 examples: []
1153 file_patterns:
1154 - "src/custom/**"
1155"#;
1156 std::fs::write(config_dir.join("scopes.yaml"), scopes_yaml)?;
1157 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1159
1160 let scopes = load_project_scopes_only(&config_dir);
1161 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
1162 assert_eq!(names, vec!["custom"], "must not include ecosystem defaults");
1163 Ok(())
1164 }
1165
1166 #[test]
1169 fn load_scopes_file_strict_parses_scopes_and_allow() -> anyhow::Result<()> {
1170 let dir = {
1171 std::fs::create_dir_all("tmp")?;
1172 TempDir::new_in("tmp")?
1173 };
1174 let scopes_yaml = r#"
1175scopes:
1176 - name: custom
1177 description: Custom scope
1178 examples: []
1179 file_patterns:
1180 - "src/custom/**"
1181allow:
1182 - "src/lib.rs"
1183 - "src/bin/**"
1184"#;
1185 std::fs::write(dir.path().join("scopes.yaml"), scopes_yaml)?;
1186
1187 let file = load_scopes_file_strict(dir.path())?;
1188 assert_eq!(file.scopes.len(), 1);
1189 assert_eq!(file.scopes[0].name, "custom");
1190 assert_eq!(
1191 file.allow,
1192 vec!["src/lib.rs".to_string(), "src/bin/**".to_string()]
1193 );
1194 Ok(())
1195 }
1196
1197 #[test]
1198 fn load_scopes_file_strict_missing_file_returns_default() -> anyhow::Result<()> {
1199 let dir = {
1200 std::fs::create_dir_all("tmp")?;
1201 TempDir::new_in("tmp")?
1202 };
1203 assert!(load_scopes_file_strict(dir.path()).is_ok());
1209 Ok(())
1210 }
1211
1212 #[test]
1213 fn load_scopes_file_strict_malformed_yaml_errors() -> anyhow::Result<()> {
1214 let dir = {
1215 std::fs::create_dir_all("tmp")?;
1216 TempDir::new_in("tmp")?
1217 };
1218 std::fs::write(dir.path().join("scopes.yaml"), "scopes: [not valid")?;
1221 assert!(load_scopes_file_strict(dir.path()).is_err());
1222 Ok(())
1223 }
1224
1225 #[test]
1226 fn load_scopes_file_strict_never_merges_ecosystem_defaults() -> anyhow::Result<()> {
1227 let dir = {
1228 std::fs::create_dir_all("tmp")?;
1229 TempDir::new_in("tmp")?
1230 };
1231 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1232 let scopes_yaml = r#"
1233scopes:
1234 - name: custom
1235 description: Custom scope
1236 examples: []
1237 file_patterns:
1238 - "src/custom/**"
1239"#;
1240 std::fs::write(dir.path().join("scopes.yaml"), scopes_yaml)?;
1241
1242 let file = load_scopes_file_strict(dir.path())?;
1243 assert_eq!(file.scopes.len(), 1);
1247 assert_eq!(file.scopes[0].name, "custom");
1248 Ok(())
1249 }
1250
1251 #[test]
1254 fn load_commit_rules_no_file_returns_default() -> anyhow::Result<()> {
1255 let dir = {
1256 std::fs::create_dir_all("tmp")?;
1257 TempDir::new_in("tmp")?
1258 };
1259
1260 let rules = load_commit_rules(dir.path());
1261 assert_eq!(
1262 rules.subject_max_len,
1263 CommitRules::default().subject_max_len
1264 );
1265 Ok(())
1266 }
1267
1268 #[test]
1269 fn load_commit_rules_with_valid_yaml() -> anyhow::Result<()> {
1270 let dir = {
1271 std::fs::create_dir_all("tmp")?;
1272 TempDir::new_in("tmp")?
1273 };
1274 let rules_yaml = r#"
1275subject_max_len: 50
1276types:
1277 - feat
1278 - fix
1279require_scope: true
1280forbidden_footers:
1281 - "Co-Authored-By"
1282"#;
1283 std::fs::write(dir.path().join("commit-rules.yaml"), rules_yaml)?;
1284
1285 let rules = load_commit_rules(dir.path());
1286 assert_eq!(rules.subject_max_len, 50);
1287 assert!(rules.require_scope);
1288 assert_eq!(rules.types, vec!["feat".to_string(), "fix".to_string()]);
1289 Ok(())
1290 }
1291
1292 #[test]
1293 fn load_commit_rules_with_malformed_yaml_falls_back_to_default() -> anyhow::Result<()> {
1294 let dir = {
1295 std::fs::create_dir_all("tmp")?;
1296 TempDir::new_in("tmp")?
1297 };
1298 std::fs::write(dir.path().join("commit-rules.yaml"), "not: [valid: yaml")?;
1299
1300 let rules = load_commit_rules(dir.path());
1301 assert_eq!(
1302 rules.subject_max_len,
1303 CommitRules::default().subject_max_len
1304 );
1305 Ok(())
1306 }
1307
1308 #[test]
1309 fn load_commit_rules_unreadable_file_falls_back_to_default() -> anyhow::Result<()> {
1310 let dir = {
1311 std::fs::create_dir_all("tmp")?;
1312 TempDir::new_in("tmp")?
1313 };
1314 std::fs::create_dir_all(dir.path().join("commit-rules.yaml"))?;
1317
1318 let rules = load_commit_rules(dir.path());
1319 assert_eq!(
1320 rules.subject_max_len,
1321 CommitRules::default().subject_max_len
1322 );
1323 Ok(())
1324 }
1325
1326 #[test]
1329 fn extract_scope_from_structure_src() {
1330 assert_eq!(
1331 extract_scope_from_structure("- `src/auth/` - Authentication"),
1332 Some("auth".to_string())
1333 );
1334 }
1335
1336 #[test]
1337 fn extract_scope_from_structure_no_match() {
1338 assert_eq!(extract_scope_from_structure("No source paths here"), None);
1339 }
1340
1341 #[test]
1342 fn extract_commit_types_from_line() {
1343 let types = extract_commit_types("feat, fix, docs, test");
1344 assert!(types.contains(&"feat".to_string()));
1345 assert!(types.contains(&"fix".to_string()));
1346 assert!(types.contains(&"docs".to_string()));
1347 assert!(types.contains(&"test".to_string()));
1348 }
1349
1350 #[test]
1351 fn extract_commit_types_empty_line() {
1352 let types = extract_commit_types("no types here");
1353 assert!(types.is_empty());
1354 }
1355
1356 #[test]
1363 fn context_dir_defaults_to_omni_dev() {
1364 let result = resolve_context_dir_with_source_env(None, &MapEnv::new()).0;
1365 assert!(
1367 result.ends_with(".omni-dev"),
1368 "expected path ending in .omni-dev, got {result:?}"
1369 );
1370 }
1371
1372 #[test]
1373 fn context_dir_uses_override() {
1374 let custom = PathBuf::from("custom-config");
1375 let result = resolve_context_dir_with_source_env(Some(&custom), &MapEnv::new()).0;
1376 assert_eq!(result, custom);
1377 }
1378
1379 #[test]
1380 fn context_dir_env_var() {
1381 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/my-config");
1382 let result = resolve_context_dir_with_source_env(None, &env).0;
1383 assert_eq!(result, PathBuf::from("/tmp/my-config"));
1384 }
1385
1386 #[test]
1387 fn context_dir_cli_flag_beats_env_var() {
1388 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1389 let cli = PathBuf::from("cli-config");
1390 let result = resolve_context_dir_with_source_env(Some(&cli), &env).0;
1391 assert_eq!(result, cli);
1392 }
1393
1394 #[test]
1395 fn context_dir_ignores_empty_env_var() {
1396 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "");
1397 let result = resolve_context_dir_with_source_env(None, &env).0;
1398 assert!(
1400 result.ends_with(".omni-dev"),
1401 "expected path ending in .omni-dev, got {result:?}"
1402 );
1403 }
1404
1405 #[test]
1408 fn with_source_cli_flag() {
1409 let custom = PathBuf::from("custom-config");
1410 let (path, source) = resolve_context_dir_with_source_env(Some(&custom), &MapEnv::new());
1411 assert_eq!(path, custom);
1412 assert_eq!(source, ConfigDirSource::CliFlag);
1413 }
1414
1415 #[test]
1416 fn with_source_env_var() {
1417 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1418 let (path, source) = resolve_context_dir_with_source_env(None, &env);
1419 assert_eq!(path, PathBuf::from("/tmp/env-config"));
1420 assert_eq!(source, ConfigDirSource::EnvVar);
1421 }
1422
1423 #[test]
1424 fn with_source_cli_beats_env() {
1425 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1426 let custom = PathBuf::from("cli-config");
1427 let (path, source) = resolve_context_dir_with_source_env(Some(&custom), &env);
1428 assert_eq!(path, custom);
1429 assert_eq!(source, ConfigDirSource::CliFlag);
1430 }
1431
1432 #[test]
1433 fn with_source_walk_up_or_default() {
1434 let (path, source) = resolve_context_dir_with_source_env(None, &MapEnv::new());
1435 assert!(
1437 path.ends_with(".omni-dev"),
1438 "expected path ending in .omni-dev, got {path:?}"
1439 );
1440 assert!(
1441 source == ConfigDirSource::WalkUp || source == ConfigDirSource::Default,
1442 "expected WalkUp or Default, got {source:?}"
1443 );
1444 }
1445
1446 #[test]
1449 fn with_source_at_cli_flag() {
1450 let custom = PathBuf::from("custom-config");
1451 let (path, source) = resolve_context_dir_with_source_at_env(
1452 Some(&custom),
1453 std::path::Path::new("/unused"),
1454 &MapEnv::new(),
1455 );
1456 assert_eq!(path, custom);
1457 assert_eq!(source, ConfigDirSource::CliFlag);
1458 }
1459
1460 #[test]
1461 fn with_source_at_env_var() {
1462 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1463 let (path, source) =
1464 resolve_context_dir_with_source_at_env(None, std::path::Path::new("/unused"), &env);
1465 assert_eq!(path, PathBuf::from("/tmp/env-config"));
1466 assert_eq!(source, ConfigDirSource::EnvVar);
1467 }
1468
1469 #[test]
1470 fn with_source_at_default_anchors_to_repo_root() {
1471 let tmp = tempfile::tempdir().unwrap();
1476 std::fs::create_dir(tmp.path().join(".git")).unwrap();
1477 let (path, source) =
1478 resolve_context_dir_with_source_at_env(None, tmp.path(), &MapEnv::new());
1479 assert_eq!(path, tmp.path().join(".omni-dev"));
1480 assert_eq!(source, ConfigDirSource::Default);
1481 assert_eq!(
1485 resolve_context_dir_at(None, tmp.path()),
1486 tmp.path().join(".omni-dev")
1487 );
1488 }
1489
1490 #[test]
1493 fn display_config_dir_source_cli_flag() {
1494 assert_eq!(ConfigDirSource::CliFlag.to_string(), "--context-dir");
1495 }
1496
1497 #[test]
1498 fn display_config_dir_source_env_var() {
1499 assert_eq!(ConfigDirSource::EnvVar.to_string(), "OMNI_DEV_CONFIG_DIR");
1500 }
1501
1502 #[test]
1503 fn display_config_dir_source_walk_up() {
1504 assert_eq!(ConfigDirSource::WalkUp.to_string(), "walk-up");
1505 }
1506
1507 #[test]
1508 fn display_config_dir_source_default() {
1509 assert_eq!(ConfigDirSource::Default.to_string(), "default");
1510 }
1511
1512 #[test]
1515 fn load_config_content_reads_project_file() -> anyhow::Result<()> {
1516 let dir = {
1517 std::fs::create_dir_all("tmp")?;
1518 TempDir::new_in("tmp")?
1519 };
1520 let base = dir.path();
1521
1522 std::fs::write(
1523 base.join("commit-guidelines.md"),
1524 "# Guidelines\nBe concise.",
1525 )?;
1526
1527 let content = load_config_content(base, "commit-guidelines.md")?;
1528 assert_eq!(content, Some("# Guidelines\nBe concise.".to_string()));
1529 Ok(())
1530 }
1531
1532 #[test]
1533 fn load_config_content_prefers_local_override() -> anyhow::Result<()> {
1534 let dir = {
1535 std::fs::create_dir_all("tmp")?;
1536 TempDir::new_in("tmp")?
1537 };
1538 let base = dir.path();
1539
1540 std::fs::create_dir_all(base.join("local"))?;
1541 std::fs::write(base.join("local").join("guidelines.md"), "local content")?;
1542 std::fs::write(base.join("guidelines.md"), "project content")?;
1543
1544 let content = load_config_content(base, "guidelines.md")?;
1545 assert_eq!(content, Some("local content".to_string()));
1546 Ok(())
1547 }
1548
1549 #[test]
1550 fn load_config_content_returns_none_when_missing() -> anyhow::Result<()> {
1551 let dir = {
1552 std::fs::create_dir_all("tmp")?;
1553 TempDir::new_in("tmp")?
1554 };
1555
1556 let content = load_config_content(dir.path(), "nonexistent.md")?;
1557 assert_eq!(content, None);
1558 Ok(())
1559 }
1560
1561 #[test]
1564 fn source_label_local_override() -> anyhow::Result<()> {
1565 let dir = {
1566 std::fs::create_dir_all("tmp")?;
1567 TempDir::new_in("tmp")?
1568 };
1569 let base = dir.path();
1570
1571 std::fs::create_dir_all(base.join("local"))?;
1572 std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
1573 std::fs::write(base.join("scopes.yaml"), "project")?;
1574
1575 let label = config_source_label(base, "scopes.yaml");
1576 assert_eq!(
1577 label,
1578 ConfigSourceLabel::LocalOverride(base.join("local").join("scopes.yaml"))
1579 );
1580 Ok(())
1581 }
1582
1583 #[test]
1584 fn source_label_project() -> anyhow::Result<()> {
1585 let dir = {
1586 std::fs::create_dir_all("tmp")?;
1587 TempDir::new_in("tmp")?
1588 };
1589 let base = dir.path();
1590
1591 std::fs::write(base.join("scopes.yaml"), "project")?;
1592
1593 let label = config_source_label(base, "scopes.yaml");
1594 assert_eq!(label, ConfigSourceLabel::Project(base.join("scopes.yaml")));
1595 Ok(())
1596 }
1597
1598 #[test]
1599 fn source_label_not_found() {
1600 let dir = {
1601 std::fs::create_dir_all("tmp").ok();
1602 TempDir::new_in("tmp").unwrap()
1603 };
1604
1605 let label = config_source_label(dir.path(), "nonexistent.yaml");
1606 assert_eq!(label, ConfigSourceLabel::NotFound);
1607 }
1608
1609 #[test]
1612 fn display_local_override() {
1613 let label = ConfigSourceLabel::LocalOverride(PathBuf::from(".omni-dev/local/scopes.yaml"));
1614 assert_eq!(
1615 label.to_string(),
1616 "Local override: .omni-dev/local/scopes.yaml"
1617 );
1618 }
1619
1620 #[test]
1621 fn display_project() {
1622 let label = ConfigSourceLabel::Project(PathBuf::from(".omni-dev/scopes.yaml"));
1623 assert_eq!(label.to_string(), "Project: .omni-dev/scopes.yaml");
1624 }
1625
1626 #[test]
1627 fn display_global() {
1628 let label = ConfigSourceLabel::Global(PathBuf::from("/home/user/.omni-dev/scopes.yaml"));
1629 assert_eq!(
1630 label.to_string(),
1631 "Global: /home/user/.omni-dev/scopes.yaml"
1632 );
1633 }
1634
1635 #[test]
1636 fn display_xdg() {
1637 let label =
1638 ConfigSourceLabel::Xdg(PathBuf::from("/home/user/.config/omni-dev/scopes.yaml"));
1639 assert_eq!(
1640 label.to_string(),
1641 "Global (XDG): /home/user/.config/omni-dev/scopes.yaml"
1642 );
1643 }
1644
1645 #[test]
1646 fn display_not_found() {
1647 let label = ConfigSourceLabel::NotFound;
1648 assert_eq!(label.to_string(), "(not found)");
1649 }
1650
1651 #[test]
1654 fn xdg_config_dir_uses_env_var() {
1655 let env = MapEnv::new().with("XDG_CONFIG_HOME", "/tmp/xdg-test");
1656 let result = xdg_config_dir_with(&env, None);
1657 assert_eq!(result, Some(PathBuf::from("/tmp/xdg-test/omni-dev")));
1658 }
1659
1660 #[test]
1661 fn xdg_config_dir_ignores_empty_env_var() {
1662 let env = MapEnv::new().with("XDG_CONFIG_HOME", "");
1664 let home = Path::new("/test-home");
1665 let result = xdg_config_dir_with(&env, Some(home));
1666 assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1667 }
1668
1669 #[test]
1670 fn xdg_config_dir_defaults_to_home_config() {
1671 let home = Path::new("/test-home");
1673 let result = xdg_config_dir_with(&MapEnv::new(), Some(home));
1674 assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1675 }
1676
1677 fn xdg_env(path: &Path) -> MapEnv {
1682 MapEnv::new().with(
1683 "XDG_CONFIG_HOME",
1684 path.to_str().expect("temp path is valid UTF-8"),
1685 )
1686 }
1687
1688 #[test]
1689 fn resolve_config_file_finds_xdg() -> anyhow::Result<()> {
1690 let xdg_dir = {
1691 std::fs::create_dir_all("tmp")?;
1692 TempDir::new_in("tmp")?
1693 };
1694 let xdg_omni = xdg_dir.path().join("omni-dev");
1695 std::fs::create_dir_all(&xdg_omni)?;
1696 std::fs::write(xdg_omni.join("commit-guidelines.md"), "xdg content")?;
1697
1698 let project_dir = {
1699 std::fs::create_dir_all("tmp")?;
1700 TempDir::new_in("tmp")?
1701 };
1702 let resolved = resolve_config_file_with(
1703 project_dir.path(),
1704 "commit-guidelines.md",
1705 &xdg_env(xdg_dir.path()),
1706 None,
1707 );
1708
1709 assert_eq!(resolved, xdg_omni.join("commit-guidelines.md"));
1710 Ok(())
1711 }
1712
1713 #[test]
1714 fn resolve_config_file_xdg_beats_home() -> anyhow::Result<()> {
1715 let xdg_dir = {
1717 std::fs::create_dir_all("tmp")?;
1718 TempDir::new_in("tmp")?
1719 };
1720 let xdg_omni = xdg_dir.path().join("omni-dev");
1721 std::fs::create_dir_all(&xdg_omni)?;
1722 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1723
1724 let project_dir = {
1726 std::fs::create_dir_all("tmp")?;
1727 TempDir::new_in("tmp")?
1728 };
1729
1730 let resolved = resolve_config_file_with(
1731 project_dir.path(),
1732 "scopes.yaml",
1733 &xdg_env(xdg_dir.path()),
1734 None,
1735 );
1736
1737 assert_eq!(resolved, xdg_omni.join("scopes.yaml"));
1739 Ok(())
1740 }
1741
1742 #[test]
1743 fn resolve_config_file_project_beats_xdg() -> anyhow::Result<()> {
1744 let xdg_dir = {
1746 std::fs::create_dir_all("tmp")?;
1747 TempDir::new_in("tmp")?
1748 };
1749 let xdg_omni = xdg_dir.path().join("omni-dev");
1750 std::fs::create_dir_all(&xdg_omni)?;
1751 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1752
1753 let project_dir = {
1755 std::fs::create_dir_all("tmp")?;
1756 TempDir::new_in("tmp")?
1757 };
1758 std::fs::write(project_dir.path().join("scopes.yaml"), "project")?;
1759
1760 let resolved = resolve_config_file_with(
1761 project_dir.path(),
1762 "scopes.yaml",
1763 &xdg_env(xdg_dir.path()),
1764 None,
1765 );
1766
1767 assert_eq!(resolved, project_dir.path().join("scopes.yaml"));
1769 Ok(())
1770 }
1771
1772 #[test]
1775 fn source_label_xdg() -> anyhow::Result<()> {
1776 let xdg_dir = {
1777 std::fs::create_dir_all("tmp")?;
1778 TempDir::new_in("tmp")?
1779 };
1780 let xdg_omni = xdg_dir.path().join("omni-dev");
1781 std::fs::create_dir_all(&xdg_omni)?;
1782 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1783
1784 let project_dir = {
1785 std::fs::create_dir_all("tmp")?;
1786 TempDir::new_in("tmp")?
1787 };
1788 let label = config_source_label_with(
1789 project_dir.path(),
1790 "scopes.yaml",
1791 &xdg_env(xdg_dir.path()),
1792 None,
1793 );
1794
1795 assert_eq!(label, ConfigSourceLabel::Xdg(xdg_omni.join("scopes.yaml")));
1796 Ok(())
1797 }
1798
1799 fn make_repo_tree() -> anyhow::Result<TempDir> {
1804 let dir = {
1805 std::fs::create_dir_all("tmp")?;
1806 TempDir::new_in("tmp")?
1807 };
1808 std::fs::create_dir(dir.path().join(".git"))?;
1810 Ok(dir)
1811 }
1812
1813 #[test]
1814 fn walk_up_finds_omni_dev_in_start_dir() -> anyhow::Result<()> {
1815 let repo = make_repo_tree()?;
1816 let sub = repo.path().join("packages").join("frontend");
1817 std::fs::create_dir_all(&sub)?;
1818 std::fs::create_dir(sub.join(".omni-dev"))?;
1819
1820 let result = walk_up_find_config_dir(&sub);
1821 assert_eq!(result, Some(sub.join(".omni-dev")));
1822 Ok(())
1823 }
1824
1825 #[test]
1826 fn walk_up_finds_omni_dev_in_parent() -> anyhow::Result<()> {
1827 let repo = make_repo_tree()?;
1828 let pkg = repo.path().join("packages").join("frontend");
1829 let src = pkg.join("src");
1830 std::fs::create_dir_all(&src)?;
1831 std::fs::create_dir(pkg.join(".omni-dev"))?;
1832
1833 let result = walk_up_find_config_dir(&src);
1834 assert_eq!(result, Some(pkg.join(".omni-dev")));
1835 Ok(())
1836 }
1837
1838 #[test]
1839 fn walk_up_finds_omni_dev_at_repo_root() -> anyhow::Result<()> {
1840 let repo = make_repo_tree()?;
1841 let deep = repo.path().join("a").join("b").join("c");
1842 std::fs::create_dir_all(&deep)?;
1843 std::fs::create_dir(repo.path().join(".omni-dev"))?;
1844
1845 let result = walk_up_find_config_dir(&deep);
1846 assert_eq!(result, Some(repo.path().join(".omni-dev")));
1847 Ok(())
1848 }
1849
1850 #[test]
1851 fn walk_up_nearest_wins() -> anyhow::Result<()> {
1852 let repo = make_repo_tree()?;
1853 let pkg = repo.path().join("packages").join("frontend");
1854 let src = pkg.join("src");
1855 std::fs::create_dir_all(&src)?;
1856 std::fs::create_dir(repo.path().join(".omni-dev"))?;
1858 std::fs::create_dir(pkg.join(".omni-dev"))?;
1859
1860 let result = walk_up_find_config_dir(&src);
1861 assert_eq!(result, Some(pkg.join(".omni-dev")));
1863 Ok(())
1864 }
1865
1866 #[test]
1867 fn walk_up_stops_at_git_boundary() -> anyhow::Result<()> {
1868 let dir = {
1869 std::fs::create_dir_all("tmp")?;
1870 TempDir::new_in("tmp")?
1871 };
1872 std::fs::create_dir(dir.path().join(".omni-dev"))?;
1874 let repo_root = dir.path().join("repo");
1876 std::fs::create_dir_all(&repo_root)?;
1877 std::fs::create_dir(repo_root.join(".git"))?;
1878 let sub = repo_root.join("sub");
1879 std::fs::create_dir(&sub)?;
1880
1881 let result = walk_up_find_config_dir(&sub);
1882 assert_eq!(result, None);
1884 Ok(())
1885 }
1886
1887 #[test]
1888 fn walk_up_returns_none_when_no_omni_dev() -> anyhow::Result<()> {
1889 let repo = make_repo_tree()?;
1890 let sub = repo.path().join("src");
1891 std::fs::create_dir(&sub)?;
1892
1893 let result = walk_up_find_config_dir(&sub);
1894 assert_eq!(result, None);
1895 Ok(())
1896 }
1897
1898 #[test]
1899 fn walk_up_handles_git_worktree_file() -> anyhow::Result<()> {
1900 let dir = {
1901 std::fs::create_dir_all("tmp")?;
1902 TempDir::new_in("tmp")?
1903 };
1904 std::fs::write(dir.path().join(".git"), "gitdir: /some/path")?;
1906 std::fs::create_dir(dir.path().join(".omni-dev"))?;
1907 let sub = dir.path().join("src");
1908 std::fs::create_dir(&sub)?;
1909
1910 let result = walk_up_find_config_dir(&sub);
1911 assert_eq!(result, Some(dir.path().join(".omni-dev")));
1912 Ok(())
1913 }
1914
1915 #[test]
1916 fn walk_up_no_omni_dev_in_repo_returns_none() -> anyhow::Result<()> {
1917 let repo = make_repo_tree()?;
1919 let sub = repo.path().join("a").join("b");
1920 std::fs::create_dir_all(&sub)?;
1921 let result = walk_up_find_config_dir(&sub);
1922 assert_eq!(result, None);
1923 Ok(())
1924 }
1925}