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 Ecosystem, FeatureContext, ProjectContext, ProjectConventions, ScopeDefinition,
13 ScopeRequirements,
14};
15
16fn xdg_config_dir() -> Option<PathBuf> {
26 if let Ok(xdg_home) = std::env::var("XDG_CONFIG_HOME") {
27 if !xdg_home.is_empty() {
28 return Some(PathBuf::from(xdg_home).join("omni-dev"));
29 }
30 }
31
32 dirs::home_dir().map(|home| home.join(".config").join("omni-dev"))
34}
35
36pub fn resolve_config_file(dir: &Path, filename: &str) -> PathBuf {
44 let local_path = dir.join("local").join(filename);
45 if local_path.exists() {
46 return local_path;
47 }
48
49 let project_path = dir.join(filename);
50 if project_path.exists() {
51 return project_path;
52 }
53
54 if let Some(xdg_dir) = xdg_config_dir() {
56 let xdg_path = xdg_dir.join(filename);
57 if xdg_path.exists() {
58 return xdg_path;
59 }
60 }
61
62 if let Ok(home_dir) = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("No home directory")) {
64 let home_path = home_dir.join(".omni-dev").join(filename);
65 if home_path.exists() {
66 return home_path;
67 }
68 }
69
70 project_path
72}
73
74fn walk_up_find_config_dir(start: &Path) -> Option<PathBuf> {
80 let mut current = start.to_path_buf();
81 loop {
82 let candidate = current.join(".omni-dev");
83 if candidate.is_dir() {
84 return Some(candidate);
85 }
86 if current.join(".git").exists() {
88 break;
89 }
90 if !current.pop() {
91 break;
92 }
93 }
94 None
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum ConfigDirSource {
100 CliFlag,
102 EnvVar,
104 WalkUp,
106 Default,
108}
109
110impl fmt::Display for ConfigDirSource {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 match self {
113 Self::CliFlag => write!(f, "--context-dir"),
114 Self::EnvVar => write!(f, "OMNI_DEV_CONFIG_DIR"),
115 Self::WalkUp => write!(f, "walk-up"),
116 Self::Default => write!(f, "default"),
117 }
118 }
119}
120
121pub fn resolve_context_dir_with_source(override_dir: Option<&Path>) -> (PathBuf, ConfigDirSource) {
129 if let Some(dir) = override_dir {
130 return (dir.to_path_buf(), ConfigDirSource::CliFlag);
131 }
132
133 if let Ok(env_dir) = std::env::var("OMNI_DEV_CONFIG_DIR") {
134 if !env_dir.is_empty() {
135 return (PathBuf::from(env_dir), ConfigDirSource::EnvVar);
136 }
137 }
138
139 if let Ok(cwd) = std::env::current_dir() {
141 if let Some(config_dir) = walk_up_find_config_dir(&cwd) {
142 return (config_dir, ConfigDirSource::WalkUp);
143 }
144 }
145
146 (PathBuf::from(".omni-dev"), ConfigDirSource::Default)
147}
148
149pub fn resolve_context_dir(override_dir: Option<&Path>) -> PathBuf {
154 resolve_context_dir_with_source(override_dir).0
155}
156
157pub fn resolve_context_dir_with_source_at(
164 override_dir: Option<&Path>,
165 repo_root: &Path,
166) -> (PathBuf, ConfigDirSource) {
167 if let Some(dir) = override_dir {
168 return (dir.to_path_buf(), ConfigDirSource::CliFlag);
169 }
170
171 if let Ok(env_dir) = std::env::var("OMNI_DEV_CONFIG_DIR") {
172 if !env_dir.is_empty() {
173 return (PathBuf::from(env_dir), ConfigDirSource::EnvVar);
174 }
175 }
176
177 if let Some(config_dir) = walk_up_find_config_dir(repo_root) {
179 return (config_dir, ConfigDirSource::WalkUp);
180 }
181
182 (repo_root.join(".omni-dev"), ConfigDirSource::Default)
183}
184
185pub fn resolve_context_dir_at(override_dir: Option<&Path>, repo_root: &Path) -> PathBuf {
187 resolve_context_dir_with_source_at(override_dir, repo_root).0
188}
189
190pub fn load_config_content(dir: &Path, filename: &str) -> Result<Option<String>> {
195 let path = resolve_config_file(dir, filename);
196 if path.exists() {
197 let content = fs::read_to_string(&path)
198 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
199 Ok(Some(content))
200 } else {
201 Ok(None)
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
207pub enum ConfigSourceLabel {
208 LocalOverride(PathBuf),
210 Project(PathBuf),
212 Xdg(PathBuf),
214 Global(PathBuf),
216 NotFound,
218}
219
220impl fmt::Display for ConfigSourceLabel {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 match self {
223 Self::LocalOverride(p) => write!(f, "Local override: {}", p.display()),
224 Self::Project(p) => write!(f, "Project: {}", p.display()),
225 Self::Xdg(p) => write!(f, "Global (XDG): {}", p.display()),
226 Self::Global(p) => write!(f, "Global: {}", p.display()),
227 Self::NotFound => write!(f, "(not found)"),
228 }
229 }
230}
231
232pub fn config_source_label(dir: &Path, filename: &str) -> ConfigSourceLabel {
237 let local_path = dir.join("local").join(filename);
238 if local_path.exists() {
239 return ConfigSourceLabel::LocalOverride(local_path);
240 }
241
242 let project_path = dir.join(filename);
243 if project_path.exists() {
244 return ConfigSourceLabel::Project(project_path);
245 }
246
247 if let Some(xdg_dir) = xdg_config_dir() {
248 let xdg_path = xdg_dir.join(filename);
249 if xdg_path.exists() {
250 return ConfigSourceLabel::Xdg(xdg_path);
251 }
252 }
253
254 if let Some(home_dir) = dirs::home_dir() {
255 let home_path = home_dir.join(".omni-dev").join(filename);
256 if home_path.exists() {
257 return ConfigSourceLabel::Global(home_path);
258 }
259 }
260
261 ConfigSourceLabel::NotFound
262}
263
264pub fn load_project_scopes(context_dir: &Path, repo_path: &Path) -> Vec<ScopeDefinition> {
269 let scopes_path = resolve_config_file(context_dir, "scopes.yaml");
270 let mut scopes = if scopes_path.exists() {
271 let scopes_yaml = match fs::read_to_string(&scopes_path) {
272 Ok(content) => content,
273 Err(e) => {
274 tracing::warn!("Cannot read scopes file {}: {e}", scopes_path.display());
275 return vec![];
276 }
277 };
278 match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
279 Ok(config) => config.scopes,
280 Err(e) => {
281 tracing::warn!(
282 "Ignoring malformed scopes file {}: {e}",
283 scopes_path.display()
284 );
285 vec![]
286 }
287 }
288 } else {
289 vec![]
290 };
291
292 merge_ecosystem_scopes(&mut scopes, repo_path);
293 scopes
294}
295
296fn merge_ecosystem_scopes(scopes: &mut Vec<ScopeDefinition>, repo_path: &Path) {
301 let ecosystem_scopes: Vec<(&str, &str, Vec<&str>)> = if repo_path.join("Cargo.toml").exists() {
302 vec![
303 (
304 "cargo",
305 "Cargo.toml and dependency management",
306 vec!["Cargo.toml", "Cargo.lock"],
307 ),
308 (
309 "lib",
310 "Library code and public API",
311 vec!["src/lib.rs", "src/**"],
312 ),
313 (
314 "cli",
315 "Command-line interface",
316 vec!["src/main.rs", "src/cli/**"],
317 ),
318 (
319 "core",
320 "Core application logic",
321 vec!["src/core/**", "src/lib/**"],
322 ),
323 ("test", "Test code", vec!["tests/**", "src/**/test*"]),
324 (
325 "docs",
326 "Documentation",
327 vec!["docs/**", "README.md", "**/*.md"],
328 ),
329 (
330 "ci",
331 "Continuous integration",
332 vec![".github/**", ".gitlab-ci.yml"],
333 ),
334 ]
335 } else if repo_path.join("package.json").exists() {
336 vec![
337 (
338 "deps",
339 "Dependencies and package.json",
340 vec!["package.json", "package-lock.json"],
341 ),
342 (
343 "config",
344 "Configuration files",
345 vec!["*.config.js", "*.config.json", ".env*"],
346 ),
347 (
348 "build",
349 "Build system and tooling",
350 vec!["webpack.config.js", "rollup.config.js"],
351 ),
352 (
353 "test",
354 "Test files",
355 vec!["test/**", "tests/**", "**/*.test.js"],
356 ),
357 (
358 "docs",
359 "Documentation",
360 vec!["docs/**", "README.md", "**/*.md"],
361 ),
362 ]
363 } else if repo_path.join("pyproject.toml").exists()
364 || repo_path.join("requirements.txt").exists()
365 {
366 vec![
367 (
368 "deps",
369 "Dependencies and requirements",
370 vec!["requirements.txt", "pyproject.toml", "setup.py"],
371 ),
372 (
373 "config",
374 "Configuration files",
375 vec!["*.ini", "*.cfg", "*.toml"],
376 ),
377 (
378 "test",
379 "Test files",
380 vec!["test/**", "tests/**", "**/*_test.py"],
381 ),
382 (
383 "docs",
384 "Documentation",
385 vec!["docs/**", "README.md", "**/*.md", "**/*.rst"],
386 ),
387 ]
388 } else if repo_path.join("go.mod").exists() {
389 vec![
390 (
391 "mod",
392 "Go modules and dependencies",
393 vec!["go.mod", "go.sum"],
394 ),
395 ("cmd", "Command-line applications", vec!["cmd/**"]),
396 ("pkg", "Library packages", vec!["pkg/**"]),
397 ("internal", "Internal packages", vec!["internal/**"]),
398 ("test", "Test files", vec!["**/*_test.go"]),
399 (
400 "docs",
401 "Documentation",
402 vec!["docs/**", "README.md", "**/*.md"],
403 ),
404 ]
405 } else if repo_path.join("pom.xml").exists() || repo_path.join("build.gradle").exists() {
406 vec![
407 (
408 "build",
409 "Build system",
410 vec!["pom.xml", "build.gradle", "build.gradle.kts"],
411 ),
412 (
413 "config",
414 "Configuration",
415 vec!["src/main/resources/**", "application.properties"],
416 ),
417 ("test", "Test files", vec!["src/test/**"]),
418 (
419 "docs",
420 "Documentation",
421 vec!["docs/**", "README.md", "**/*.md"],
422 ),
423 ]
424 } else {
425 vec![]
426 };
427
428 for (name, description, patterns) in ecosystem_scopes {
429 if !scopes.iter().any(|s| s.name == name) {
430 scopes.push(ScopeDefinition {
431 name: name.to_string(),
432 description: description.to_string(),
433 examples: vec![],
434 file_patterns: patterns.into_iter().map(String::from).collect(),
435 });
436 }
437 }
438}
439
440pub struct ProjectDiscovery {
442 repo_path: PathBuf,
443 context_dir: PathBuf,
444}
445
446impl ProjectDiscovery {
447 pub fn new(repo_path: PathBuf, context_dir: PathBuf) -> Self {
449 Self {
450 repo_path,
451 context_dir,
452 }
453 }
454
455 pub fn discover(&self) -> Result<ProjectContext> {
457 let mut context = ProjectContext::default();
458
459 let context_dir_path = if self.context_dir.is_absolute() {
461 self.context_dir.clone()
462 } else {
463 self.repo_path.join(&self.context_dir)
464 };
465 debug!(
466 context_dir = ?context_dir_path,
467 exists = context_dir_path.exists(),
468 "Looking for context directory"
469 );
470 debug!("Loading omni-dev config");
471 self.load_omni_dev_config(&mut context, &context_dir_path)?;
472 debug!("Config loading completed");
473
474 self.load_git_config(&mut context)?;
476
477 self.parse_documentation(&mut context)?;
479
480 self.detect_ecosystem(&mut context)?;
482
483 Ok(context)
484 }
485
486 fn load_omni_dev_config(&self, context: &mut ProjectContext, dir: &Path) -> Result<()> {
488 let guidelines_path = resolve_config_file(dir, "commit-guidelines.md");
490 debug!(
491 path = ?guidelines_path,
492 exists = guidelines_path.exists(),
493 "Checking for commit guidelines"
494 );
495 if guidelines_path.exists() {
496 let content = fs::read_to_string(&guidelines_path)?;
497 debug!(bytes = content.len(), "Loaded commit guidelines");
498 context.commit_guidelines = Some(content);
499 } else {
500 debug!("No commit guidelines file found");
501 }
502
503 let pr_guidelines_path = resolve_config_file(dir, "pr-guidelines.md");
505 debug!(
506 path = ?pr_guidelines_path,
507 exists = pr_guidelines_path.exists(),
508 "Checking for PR guidelines"
509 );
510 if pr_guidelines_path.exists() {
511 let content = fs::read_to_string(&pr_guidelines_path)?;
512 debug!(bytes = content.len(), "Loaded PR guidelines");
513 context.pr_guidelines = Some(content);
514 } else {
515 debug!("No PR guidelines file found");
516 }
517
518 let scopes_path = resolve_config_file(dir, "scopes.yaml");
520 if scopes_path.exists() {
521 let scopes_yaml = fs::read_to_string(&scopes_path)?;
522 match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
523 Ok(scopes_config) => {
524 context.valid_scopes = scopes_config.scopes;
525 }
526 Err(e) => {
527 tracing::warn!(
528 "Ignoring malformed scopes file {}: {e}",
529 scopes_path.display()
530 );
531 }
532 }
533 }
534
535 let local_contexts_dir = dir.join("local").join("context").join("feature-contexts");
537 let contexts_dir = dir.join("context").join("feature-contexts");
538
539 if contexts_dir.exists() {
541 self.load_feature_contexts(context, &contexts_dir)?;
542 }
543
544 if local_contexts_dir.exists() {
546 self.load_feature_contexts(context, &local_contexts_dir)?;
547 }
548
549 Ok(())
550 }
551
552 fn load_git_config(&self, _context: &mut ProjectContext) -> Result<()> {
554 Ok(())
556 }
557
558 fn parse_documentation(&self, context: &mut ProjectContext) -> Result<()> {
560 let contributing_path = self.repo_path.join("CONTRIBUTING.md");
562 if contributing_path.exists() {
563 let content = fs::read_to_string(contributing_path)?;
564 context.project_conventions = self.parse_contributing_conventions(&content)?;
565 }
566
567 let readme_path = self.repo_path.join("README.md");
569 if readme_path.exists() {
570 let content = fs::read_to_string(readme_path)?;
571 self.parse_readme_conventions(context, &content)?;
572 }
573
574 Ok(())
575 }
576
577 fn detect_ecosystem(&self, context: &mut ProjectContext) -> Result<()> {
579 context.ecosystem = if self.repo_path.join("Cargo.toml").exists() {
580 Ecosystem::Rust
581 } else if self.repo_path.join("package.json").exists() {
582 Ecosystem::Node
583 } else if self.repo_path.join("pyproject.toml").exists()
584 || self.repo_path.join("requirements.txt").exists()
585 {
586 Ecosystem::Python
587 } else if self.repo_path.join("go.mod").exists() {
588 Ecosystem::Go
589 } else if self.repo_path.join("pom.xml").exists()
590 || self.repo_path.join("build.gradle").exists()
591 {
592 Ecosystem::Java
593 } else {
594 Ecosystem::Generic
595 };
596
597 merge_ecosystem_scopes(&mut context.valid_scopes, &self.repo_path);
598
599 Ok(())
600 }
601
602 fn load_feature_contexts(
604 &self,
605 context: &mut ProjectContext,
606 contexts_dir: &Path,
607 ) -> Result<()> {
608 let entries = match fs::read_dir(contexts_dir) {
609 Ok(entries) => entries,
610 Err(e) => {
611 tracing::warn!(
612 "Cannot read feature contexts dir {}: {e}",
613 contexts_dir.display()
614 );
615 return Ok(());
616 }
617 };
618 for entry in entries.flatten() {
619 if let Some(name) = entry.file_name().to_str() {
620 if name.ends_with(".yaml") || name.ends_with(".yml") {
621 let content = fs::read_to_string(entry.path())?;
622 match serde_yaml::from_str::<FeatureContext>(&content) {
623 Ok(feature_context) => {
624 let feature_name = name
625 .trim_end_matches(".yaml")
626 .trim_end_matches(".yml")
627 .to_string();
628 context
629 .feature_contexts
630 .insert(feature_name, feature_context);
631 }
632 Err(e) => {
633 tracing::warn!(
634 "Ignoring malformed feature context {}: {e}",
635 entry.path().display()
636 );
637 }
638 }
639 }
640 }
641 }
642 Ok(())
643 }
644
645 fn parse_contributing_conventions(&self, content: &str) -> Result<ProjectConventions> {
647 let mut conventions = ProjectConventions::default();
648
649 let lines: Vec<&str> = content.lines().collect();
651 let mut in_commit_section = false;
652
653 for (i, line) in lines.iter().enumerate() {
654 let line_lower = line.to_lowercase();
655
656 if line_lower.contains("commit")
658 && (line_lower.contains("message") || line_lower.contains("format"))
659 {
660 in_commit_section = true;
661 continue;
662 }
663
664 if in_commit_section && line.starts_with('#') && !line_lower.contains("commit") {
666 in_commit_section = false;
667 }
668
669 if in_commit_section {
670 if line.contains("type(scope):") || line.contains("<type>(<scope>):") {
672 conventions.commit_format = Some("type(scope): description".to_string());
673 }
674
675 if line_lower.contains("signed-off-by") {
677 conventions
678 .required_trailers
679 .push("Signed-off-by".to_string());
680 }
681
682 if line_lower.contains("fixes") && line_lower.contains('#') {
683 conventions.required_trailers.push("Fixes".to_string());
684 }
685
686 if line.contains("feat") || line.contains("fix") || line.contains("docs") {
688 let types = extract_commit_types(line);
689 conventions.preferred_types.extend(types);
690 }
691
692 if line_lower.contains("scope") && i + 1 < lines.len() {
694 let scope_requirements = self.extract_scope_requirements(&lines[i..]);
695 conventions.scope_requirements = scope_requirements;
696 }
697 }
698 }
699
700 Ok(conventions)
701 }
702
703 fn parse_readme_conventions(&self, context: &mut ProjectContext, content: &str) -> Result<()> {
705 let lines: Vec<&str> = content.lines().collect();
707
708 for line in lines {
709 let _line_lower = line.to_lowercase();
710
711 if line.contains("src/") || line.contains("lib/") {
713 if let Some(scope) = extract_scope_from_structure(line) {
715 context.valid_scopes.push(ScopeDefinition {
716 name: scope.clone(),
717 description: format!("{scope} related changes"),
718 examples: vec![],
719 file_patterns: vec![format!("{}/**", scope)],
720 });
721 }
722 }
723 }
724
725 Ok(())
726 }
727
728 fn extract_scope_requirements(&self, lines: &[&str]) -> ScopeRequirements {
730 let mut requirements = ScopeRequirements::default();
731
732 for line in lines.iter().take(10) {
733 if line.starts_with("##") {
735 break;
736 }
737
738 let line_lower = line.to_lowercase();
739
740 if line_lower.contains("required") || line_lower.contains("must") {
741 requirements.required = true;
742 }
743
744 if line.contains(':')
746 && (line.contains("auth") || line.contains("api") || line.contains("ui"))
747 {
748 let scopes = extract_scopes_from_examples(line);
749 requirements.valid_scopes.extend(scopes);
750 }
751 }
752
753 requirements
754 }
755}
756
757#[derive(serde::Deserialize)]
759struct ScopesConfig {
760 scopes: Vec<ScopeDefinition>,
761}
762
763fn extract_commit_types(line: &str) -> Vec<String> {
765 let mut types = Vec::new();
766 let common_types = [
767 "feat", "fix", "docs", "style", "refactor", "test", "chore", "ci", "build", "perf",
768 ];
769
770 for &type_str in &common_types {
771 if line.to_lowercase().contains(type_str) {
772 types.push(type_str.to_string());
773 }
774 }
775
776 types
777}
778
779fn extract_scope_from_structure(line: &str) -> Option<String> {
781 if let Some(start) = line.find("src/") {
783 let after_src = &line[start + 4..];
784 if let Some(end) = after_src.find('/') {
785 return Some(after_src[..end].to_string());
786 }
787 }
788
789 None
790}
791
792fn extract_scopes_from_examples(line: &str) -> Vec<String> {
794 let mut scopes = Vec::new();
795 let common_scopes = ["auth", "api", "ui", "db", "config", "core", "cli", "web"];
796
797 for &scope in &common_scopes {
798 if line.to_lowercase().contains(scope) {
799 scopes.push(scope.to_string());
800 }
801 }
802
803 scopes
804}
805
806#[cfg(test)]
807#[allow(clippy::unwrap_used, clippy::expect_used)]
808mod tests {
809 use super::*;
810 use tempfile::TempDir;
811
812 #[test]
815 fn local_override_wins() -> anyhow::Result<()> {
816 let dir = {
817 std::fs::create_dir_all("tmp")?;
818 TempDir::new_in("tmp")?
819 };
820 let base = dir.path();
821
822 std::fs::create_dir_all(base.join("local"))?;
824 std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
825 std::fs::write(base.join("scopes.yaml"), "project")?;
826
827 let resolved = resolve_config_file(base, "scopes.yaml");
828 assert_eq!(resolved, base.join("local").join("scopes.yaml"));
829 Ok(())
830 }
831
832 #[test]
833 fn project_fallback() -> anyhow::Result<()> {
834 let dir = {
835 std::fs::create_dir_all("tmp")?;
836 TempDir::new_in("tmp")?
837 };
838 let base = dir.path();
839
840 std::fs::write(base.join("scopes.yaml"), "project")?;
842
843 let resolved = resolve_config_file(base, "scopes.yaml");
844 assert_eq!(resolved, base.join("scopes.yaml"));
845 Ok(())
846 }
847
848 #[test]
849 fn returns_default_when_nothing_exists() {
850 let dir = {
851 std::fs::create_dir_all("tmp").ok();
852 TempDir::new_in("tmp").unwrap()
853 };
854 let base = dir.path();
855
856 let resolved = resolve_config_file(base, "scopes.yaml");
857 assert_ne!(resolved, base.join("local").join("scopes.yaml"));
862 }
863
864 #[test]
867 fn rust_ecosystem_detected() -> anyhow::Result<()> {
868 let dir = {
869 std::fs::create_dir_all("tmp")?;
870 TempDir::new_in("tmp")?
871 };
872 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
873
874 let mut scopes = vec![];
875 merge_ecosystem_scopes(&mut scopes, dir.path());
876
877 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
878 assert!(names.contains(&"cargo"), "missing 'cargo' scope");
879 assert!(names.contains(&"cli"), "missing 'cli' scope");
880 assert!(names.contains(&"core"), "missing 'core' scope");
881 assert!(names.contains(&"test"), "missing 'test' scope");
882 assert!(names.contains(&"docs"), "missing 'docs' scope");
883 assert!(names.contains(&"ci"), "missing 'ci' scope");
884 Ok(())
885 }
886
887 #[test]
888 fn node_ecosystem_detected() -> anyhow::Result<()> {
889 let dir = {
890 std::fs::create_dir_all("tmp")?;
891 TempDir::new_in("tmp")?
892 };
893 std::fs::write(dir.path().join("package.json"), "{}")?;
894
895 let mut scopes = vec![];
896 merge_ecosystem_scopes(&mut scopes, dir.path());
897
898 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
899 assert!(names.contains(&"deps"), "missing 'deps' scope");
900 assert!(names.contains(&"config"), "missing 'config' scope");
901 Ok(())
902 }
903
904 #[test]
905 fn go_ecosystem_detected() -> anyhow::Result<()> {
906 let dir = {
907 std::fs::create_dir_all("tmp")?;
908 TempDir::new_in("tmp")?
909 };
910 std::fs::write(dir.path().join("go.mod"), "module example")?;
911
912 let mut scopes = vec![];
913 merge_ecosystem_scopes(&mut scopes, dir.path());
914
915 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
916 assert!(names.contains(&"mod"), "missing 'mod' scope");
917 assert!(names.contains(&"cmd"), "missing 'cmd' scope");
918 assert!(names.contains(&"pkg"), "missing 'pkg' scope");
919 Ok(())
920 }
921
922 #[test]
923 fn existing_scope_not_overridden() -> anyhow::Result<()> {
924 let dir = {
925 std::fs::create_dir_all("tmp")?;
926 TempDir::new_in("tmp")?
927 };
928 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
929
930 let mut scopes = vec![ScopeDefinition {
931 name: "cli".to_string(),
932 description: "Custom CLI scope".to_string(),
933 examples: vec![],
934 file_patterns: vec!["custom/**".to_string()],
935 }];
936 merge_ecosystem_scopes(&mut scopes, dir.path());
937
938 let cli_scope = scopes.iter().find(|s| s.name == "cli").unwrap();
940 assert_eq!(cli_scope.description, "Custom CLI scope");
941 assert_eq!(cli_scope.file_patterns, vec!["custom/**"]);
942 Ok(())
943 }
944
945 #[test]
946 fn no_marker_files_produces_empty() {
947 let dir = {
948 std::fs::create_dir_all("tmp").ok();
949 TempDir::new_in("tmp").unwrap()
950 };
951 let mut scopes = vec![];
952 merge_ecosystem_scopes(&mut scopes, dir.path());
953 assert!(scopes.is_empty());
954 }
955
956 #[test]
959 fn load_project_scopes_with_yaml() -> anyhow::Result<()> {
960 let dir = {
961 std::fs::create_dir_all("tmp")?;
962 TempDir::new_in("tmp")?
963 };
964 let config_dir = dir.path().join("config");
965 std::fs::create_dir_all(&config_dir)?;
966
967 let scopes_yaml = r#"
968scopes:
969 - name: custom
970 description: Custom scope
971 examples: []
972 file_patterns:
973 - "src/custom/**"
974"#;
975 std::fs::write(config_dir.join("scopes.yaml"), scopes_yaml)?;
976
977 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
979
980 let scopes = load_project_scopes(&config_dir, dir.path());
981 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
982 assert!(names.contains(&"custom"), "missing custom scope");
983 assert!(names.contains(&"cargo"), "missing ecosystem scope");
985 Ok(())
986 }
987
988 #[test]
989 fn load_project_scopes_no_file() -> anyhow::Result<()> {
990 let dir = {
991 std::fs::create_dir_all("tmp")?;
992 TempDir::new_in("tmp")?
993 };
994 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
995
996 let scopes = load_project_scopes(dir.path(), dir.path());
997 assert!(!scopes.is_empty());
999 Ok(())
1000 }
1001
1002 #[test]
1005 fn extract_scope_from_structure_src() {
1006 assert_eq!(
1007 extract_scope_from_structure("- `src/auth/` - Authentication"),
1008 Some("auth".to_string())
1009 );
1010 }
1011
1012 #[test]
1013 fn extract_scope_from_structure_no_match() {
1014 assert_eq!(extract_scope_from_structure("No source paths here"), None);
1015 }
1016
1017 #[test]
1018 fn extract_commit_types_from_line() {
1019 let types = extract_commit_types("feat, fix, docs, test");
1020 assert!(types.contains(&"feat".to_string()));
1021 assert!(types.contains(&"fix".to_string()));
1022 assert!(types.contains(&"docs".to_string()));
1023 assert!(types.contains(&"test".to_string()));
1024 }
1025
1026 #[test]
1027 fn extract_commit_types_empty_line() {
1028 let types = extract_commit_types("no types here");
1029 assert!(types.is_empty());
1030 }
1031
1032 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1036
1037 #[test]
1038 fn context_dir_defaults_to_omni_dev() {
1039 let _lock = ENV_MUTEX.lock().unwrap();
1040 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1041 let result = resolve_context_dir(None);
1042 assert!(
1044 result.ends_with(".omni-dev"),
1045 "expected path ending in .omni-dev, got {result:?}"
1046 );
1047 }
1048
1049 #[test]
1050 fn context_dir_uses_override() {
1051 let _lock = ENV_MUTEX.lock().unwrap();
1052 let custom = PathBuf::from("custom-config");
1053 let result = resolve_context_dir(Some(&custom));
1054 assert_eq!(result, custom);
1055 }
1056
1057 #[test]
1058 fn context_dir_env_var() {
1059 let _lock = ENV_MUTEX.lock().unwrap();
1060 std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/my-config");
1061 let result = resolve_context_dir(None);
1062 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1063 assert_eq!(result, PathBuf::from("/tmp/my-config"));
1064 }
1065
1066 #[test]
1067 fn context_dir_cli_flag_beats_env_var() {
1068 let _lock = ENV_MUTEX.lock().unwrap();
1069 std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1070 let cli = PathBuf::from("cli-config");
1071 let result = resolve_context_dir(Some(&cli));
1072 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1073 assert_eq!(result, cli);
1074 }
1075
1076 #[test]
1077 fn context_dir_ignores_empty_env_var() {
1078 let _lock = ENV_MUTEX.lock().unwrap();
1079 std::env::set_var("OMNI_DEV_CONFIG_DIR", "");
1080 let result = resolve_context_dir(None);
1081 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1082 assert!(
1084 result.ends_with(".omni-dev"),
1085 "expected path ending in .omni-dev, got {result:?}"
1086 );
1087 }
1088
1089 #[test]
1092 fn with_source_cli_flag() {
1093 let _lock = ENV_MUTEX.lock().unwrap();
1094 let custom = PathBuf::from("custom-config");
1095 let (path, source) = resolve_context_dir_with_source(Some(&custom));
1096 assert_eq!(path, custom);
1097 assert_eq!(source, ConfigDirSource::CliFlag);
1098 }
1099
1100 #[test]
1101 fn with_source_env_var() {
1102 let _lock = ENV_MUTEX.lock().unwrap();
1103 std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1104 let (path, source) = resolve_context_dir_with_source(None);
1105 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1106 assert_eq!(path, PathBuf::from("/tmp/env-config"));
1107 assert_eq!(source, ConfigDirSource::EnvVar);
1108 }
1109
1110 #[test]
1111 fn with_source_cli_beats_env() {
1112 let _lock = ENV_MUTEX.lock().unwrap();
1113 std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1114 let custom = PathBuf::from("cli-config");
1115 let (path, source) = resolve_context_dir_with_source(Some(&custom));
1116 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1117 assert_eq!(path, custom);
1118 assert_eq!(source, ConfigDirSource::CliFlag);
1119 }
1120
1121 #[test]
1122 fn with_source_walk_up_or_default() {
1123 let _lock = ENV_MUTEX.lock().unwrap();
1124 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1125 let (path, source) = resolve_context_dir_with_source(None);
1126 assert!(
1128 path.ends_with(".omni-dev"),
1129 "expected path ending in .omni-dev, got {path:?}"
1130 );
1131 assert!(
1132 source == ConfigDirSource::WalkUp || source == ConfigDirSource::Default,
1133 "expected WalkUp or Default, got {source:?}"
1134 );
1135 }
1136
1137 #[test]
1140 fn with_source_at_cli_flag() {
1141 let _lock = ENV_MUTEX.lock().unwrap();
1142 let custom = PathBuf::from("custom-config");
1143 let (path, source) =
1144 resolve_context_dir_with_source_at(Some(&custom), std::path::Path::new("/unused"));
1145 assert_eq!(path, custom);
1146 assert_eq!(source, ConfigDirSource::CliFlag);
1147 }
1148
1149 #[test]
1150 fn with_source_at_env_var() {
1151 let _lock = ENV_MUTEX.lock().unwrap();
1152 std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1153 let (path, source) =
1154 resolve_context_dir_with_source_at(None, std::path::Path::new("/unused"));
1155 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1156 assert_eq!(path, PathBuf::from("/tmp/env-config"));
1157 assert_eq!(source, ConfigDirSource::EnvVar);
1158 }
1159
1160 #[test]
1161 fn with_source_at_default_anchors_to_repo_root() {
1162 let _lock = ENV_MUTEX.lock().unwrap();
1163 std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1164 let tmp = tempfile::tempdir().unwrap();
1169 std::fs::create_dir(tmp.path().join(".git")).unwrap();
1170 let (path, source) = resolve_context_dir_with_source_at(None, tmp.path());
1171 assert_eq!(path, tmp.path().join(".omni-dev"));
1172 assert_eq!(source, ConfigDirSource::Default);
1173 assert_eq!(
1175 resolve_context_dir_at(None, tmp.path()),
1176 tmp.path().join(".omni-dev")
1177 );
1178 }
1179
1180 #[test]
1183 fn display_config_dir_source_cli_flag() {
1184 assert_eq!(ConfigDirSource::CliFlag.to_string(), "--context-dir");
1185 }
1186
1187 #[test]
1188 fn display_config_dir_source_env_var() {
1189 assert_eq!(ConfigDirSource::EnvVar.to_string(), "OMNI_DEV_CONFIG_DIR");
1190 }
1191
1192 #[test]
1193 fn display_config_dir_source_walk_up() {
1194 assert_eq!(ConfigDirSource::WalkUp.to_string(), "walk-up");
1195 }
1196
1197 #[test]
1198 fn display_config_dir_source_default() {
1199 assert_eq!(ConfigDirSource::Default.to_string(), "default");
1200 }
1201
1202 #[test]
1205 fn load_config_content_reads_project_file() -> anyhow::Result<()> {
1206 let dir = {
1207 std::fs::create_dir_all("tmp")?;
1208 TempDir::new_in("tmp")?
1209 };
1210 let base = dir.path();
1211
1212 std::fs::write(
1213 base.join("commit-guidelines.md"),
1214 "# Guidelines\nBe concise.",
1215 )?;
1216
1217 let content = load_config_content(base, "commit-guidelines.md")?;
1218 assert_eq!(content, Some("# Guidelines\nBe concise.".to_string()));
1219 Ok(())
1220 }
1221
1222 #[test]
1223 fn load_config_content_prefers_local_override() -> anyhow::Result<()> {
1224 let dir = {
1225 std::fs::create_dir_all("tmp")?;
1226 TempDir::new_in("tmp")?
1227 };
1228 let base = dir.path();
1229
1230 std::fs::create_dir_all(base.join("local"))?;
1231 std::fs::write(base.join("local").join("guidelines.md"), "local content")?;
1232 std::fs::write(base.join("guidelines.md"), "project content")?;
1233
1234 let content = load_config_content(base, "guidelines.md")?;
1235 assert_eq!(content, Some("local content".to_string()));
1236 Ok(())
1237 }
1238
1239 #[test]
1240 fn load_config_content_returns_none_when_missing() -> anyhow::Result<()> {
1241 let dir = {
1242 std::fs::create_dir_all("tmp")?;
1243 TempDir::new_in("tmp")?
1244 };
1245
1246 let content = load_config_content(dir.path(), "nonexistent.md")?;
1247 assert_eq!(content, None);
1248 Ok(())
1249 }
1250
1251 #[test]
1254 fn source_label_local_override() -> anyhow::Result<()> {
1255 let dir = {
1256 std::fs::create_dir_all("tmp")?;
1257 TempDir::new_in("tmp")?
1258 };
1259 let base = dir.path();
1260
1261 std::fs::create_dir_all(base.join("local"))?;
1262 std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
1263 std::fs::write(base.join("scopes.yaml"), "project")?;
1264
1265 let label = config_source_label(base, "scopes.yaml");
1266 assert_eq!(
1267 label,
1268 ConfigSourceLabel::LocalOverride(base.join("local").join("scopes.yaml"))
1269 );
1270 Ok(())
1271 }
1272
1273 #[test]
1274 fn source_label_project() -> anyhow::Result<()> {
1275 let dir = {
1276 std::fs::create_dir_all("tmp")?;
1277 TempDir::new_in("tmp")?
1278 };
1279 let base = dir.path();
1280
1281 std::fs::write(base.join("scopes.yaml"), "project")?;
1282
1283 let label = config_source_label(base, "scopes.yaml");
1284 assert_eq!(label, ConfigSourceLabel::Project(base.join("scopes.yaml")));
1285 Ok(())
1286 }
1287
1288 #[test]
1289 fn source_label_not_found() {
1290 let dir = {
1291 std::fs::create_dir_all("tmp").ok();
1292 TempDir::new_in("tmp").unwrap()
1293 };
1294
1295 let label = config_source_label(dir.path(), "nonexistent.yaml");
1296 assert_eq!(label, ConfigSourceLabel::NotFound);
1297 }
1298
1299 #[test]
1302 fn display_local_override() {
1303 let label = ConfigSourceLabel::LocalOverride(PathBuf::from(".omni-dev/local/scopes.yaml"));
1304 assert_eq!(
1305 label.to_string(),
1306 "Local override: .omni-dev/local/scopes.yaml"
1307 );
1308 }
1309
1310 #[test]
1311 fn display_project() {
1312 let label = ConfigSourceLabel::Project(PathBuf::from(".omni-dev/scopes.yaml"));
1313 assert_eq!(label.to_string(), "Project: .omni-dev/scopes.yaml");
1314 }
1315
1316 #[test]
1317 fn display_global() {
1318 let label = ConfigSourceLabel::Global(PathBuf::from("/home/user/.omni-dev/scopes.yaml"));
1319 assert_eq!(
1320 label.to_string(),
1321 "Global: /home/user/.omni-dev/scopes.yaml"
1322 );
1323 }
1324
1325 #[test]
1326 fn display_xdg() {
1327 let label =
1328 ConfigSourceLabel::Xdg(PathBuf::from("/home/user/.config/omni-dev/scopes.yaml"));
1329 assert_eq!(
1330 label.to_string(),
1331 "Global (XDG): /home/user/.config/omni-dev/scopes.yaml"
1332 );
1333 }
1334
1335 #[test]
1336 fn display_not_found() {
1337 let label = ConfigSourceLabel::NotFound;
1338 assert_eq!(label.to_string(), "(not found)");
1339 }
1340
1341 #[test]
1344 fn xdg_config_dir_uses_env_var() {
1345 let _lock = ENV_MUTEX.lock().unwrap();
1346 std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-test");
1347 let result = xdg_config_dir();
1348 std::env::remove_var("XDG_CONFIG_HOME");
1349 assert_eq!(result, Some(PathBuf::from("/tmp/xdg-test/omni-dev")));
1350 }
1351
1352 #[test]
1353 fn xdg_config_dir_ignores_empty_env_var() {
1354 let _lock = ENV_MUTEX.lock().unwrap();
1355 std::env::set_var("XDG_CONFIG_HOME", "");
1356 let result = xdg_config_dir();
1357 std::env::remove_var("XDG_CONFIG_HOME");
1358 if let Some(home) = dirs::home_dir() {
1360 assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1361 }
1362 }
1363
1364 #[test]
1365 fn xdg_config_dir_defaults_to_home_config() {
1366 let _lock = ENV_MUTEX.lock().unwrap();
1367 std::env::remove_var("XDG_CONFIG_HOME");
1368 let result = xdg_config_dir();
1369 if let Some(home) = dirs::home_dir() {
1370 assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1371 }
1372 }
1373
1374 #[test]
1377 fn resolve_config_file_finds_xdg() -> anyhow::Result<()> {
1378 let _lock = ENV_MUTEX.lock().unwrap();
1379
1380 let xdg_dir = {
1381 std::fs::create_dir_all("tmp")?;
1382 TempDir::new_in("tmp")?
1383 };
1384 let xdg_omni = xdg_dir.path().join("omni-dev");
1385 std::fs::create_dir_all(&xdg_omni)?;
1386 std::fs::write(xdg_omni.join("commit-guidelines.md"), "xdg content")?;
1387
1388 std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1389 let project_dir = {
1390 std::fs::create_dir_all("tmp")?;
1391 TempDir::new_in("tmp")?
1392 };
1393 let resolved = resolve_config_file(project_dir.path(), "commit-guidelines.md");
1394 std::env::remove_var("XDG_CONFIG_HOME");
1395
1396 assert_eq!(resolved, xdg_omni.join("commit-guidelines.md"));
1397 Ok(())
1398 }
1399
1400 #[test]
1401 fn resolve_config_file_xdg_beats_home() -> anyhow::Result<()> {
1402 let _lock = ENV_MUTEX.lock().unwrap();
1403
1404 let xdg_dir = {
1406 std::fs::create_dir_all("tmp")?;
1407 TempDir::new_in("tmp")?
1408 };
1409 let xdg_omni = xdg_dir.path().join("omni-dev");
1410 std::fs::create_dir_all(&xdg_omni)?;
1411 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1412
1413 std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1414
1415 let project_dir = {
1417 std::fs::create_dir_all("tmp")?;
1418 TempDir::new_in("tmp")?
1419 };
1420
1421 let resolved = resolve_config_file(project_dir.path(), "scopes.yaml");
1422 std::env::remove_var("XDG_CONFIG_HOME");
1423
1424 assert_eq!(resolved, xdg_omni.join("scopes.yaml"));
1426 Ok(())
1427 }
1428
1429 #[test]
1430 fn resolve_config_file_project_beats_xdg() -> anyhow::Result<()> {
1431 let _lock = ENV_MUTEX.lock().unwrap();
1432
1433 let xdg_dir = {
1435 std::fs::create_dir_all("tmp")?;
1436 TempDir::new_in("tmp")?
1437 };
1438 let xdg_omni = xdg_dir.path().join("omni-dev");
1439 std::fs::create_dir_all(&xdg_omni)?;
1440 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1441
1442 std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1443
1444 let project_dir = {
1446 std::fs::create_dir_all("tmp")?;
1447 TempDir::new_in("tmp")?
1448 };
1449 std::fs::write(project_dir.path().join("scopes.yaml"), "project")?;
1450
1451 let resolved = resolve_config_file(project_dir.path(), "scopes.yaml");
1452 std::env::remove_var("XDG_CONFIG_HOME");
1453
1454 assert_eq!(resolved, project_dir.path().join("scopes.yaml"));
1456 Ok(())
1457 }
1458
1459 #[test]
1462 fn source_label_xdg() -> anyhow::Result<()> {
1463 let _lock = ENV_MUTEX.lock().unwrap();
1464
1465 let xdg_dir = {
1466 std::fs::create_dir_all("tmp")?;
1467 TempDir::new_in("tmp")?
1468 };
1469 let xdg_omni = xdg_dir.path().join("omni-dev");
1470 std::fs::create_dir_all(&xdg_omni)?;
1471 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1472
1473 std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1474
1475 let project_dir = {
1476 std::fs::create_dir_all("tmp")?;
1477 TempDir::new_in("tmp")?
1478 };
1479 let label = config_source_label(project_dir.path(), "scopes.yaml");
1480 std::env::remove_var("XDG_CONFIG_HOME");
1481
1482 assert_eq!(label, ConfigSourceLabel::Xdg(xdg_omni.join("scopes.yaml")));
1483 Ok(())
1484 }
1485
1486 fn make_repo_tree() -> anyhow::Result<TempDir> {
1491 let dir = {
1492 std::fs::create_dir_all("tmp")?;
1493 TempDir::new_in("tmp")?
1494 };
1495 std::fs::create_dir(dir.path().join(".git"))?;
1497 Ok(dir)
1498 }
1499
1500 #[test]
1501 fn walk_up_finds_omni_dev_in_start_dir() -> anyhow::Result<()> {
1502 let repo = make_repo_tree()?;
1503 let sub = repo.path().join("packages").join("frontend");
1504 std::fs::create_dir_all(&sub)?;
1505 std::fs::create_dir(sub.join(".omni-dev"))?;
1506
1507 let result = walk_up_find_config_dir(&sub);
1508 assert_eq!(result, Some(sub.join(".omni-dev")));
1509 Ok(())
1510 }
1511
1512 #[test]
1513 fn walk_up_finds_omni_dev_in_parent() -> anyhow::Result<()> {
1514 let repo = make_repo_tree()?;
1515 let pkg = repo.path().join("packages").join("frontend");
1516 let src = pkg.join("src");
1517 std::fs::create_dir_all(&src)?;
1518 std::fs::create_dir(pkg.join(".omni-dev"))?;
1519
1520 let result = walk_up_find_config_dir(&src);
1521 assert_eq!(result, Some(pkg.join(".omni-dev")));
1522 Ok(())
1523 }
1524
1525 #[test]
1526 fn walk_up_finds_omni_dev_at_repo_root() -> anyhow::Result<()> {
1527 let repo = make_repo_tree()?;
1528 let deep = repo.path().join("a").join("b").join("c");
1529 std::fs::create_dir_all(&deep)?;
1530 std::fs::create_dir(repo.path().join(".omni-dev"))?;
1531
1532 let result = walk_up_find_config_dir(&deep);
1533 assert_eq!(result, Some(repo.path().join(".omni-dev")));
1534 Ok(())
1535 }
1536
1537 #[test]
1538 fn walk_up_nearest_wins() -> anyhow::Result<()> {
1539 let repo = make_repo_tree()?;
1540 let pkg = repo.path().join("packages").join("frontend");
1541 let src = pkg.join("src");
1542 std::fs::create_dir_all(&src)?;
1543 std::fs::create_dir(repo.path().join(".omni-dev"))?;
1545 std::fs::create_dir(pkg.join(".omni-dev"))?;
1546
1547 let result = walk_up_find_config_dir(&src);
1548 assert_eq!(result, Some(pkg.join(".omni-dev")));
1550 Ok(())
1551 }
1552
1553 #[test]
1554 fn walk_up_stops_at_git_boundary() -> anyhow::Result<()> {
1555 let dir = {
1556 std::fs::create_dir_all("tmp")?;
1557 TempDir::new_in("tmp")?
1558 };
1559 std::fs::create_dir(dir.path().join(".omni-dev"))?;
1561 let repo_root = dir.path().join("repo");
1563 std::fs::create_dir_all(&repo_root)?;
1564 std::fs::create_dir(repo_root.join(".git"))?;
1565 let sub = repo_root.join("sub");
1566 std::fs::create_dir(&sub)?;
1567
1568 let result = walk_up_find_config_dir(&sub);
1569 assert_eq!(result, None);
1571 Ok(())
1572 }
1573
1574 #[test]
1575 fn walk_up_returns_none_when_no_omni_dev() -> anyhow::Result<()> {
1576 let repo = make_repo_tree()?;
1577 let sub = repo.path().join("src");
1578 std::fs::create_dir(&sub)?;
1579
1580 let result = walk_up_find_config_dir(&sub);
1581 assert_eq!(result, None);
1582 Ok(())
1583 }
1584
1585 #[test]
1586 fn walk_up_handles_git_worktree_file() -> anyhow::Result<()> {
1587 let dir = {
1588 std::fs::create_dir_all("tmp")?;
1589 TempDir::new_in("tmp")?
1590 };
1591 std::fs::write(dir.path().join(".git"), "gitdir: /some/path")?;
1593 std::fs::create_dir(dir.path().join(".omni-dev"))?;
1594 let sub = dir.path().join("src");
1595 std::fs::create_dir(&sub)?;
1596
1597 let result = walk_up_find_config_dir(&sub);
1598 assert_eq!(result, Some(dir.path().join(".omni-dev")));
1599 Ok(())
1600 }
1601
1602 #[test]
1603 fn walk_up_no_omni_dev_in_repo_returns_none() -> anyhow::Result<()> {
1604 let repo = make_repo_tree()?;
1606 let sub = repo.path().join("a").join("b");
1607 std::fs::create_dir_all(&sub)?;
1608 let result = walk_up_find_config_dir(&sub);
1609 assert_eq!(result, None);
1610 Ok(())
1611 }
1612}