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};
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 scopes_path = resolve_config_file(context_dir, "scopes.yaml");
323 let mut scopes = if scopes_path.exists() {
324 let scopes_yaml = match fs::read_to_string(&scopes_path) {
325 Ok(content) => content,
326 Err(e) => {
327 tracing::warn!("Cannot read scopes file {}: {e}", scopes_path.display());
328 return vec![];
329 }
330 };
331 match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
332 Ok(config) => config.scopes,
333 Err(e) => {
334 tracing::warn!(
335 "Ignoring malformed scopes file {}: {e}",
336 scopes_path.display()
337 );
338 vec![]
339 }
340 }
341 } else {
342 vec![]
343 };
344
345 merge_ecosystem_scopes(&mut scopes, repo_path);
346 scopes
347}
348
349fn merge_ecosystem_scopes(scopes: &mut Vec<ScopeDefinition>, repo_path: &Path) {
354 let ecosystem_scopes: Vec<(&str, &str, Vec<&str>)> = if repo_path.join("Cargo.toml").exists() {
355 vec![
356 (
357 "cargo",
358 "Cargo.toml and dependency management",
359 vec!["Cargo.toml", "Cargo.lock"],
360 ),
361 (
362 "lib",
363 "Library code and public API",
364 vec!["src/lib.rs", "src/**"],
365 ),
366 (
367 "cli",
368 "Command-line interface",
369 vec!["src/main.rs", "src/cli/**"],
370 ),
371 (
372 "core",
373 "Core application logic",
374 vec!["src/core/**", "src/lib/**"],
375 ),
376 ("test", "Test code", vec!["tests/**", "src/**/test*"]),
377 (
378 "docs",
379 "Documentation",
380 vec!["docs/**", "README.md", "**/*.md"],
381 ),
382 (
383 "ci",
384 "Continuous integration",
385 vec![".github/**", ".gitlab-ci.yml"],
386 ),
387 ]
388 } else if repo_path.join("package.json").exists() {
389 vec![
390 (
391 "deps",
392 "Dependencies and package.json",
393 vec!["package.json", "package-lock.json"],
394 ),
395 (
396 "config",
397 "Configuration files",
398 vec!["*.config.js", "*.config.json", ".env*"],
399 ),
400 (
401 "build",
402 "Build system and tooling",
403 vec!["webpack.config.js", "rollup.config.js"],
404 ),
405 (
406 "test",
407 "Test files",
408 vec!["test/**", "tests/**", "**/*.test.js"],
409 ),
410 (
411 "docs",
412 "Documentation",
413 vec!["docs/**", "README.md", "**/*.md"],
414 ),
415 ]
416 } else if repo_path.join("pyproject.toml").exists()
417 || repo_path.join("requirements.txt").exists()
418 {
419 vec![
420 (
421 "deps",
422 "Dependencies and requirements",
423 vec!["requirements.txt", "pyproject.toml", "setup.py"],
424 ),
425 (
426 "config",
427 "Configuration files",
428 vec!["*.ini", "*.cfg", "*.toml"],
429 ),
430 (
431 "test",
432 "Test files",
433 vec!["test/**", "tests/**", "**/*_test.py"],
434 ),
435 (
436 "docs",
437 "Documentation",
438 vec!["docs/**", "README.md", "**/*.md", "**/*.rst"],
439 ),
440 ]
441 } else if repo_path.join("go.mod").exists() {
442 vec![
443 (
444 "mod",
445 "Go modules and dependencies",
446 vec!["go.mod", "go.sum"],
447 ),
448 ("cmd", "Command-line applications", vec!["cmd/**"]),
449 ("pkg", "Library packages", vec!["pkg/**"]),
450 ("internal", "Internal packages", vec!["internal/**"]),
451 ("test", "Test files", vec!["**/*_test.go"]),
452 (
453 "docs",
454 "Documentation",
455 vec!["docs/**", "README.md", "**/*.md"],
456 ),
457 ]
458 } else if repo_path.join("pom.xml").exists() || repo_path.join("build.gradle").exists() {
459 vec![
460 (
461 "build",
462 "Build system",
463 vec!["pom.xml", "build.gradle", "build.gradle.kts"],
464 ),
465 (
466 "config",
467 "Configuration",
468 vec!["src/main/resources/**", "application.properties"],
469 ),
470 ("test", "Test files", vec!["src/test/**"]),
471 (
472 "docs",
473 "Documentation",
474 vec!["docs/**", "README.md", "**/*.md"],
475 ),
476 ]
477 } else {
478 vec![]
479 };
480
481 for (name, description, patterns) in ecosystem_scopes {
482 if !scopes.iter().any(|s| s.name == name) {
483 scopes.push(ScopeDefinition {
484 name: name.to_string(),
485 description: description.to_string(),
486 examples: vec![],
487 file_patterns: patterns.into_iter().map(String::from).collect(),
488 });
489 }
490 }
491}
492
493pub struct ProjectDiscovery {
495 repo_path: PathBuf,
496 context_dir: PathBuf,
497}
498
499impl ProjectDiscovery {
500 pub fn new(repo_path: PathBuf, context_dir: PathBuf) -> Self {
502 Self {
503 repo_path,
504 context_dir,
505 }
506 }
507
508 pub fn discover(&self) -> Result<ProjectContext> {
510 let mut context = ProjectContext::default();
511
512 let context_dir_path = if self.context_dir.is_absolute() {
514 self.context_dir.clone()
515 } else {
516 self.repo_path.join(&self.context_dir)
517 };
518 debug!(
519 context_dir = ?context_dir_path,
520 exists = context_dir_path.exists(),
521 "Looking for context directory"
522 );
523 debug!("Loading omni-dev config");
524 self.load_omni_dev_config(&mut context, &context_dir_path)?;
525 debug!("Config loading completed");
526
527 self.load_git_config(&mut context)?;
529
530 self.parse_documentation(&mut context)?;
532
533 self.detect_ecosystem(&mut context)?;
535
536 Ok(context)
537 }
538
539 fn load_omni_dev_config(&self, context: &mut ProjectContext, dir: &Path) -> Result<()> {
541 let guidelines_path = resolve_config_file(dir, "commit-guidelines.md");
543 debug!(
544 path = ?guidelines_path,
545 exists = guidelines_path.exists(),
546 "Checking for commit guidelines"
547 );
548 if guidelines_path.exists() {
549 let content = fs::read_to_string(&guidelines_path)?;
550 debug!(bytes = content.len(), "Loaded commit guidelines");
551 context.commit_guidelines = Some(content);
552 } else {
553 debug!("No commit guidelines file found");
554 }
555
556 let pr_guidelines_path = resolve_config_file(dir, "pr-guidelines.md");
558 debug!(
559 path = ?pr_guidelines_path,
560 exists = pr_guidelines_path.exists(),
561 "Checking for PR guidelines"
562 );
563 if pr_guidelines_path.exists() {
564 let content = fs::read_to_string(&pr_guidelines_path)?;
565 debug!(bytes = content.len(), "Loaded PR guidelines");
566 context.pr_guidelines = Some(content);
567 } else {
568 debug!("No PR guidelines file found");
569 }
570
571 let scopes_path = resolve_config_file(dir, "scopes.yaml");
573 if scopes_path.exists() {
574 let scopes_yaml = fs::read_to_string(&scopes_path)?;
575 match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
576 Ok(scopes_config) => {
577 context.valid_scopes = scopes_config.scopes;
578 }
579 Err(e) => {
580 tracing::warn!(
581 "Ignoring malformed scopes file {}: {e}",
582 scopes_path.display()
583 );
584 }
585 }
586 }
587
588 let local_contexts_dir = dir.join("local").join("context").join("feature-contexts");
590 let contexts_dir = dir.join("context").join("feature-contexts");
591
592 if contexts_dir.exists() {
594 self.load_feature_contexts(context, &contexts_dir)?;
595 }
596
597 if local_contexts_dir.exists() {
599 self.load_feature_contexts(context, &local_contexts_dir)?;
600 }
601
602 Ok(())
603 }
604
605 fn load_git_config(&self, _context: &mut ProjectContext) -> Result<()> {
607 Ok(())
609 }
610
611 fn parse_documentation(&self, context: &mut ProjectContext) -> Result<()> {
613 let contributing_path = self.repo_path.join("CONTRIBUTING.md");
615 if contributing_path.exists() {
616 let content = fs::read_to_string(contributing_path)?;
617 context.project_conventions = self.parse_contributing_conventions(&content)?;
618 }
619
620 let readme_path = self.repo_path.join("README.md");
622 if readme_path.exists() {
623 let content = fs::read_to_string(readme_path)?;
624 self.parse_readme_conventions(context, &content)?;
625 }
626
627 Ok(())
628 }
629
630 fn detect_ecosystem(&self, context: &mut ProjectContext) -> Result<()> {
632 context.ecosystem = if self.repo_path.join("Cargo.toml").exists() {
633 Ecosystem::Rust
634 } else if self.repo_path.join("package.json").exists() {
635 Ecosystem::Node
636 } else if self.repo_path.join("pyproject.toml").exists()
637 || self.repo_path.join("requirements.txt").exists()
638 {
639 Ecosystem::Python
640 } else if self.repo_path.join("go.mod").exists() {
641 Ecosystem::Go
642 } else if self.repo_path.join("pom.xml").exists()
643 || self.repo_path.join("build.gradle").exists()
644 {
645 Ecosystem::Java
646 } else {
647 Ecosystem::Generic
648 };
649
650 merge_ecosystem_scopes(&mut context.valid_scopes, &self.repo_path);
651
652 Ok(())
653 }
654
655 fn load_feature_contexts(
657 &self,
658 context: &mut ProjectContext,
659 contexts_dir: &Path,
660 ) -> Result<()> {
661 let entries = match fs::read_dir(contexts_dir) {
662 Ok(entries) => entries,
663 Err(e) => {
664 tracing::warn!(
665 "Cannot read feature contexts dir {}: {e}",
666 contexts_dir.display()
667 );
668 return Ok(());
669 }
670 };
671 for entry in entries.flatten() {
672 if let Some(name) = entry.file_name().to_str() {
673 if name.ends_with(".yaml") || name.ends_with(".yml") {
674 let content = fs::read_to_string(entry.path())?;
675 match serde_yaml::from_str::<FeatureContext>(&content) {
676 Ok(feature_context) => {
677 let feature_name = name
678 .trim_end_matches(".yaml")
679 .trim_end_matches(".yml")
680 .to_string();
681 context
682 .feature_contexts
683 .insert(feature_name, feature_context);
684 }
685 Err(e) => {
686 tracing::warn!(
687 "Ignoring malformed feature context {}: {e}",
688 entry.path().display()
689 );
690 }
691 }
692 }
693 }
694 }
695 Ok(())
696 }
697
698 fn parse_contributing_conventions(&self, content: &str) -> Result<ProjectConventions> {
700 let mut conventions = ProjectConventions::default();
701
702 let lines: Vec<&str> = content.lines().collect();
704 let mut in_commit_section = false;
705
706 for (i, line) in lines.iter().enumerate() {
707 let line_lower = line.to_lowercase();
708
709 if line_lower.contains("commit")
711 && (line_lower.contains("message") || line_lower.contains("format"))
712 {
713 in_commit_section = true;
714 continue;
715 }
716
717 if in_commit_section && line.starts_with('#') && !line_lower.contains("commit") {
719 in_commit_section = false;
720 }
721
722 if in_commit_section {
723 if line.contains("type(scope):") || line.contains("<type>(<scope>):") {
725 conventions.commit_format = Some("type(scope): description".to_string());
726 }
727
728 if line_lower.contains("signed-off-by") {
730 conventions
731 .required_trailers
732 .push("Signed-off-by".to_string());
733 }
734
735 if line_lower.contains("fixes") && line_lower.contains('#') {
736 conventions.required_trailers.push("Fixes".to_string());
737 }
738
739 if line.contains("feat") || line.contains("fix") || line.contains("docs") {
741 let types = extract_commit_types(line);
742 conventions.preferred_types.extend(types);
743 }
744
745 if line_lower.contains("scope") && i + 1 < lines.len() {
747 let scope_requirements = self.extract_scope_requirements(&lines[i..]);
748 conventions.scope_requirements = scope_requirements;
749 }
750 }
751 }
752
753 Ok(conventions)
754 }
755
756 fn parse_readme_conventions(&self, context: &mut ProjectContext, content: &str) -> Result<()> {
758 let lines: Vec<&str> = content.lines().collect();
760
761 for line in lines {
762 let _line_lower = line.to_lowercase();
763
764 if line.contains("src/") || line.contains("lib/") {
766 if let Some(scope) = extract_scope_from_structure(line) {
768 context.valid_scopes.push(ScopeDefinition {
769 name: scope.clone(),
770 description: format!("{scope} related changes"),
771 examples: vec![],
772 file_patterns: vec![format!("{}/**", scope)],
773 });
774 }
775 }
776 }
777
778 Ok(())
779 }
780
781 fn extract_scope_requirements(&self, lines: &[&str]) -> ScopeRequirements {
783 let mut requirements = ScopeRequirements::default();
784
785 for line in lines.iter().take(10) {
786 if line.starts_with("##") {
788 break;
789 }
790
791 let line_lower = line.to_lowercase();
792
793 if line_lower.contains("required") || line_lower.contains("must") {
794 requirements.required = true;
795 }
796
797 if line.contains(':')
799 && (line.contains("auth") || line.contains("api") || line.contains("ui"))
800 {
801 let scopes = extract_scopes_from_examples(line);
802 requirements.valid_scopes.extend(scopes);
803 }
804 }
805
806 requirements
807 }
808}
809
810#[derive(serde::Deserialize)]
812struct ScopesConfig {
813 scopes: Vec<ScopeDefinition>,
814}
815
816fn extract_commit_types(line: &str) -> Vec<String> {
818 let mut types = Vec::new();
819 let common_types = [
820 "feat", "fix", "docs", "style", "refactor", "test", "chore", "ci", "build", "perf",
821 ];
822
823 for &type_str in &common_types {
824 if line.to_lowercase().contains(type_str) {
825 types.push(type_str.to_string());
826 }
827 }
828
829 types
830}
831
832fn extract_scope_from_structure(line: &str) -> Option<String> {
834 if let Some(start) = line.find("src/") {
836 let after_src = &line[start + 4..];
837 if let Some(end) = after_src.find('/') {
838 return Some(after_src[..end].to_string());
839 }
840 }
841
842 None
843}
844
845fn extract_scopes_from_examples(line: &str) -> Vec<String> {
847 let mut scopes = Vec::new();
848 let common_scopes = ["auth", "api", "ui", "db", "config", "core", "cli", "web"];
849
850 for &scope in &common_scopes {
851 if line.to_lowercase().contains(scope) {
852 scopes.push(scope.to_string());
853 }
854 }
855
856 scopes
857}
858
859#[cfg(test)]
860#[allow(clippy::unwrap_used, clippy::expect_used)]
861mod tests {
862 use super::*;
863 use crate::test_support::env::MapEnv;
864 use tempfile::TempDir;
865
866 #[test]
869 fn local_override_wins() -> anyhow::Result<()> {
870 let dir = {
871 std::fs::create_dir_all("tmp")?;
872 TempDir::new_in("tmp")?
873 };
874 let base = dir.path();
875
876 std::fs::create_dir_all(base.join("local"))?;
878 std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
879 std::fs::write(base.join("scopes.yaml"), "project")?;
880
881 let resolved = resolve_config_file(base, "scopes.yaml");
882 assert_eq!(resolved, base.join("local").join("scopes.yaml"));
883 Ok(())
884 }
885
886 #[test]
887 fn project_fallback() -> anyhow::Result<()> {
888 let dir = {
889 std::fs::create_dir_all("tmp")?;
890 TempDir::new_in("tmp")?
891 };
892 let base = dir.path();
893
894 std::fs::write(base.join("scopes.yaml"), "project")?;
896
897 let resolved = resolve_config_file(base, "scopes.yaml");
898 assert_eq!(resolved, base.join("scopes.yaml"));
899 Ok(())
900 }
901
902 #[test]
903 fn returns_default_when_nothing_exists() {
904 let dir = {
905 std::fs::create_dir_all("tmp").ok();
906 TempDir::new_in("tmp").unwrap()
907 };
908 let base = dir.path();
909
910 let resolved = resolve_config_file(base, "scopes.yaml");
911 assert_ne!(resolved, base.join("local").join("scopes.yaml"));
916 }
917
918 #[test]
921 fn rust_ecosystem_detected() -> anyhow::Result<()> {
922 let dir = {
923 std::fs::create_dir_all("tmp")?;
924 TempDir::new_in("tmp")?
925 };
926 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
927
928 let mut scopes = vec![];
929 merge_ecosystem_scopes(&mut scopes, dir.path());
930
931 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
932 assert!(names.contains(&"cargo"), "missing 'cargo' scope");
933 assert!(names.contains(&"cli"), "missing 'cli' scope");
934 assert!(names.contains(&"core"), "missing 'core' scope");
935 assert!(names.contains(&"test"), "missing 'test' scope");
936 assert!(names.contains(&"docs"), "missing 'docs' scope");
937 assert!(names.contains(&"ci"), "missing 'ci' scope");
938 Ok(())
939 }
940
941 #[test]
942 fn node_ecosystem_detected() -> anyhow::Result<()> {
943 let dir = {
944 std::fs::create_dir_all("tmp")?;
945 TempDir::new_in("tmp")?
946 };
947 std::fs::write(dir.path().join("package.json"), "{}")?;
948
949 let mut scopes = vec![];
950 merge_ecosystem_scopes(&mut scopes, dir.path());
951
952 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
953 assert!(names.contains(&"deps"), "missing 'deps' scope");
954 assert!(names.contains(&"config"), "missing 'config' scope");
955 Ok(())
956 }
957
958 #[test]
959 fn go_ecosystem_detected() -> anyhow::Result<()> {
960 let dir = {
961 std::fs::create_dir_all("tmp")?;
962 TempDir::new_in("tmp")?
963 };
964 std::fs::write(dir.path().join("go.mod"), "module example")?;
965
966 let mut scopes = vec![];
967 merge_ecosystem_scopes(&mut scopes, dir.path());
968
969 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
970 assert!(names.contains(&"mod"), "missing 'mod' scope");
971 assert!(names.contains(&"cmd"), "missing 'cmd' scope");
972 assert!(names.contains(&"pkg"), "missing 'pkg' scope");
973 Ok(())
974 }
975
976 #[test]
977 fn existing_scope_not_overridden() -> anyhow::Result<()> {
978 let dir = {
979 std::fs::create_dir_all("tmp")?;
980 TempDir::new_in("tmp")?
981 };
982 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
983
984 let mut scopes = vec![ScopeDefinition {
985 name: "cli".to_string(),
986 description: "Custom CLI scope".to_string(),
987 examples: vec![],
988 file_patterns: vec!["custom/**".to_string()],
989 }];
990 merge_ecosystem_scopes(&mut scopes, dir.path());
991
992 let cli_scope = scopes.iter().find(|s| s.name == "cli").unwrap();
994 assert_eq!(cli_scope.description, "Custom CLI scope");
995 assert_eq!(cli_scope.file_patterns, vec!["custom/**"]);
996 Ok(())
997 }
998
999 #[test]
1000 fn no_marker_files_produces_empty() {
1001 let dir = {
1002 std::fs::create_dir_all("tmp").ok();
1003 TempDir::new_in("tmp").unwrap()
1004 };
1005 let mut scopes = vec![];
1006 merge_ecosystem_scopes(&mut scopes, dir.path());
1007 assert!(scopes.is_empty());
1008 }
1009
1010 #[test]
1013 fn load_project_scopes_with_yaml() -> anyhow::Result<()> {
1014 let dir = {
1015 std::fs::create_dir_all("tmp")?;
1016 TempDir::new_in("tmp")?
1017 };
1018 let config_dir = dir.path().join("config");
1019 std::fs::create_dir_all(&config_dir)?;
1020
1021 let scopes_yaml = r#"
1022scopes:
1023 - name: custom
1024 description: Custom scope
1025 examples: []
1026 file_patterns:
1027 - "src/custom/**"
1028"#;
1029 std::fs::write(config_dir.join("scopes.yaml"), scopes_yaml)?;
1030
1031 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1033
1034 let scopes = load_project_scopes(&config_dir, dir.path());
1035 let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
1036 assert!(names.contains(&"custom"), "missing custom scope");
1037 assert!(names.contains(&"cargo"), "missing ecosystem scope");
1039 Ok(())
1040 }
1041
1042 #[test]
1043 fn load_project_scopes_no_file() -> anyhow::Result<()> {
1044 let dir = {
1045 std::fs::create_dir_all("tmp")?;
1046 TempDir::new_in("tmp")?
1047 };
1048 std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1049
1050 let scopes = load_project_scopes(dir.path(), dir.path());
1051 assert!(!scopes.is_empty());
1053 Ok(())
1054 }
1055
1056 #[test]
1059 fn extract_scope_from_structure_src() {
1060 assert_eq!(
1061 extract_scope_from_structure("- `src/auth/` - Authentication"),
1062 Some("auth".to_string())
1063 );
1064 }
1065
1066 #[test]
1067 fn extract_scope_from_structure_no_match() {
1068 assert_eq!(extract_scope_from_structure("No source paths here"), None);
1069 }
1070
1071 #[test]
1072 fn extract_commit_types_from_line() {
1073 let types = extract_commit_types("feat, fix, docs, test");
1074 assert!(types.contains(&"feat".to_string()));
1075 assert!(types.contains(&"fix".to_string()));
1076 assert!(types.contains(&"docs".to_string()));
1077 assert!(types.contains(&"test".to_string()));
1078 }
1079
1080 #[test]
1081 fn extract_commit_types_empty_line() {
1082 let types = extract_commit_types("no types here");
1083 assert!(types.is_empty());
1084 }
1085
1086 #[test]
1093 fn context_dir_defaults_to_omni_dev() {
1094 let result = resolve_context_dir_with_source_env(None, &MapEnv::new()).0;
1095 assert!(
1097 result.ends_with(".omni-dev"),
1098 "expected path ending in .omni-dev, got {result:?}"
1099 );
1100 }
1101
1102 #[test]
1103 fn context_dir_uses_override() {
1104 let custom = PathBuf::from("custom-config");
1105 let result = resolve_context_dir_with_source_env(Some(&custom), &MapEnv::new()).0;
1106 assert_eq!(result, custom);
1107 }
1108
1109 #[test]
1110 fn context_dir_env_var() {
1111 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/my-config");
1112 let result = resolve_context_dir_with_source_env(None, &env).0;
1113 assert_eq!(result, PathBuf::from("/tmp/my-config"));
1114 }
1115
1116 #[test]
1117 fn context_dir_cli_flag_beats_env_var() {
1118 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1119 let cli = PathBuf::from("cli-config");
1120 let result = resolve_context_dir_with_source_env(Some(&cli), &env).0;
1121 assert_eq!(result, cli);
1122 }
1123
1124 #[test]
1125 fn context_dir_ignores_empty_env_var() {
1126 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "");
1127 let result = resolve_context_dir_with_source_env(None, &env).0;
1128 assert!(
1130 result.ends_with(".omni-dev"),
1131 "expected path ending in .omni-dev, got {result:?}"
1132 );
1133 }
1134
1135 #[test]
1138 fn with_source_cli_flag() {
1139 let custom = PathBuf::from("custom-config");
1140 let (path, source) = resolve_context_dir_with_source_env(Some(&custom), &MapEnv::new());
1141 assert_eq!(path, custom);
1142 assert_eq!(source, ConfigDirSource::CliFlag);
1143 }
1144
1145 #[test]
1146 fn with_source_env_var() {
1147 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1148 let (path, source) = resolve_context_dir_with_source_env(None, &env);
1149 assert_eq!(path, PathBuf::from("/tmp/env-config"));
1150 assert_eq!(source, ConfigDirSource::EnvVar);
1151 }
1152
1153 #[test]
1154 fn with_source_cli_beats_env() {
1155 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1156 let custom = PathBuf::from("cli-config");
1157 let (path, source) = resolve_context_dir_with_source_env(Some(&custom), &env);
1158 assert_eq!(path, custom);
1159 assert_eq!(source, ConfigDirSource::CliFlag);
1160 }
1161
1162 #[test]
1163 fn with_source_walk_up_or_default() {
1164 let (path, source) = resolve_context_dir_with_source_env(None, &MapEnv::new());
1165 assert!(
1167 path.ends_with(".omni-dev"),
1168 "expected path ending in .omni-dev, got {path:?}"
1169 );
1170 assert!(
1171 source == ConfigDirSource::WalkUp || source == ConfigDirSource::Default,
1172 "expected WalkUp or Default, got {source:?}"
1173 );
1174 }
1175
1176 #[test]
1179 fn with_source_at_cli_flag() {
1180 let custom = PathBuf::from("custom-config");
1181 let (path, source) = resolve_context_dir_with_source_at_env(
1182 Some(&custom),
1183 std::path::Path::new("/unused"),
1184 &MapEnv::new(),
1185 );
1186 assert_eq!(path, custom);
1187 assert_eq!(source, ConfigDirSource::CliFlag);
1188 }
1189
1190 #[test]
1191 fn with_source_at_env_var() {
1192 let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1193 let (path, source) =
1194 resolve_context_dir_with_source_at_env(None, std::path::Path::new("/unused"), &env);
1195 assert_eq!(path, PathBuf::from("/tmp/env-config"));
1196 assert_eq!(source, ConfigDirSource::EnvVar);
1197 }
1198
1199 #[test]
1200 fn with_source_at_default_anchors_to_repo_root() {
1201 let tmp = tempfile::tempdir().unwrap();
1206 std::fs::create_dir(tmp.path().join(".git")).unwrap();
1207 let (path, source) =
1208 resolve_context_dir_with_source_at_env(None, tmp.path(), &MapEnv::new());
1209 assert_eq!(path, tmp.path().join(".omni-dev"));
1210 assert_eq!(source, ConfigDirSource::Default);
1211 assert_eq!(
1215 resolve_context_dir_at(None, tmp.path()),
1216 tmp.path().join(".omni-dev")
1217 );
1218 }
1219
1220 #[test]
1223 fn display_config_dir_source_cli_flag() {
1224 assert_eq!(ConfigDirSource::CliFlag.to_string(), "--context-dir");
1225 }
1226
1227 #[test]
1228 fn display_config_dir_source_env_var() {
1229 assert_eq!(ConfigDirSource::EnvVar.to_string(), "OMNI_DEV_CONFIG_DIR");
1230 }
1231
1232 #[test]
1233 fn display_config_dir_source_walk_up() {
1234 assert_eq!(ConfigDirSource::WalkUp.to_string(), "walk-up");
1235 }
1236
1237 #[test]
1238 fn display_config_dir_source_default() {
1239 assert_eq!(ConfigDirSource::Default.to_string(), "default");
1240 }
1241
1242 #[test]
1245 fn load_config_content_reads_project_file() -> anyhow::Result<()> {
1246 let dir = {
1247 std::fs::create_dir_all("tmp")?;
1248 TempDir::new_in("tmp")?
1249 };
1250 let base = dir.path();
1251
1252 std::fs::write(
1253 base.join("commit-guidelines.md"),
1254 "# Guidelines\nBe concise.",
1255 )?;
1256
1257 let content = load_config_content(base, "commit-guidelines.md")?;
1258 assert_eq!(content, Some("# Guidelines\nBe concise.".to_string()));
1259 Ok(())
1260 }
1261
1262 #[test]
1263 fn load_config_content_prefers_local_override() -> anyhow::Result<()> {
1264 let dir = {
1265 std::fs::create_dir_all("tmp")?;
1266 TempDir::new_in("tmp")?
1267 };
1268 let base = dir.path();
1269
1270 std::fs::create_dir_all(base.join("local"))?;
1271 std::fs::write(base.join("local").join("guidelines.md"), "local content")?;
1272 std::fs::write(base.join("guidelines.md"), "project content")?;
1273
1274 let content = load_config_content(base, "guidelines.md")?;
1275 assert_eq!(content, Some("local content".to_string()));
1276 Ok(())
1277 }
1278
1279 #[test]
1280 fn load_config_content_returns_none_when_missing() -> anyhow::Result<()> {
1281 let dir = {
1282 std::fs::create_dir_all("tmp")?;
1283 TempDir::new_in("tmp")?
1284 };
1285
1286 let content = load_config_content(dir.path(), "nonexistent.md")?;
1287 assert_eq!(content, None);
1288 Ok(())
1289 }
1290
1291 #[test]
1294 fn source_label_local_override() -> anyhow::Result<()> {
1295 let dir = {
1296 std::fs::create_dir_all("tmp")?;
1297 TempDir::new_in("tmp")?
1298 };
1299 let base = dir.path();
1300
1301 std::fs::create_dir_all(base.join("local"))?;
1302 std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
1303 std::fs::write(base.join("scopes.yaml"), "project")?;
1304
1305 let label = config_source_label(base, "scopes.yaml");
1306 assert_eq!(
1307 label,
1308 ConfigSourceLabel::LocalOverride(base.join("local").join("scopes.yaml"))
1309 );
1310 Ok(())
1311 }
1312
1313 #[test]
1314 fn source_label_project() -> anyhow::Result<()> {
1315 let dir = {
1316 std::fs::create_dir_all("tmp")?;
1317 TempDir::new_in("tmp")?
1318 };
1319 let base = dir.path();
1320
1321 std::fs::write(base.join("scopes.yaml"), "project")?;
1322
1323 let label = config_source_label(base, "scopes.yaml");
1324 assert_eq!(label, ConfigSourceLabel::Project(base.join("scopes.yaml")));
1325 Ok(())
1326 }
1327
1328 #[test]
1329 fn source_label_not_found() {
1330 let dir = {
1331 std::fs::create_dir_all("tmp").ok();
1332 TempDir::new_in("tmp").unwrap()
1333 };
1334
1335 let label = config_source_label(dir.path(), "nonexistent.yaml");
1336 assert_eq!(label, ConfigSourceLabel::NotFound);
1337 }
1338
1339 #[test]
1342 fn display_local_override() {
1343 let label = ConfigSourceLabel::LocalOverride(PathBuf::from(".omni-dev/local/scopes.yaml"));
1344 assert_eq!(
1345 label.to_string(),
1346 "Local override: .omni-dev/local/scopes.yaml"
1347 );
1348 }
1349
1350 #[test]
1351 fn display_project() {
1352 let label = ConfigSourceLabel::Project(PathBuf::from(".omni-dev/scopes.yaml"));
1353 assert_eq!(label.to_string(), "Project: .omni-dev/scopes.yaml");
1354 }
1355
1356 #[test]
1357 fn display_global() {
1358 let label = ConfigSourceLabel::Global(PathBuf::from("/home/user/.omni-dev/scopes.yaml"));
1359 assert_eq!(
1360 label.to_string(),
1361 "Global: /home/user/.omni-dev/scopes.yaml"
1362 );
1363 }
1364
1365 #[test]
1366 fn display_xdg() {
1367 let label =
1368 ConfigSourceLabel::Xdg(PathBuf::from("/home/user/.config/omni-dev/scopes.yaml"));
1369 assert_eq!(
1370 label.to_string(),
1371 "Global (XDG): /home/user/.config/omni-dev/scopes.yaml"
1372 );
1373 }
1374
1375 #[test]
1376 fn display_not_found() {
1377 let label = ConfigSourceLabel::NotFound;
1378 assert_eq!(label.to_string(), "(not found)");
1379 }
1380
1381 #[test]
1384 fn xdg_config_dir_uses_env_var() {
1385 let env = MapEnv::new().with("XDG_CONFIG_HOME", "/tmp/xdg-test");
1386 let result = xdg_config_dir_with(&env, None);
1387 assert_eq!(result, Some(PathBuf::from("/tmp/xdg-test/omni-dev")));
1388 }
1389
1390 #[test]
1391 fn xdg_config_dir_ignores_empty_env_var() {
1392 let env = MapEnv::new().with("XDG_CONFIG_HOME", "");
1394 let home = Path::new("/test-home");
1395 let result = xdg_config_dir_with(&env, Some(home));
1396 assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1397 }
1398
1399 #[test]
1400 fn xdg_config_dir_defaults_to_home_config() {
1401 let home = Path::new("/test-home");
1403 let result = xdg_config_dir_with(&MapEnv::new(), Some(home));
1404 assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1405 }
1406
1407 fn xdg_env(path: &Path) -> MapEnv {
1412 MapEnv::new().with(
1413 "XDG_CONFIG_HOME",
1414 path.to_str().expect("temp path is valid UTF-8"),
1415 )
1416 }
1417
1418 #[test]
1419 fn resolve_config_file_finds_xdg() -> anyhow::Result<()> {
1420 let xdg_dir = {
1421 std::fs::create_dir_all("tmp")?;
1422 TempDir::new_in("tmp")?
1423 };
1424 let xdg_omni = xdg_dir.path().join("omni-dev");
1425 std::fs::create_dir_all(&xdg_omni)?;
1426 std::fs::write(xdg_omni.join("commit-guidelines.md"), "xdg content")?;
1427
1428 let project_dir = {
1429 std::fs::create_dir_all("tmp")?;
1430 TempDir::new_in("tmp")?
1431 };
1432 let resolved = resolve_config_file_with(
1433 project_dir.path(),
1434 "commit-guidelines.md",
1435 &xdg_env(xdg_dir.path()),
1436 None,
1437 );
1438
1439 assert_eq!(resolved, xdg_omni.join("commit-guidelines.md"));
1440 Ok(())
1441 }
1442
1443 #[test]
1444 fn resolve_config_file_xdg_beats_home() -> anyhow::Result<()> {
1445 let xdg_dir = {
1447 std::fs::create_dir_all("tmp")?;
1448 TempDir::new_in("tmp")?
1449 };
1450 let xdg_omni = xdg_dir.path().join("omni-dev");
1451 std::fs::create_dir_all(&xdg_omni)?;
1452 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1453
1454 let project_dir = {
1456 std::fs::create_dir_all("tmp")?;
1457 TempDir::new_in("tmp")?
1458 };
1459
1460 let resolved = resolve_config_file_with(
1461 project_dir.path(),
1462 "scopes.yaml",
1463 &xdg_env(xdg_dir.path()),
1464 None,
1465 );
1466
1467 assert_eq!(resolved, xdg_omni.join("scopes.yaml"));
1469 Ok(())
1470 }
1471
1472 #[test]
1473 fn resolve_config_file_project_beats_xdg() -> anyhow::Result<()> {
1474 let xdg_dir = {
1476 std::fs::create_dir_all("tmp")?;
1477 TempDir::new_in("tmp")?
1478 };
1479 let xdg_omni = xdg_dir.path().join("omni-dev");
1480 std::fs::create_dir_all(&xdg_omni)?;
1481 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1482
1483 let project_dir = {
1485 std::fs::create_dir_all("tmp")?;
1486 TempDir::new_in("tmp")?
1487 };
1488 std::fs::write(project_dir.path().join("scopes.yaml"), "project")?;
1489
1490 let resolved = resolve_config_file_with(
1491 project_dir.path(),
1492 "scopes.yaml",
1493 &xdg_env(xdg_dir.path()),
1494 None,
1495 );
1496
1497 assert_eq!(resolved, project_dir.path().join("scopes.yaml"));
1499 Ok(())
1500 }
1501
1502 #[test]
1505 fn source_label_xdg() -> anyhow::Result<()> {
1506 let xdg_dir = {
1507 std::fs::create_dir_all("tmp")?;
1508 TempDir::new_in("tmp")?
1509 };
1510 let xdg_omni = xdg_dir.path().join("omni-dev");
1511 std::fs::create_dir_all(&xdg_omni)?;
1512 std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1513
1514 let project_dir = {
1515 std::fs::create_dir_all("tmp")?;
1516 TempDir::new_in("tmp")?
1517 };
1518 let label = config_source_label_with(
1519 project_dir.path(),
1520 "scopes.yaml",
1521 &xdg_env(xdg_dir.path()),
1522 None,
1523 );
1524
1525 assert_eq!(label, ConfigSourceLabel::Xdg(xdg_omni.join("scopes.yaml")));
1526 Ok(())
1527 }
1528
1529 fn make_repo_tree() -> anyhow::Result<TempDir> {
1534 let dir = {
1535 std::fs::create_dir_all("tmp")?;
1536 TempDir::new_in("tmp")?
1537 };
1538 std::fs::create_dir(dir.path().join(".git"))?;
1540 Ok(dir)
1541 }
1542
1543 #[test]
1544 fn walk_up_finds_omni_dev_in_start_dir() -> anyhow::Result<()> {
1545 let repo = make_repo_tree()?;
1546 let sub = repo.path().join("packages").join("frontend");
1547 std::fs::create_dir_all(&sub)?;
1548 std::fs::create_dir(sub.join(".omni-dev"))?;
1549
1550 let result = walk_up_find_config_dir(&sub);
1551 assert_eq!(result, Some(sub.join(".omni-dev")));
1552 Ok(())
1553 }
1554
1555 #[test]
1556 fn walk_up_finds_omni_dev_in_parent() -> anyhow::Result<()> {
1557 let repo = make_repo_tree()?;
1558 let pkg = repo.path().join("packages").join("frontend");
1559 let src = pkg.join("src");
1560 std::fs::create_dir_all(&src)?;
1561 std::fs::create_dir(pkg.join(".omni-dev"))?;
1562
1563 let result = walk_up_find_config_dir(&src);
1564 assert_eq!(result, Some(pkg.join(".omni-dev")));
1565 Ok(())
1566 }
1567
1568 #[test]
1569 fn walk_up_finds_omni_dev_at_repo_root() -> anyhow::Result<()> {
1570 let repo = make_repo_tree()?;
1571 let deep = repo.path().join("a").join("b").join("c");
1572 std::fs::create_dir_all(&deep)?;
1573 std::fs::create_dir(repo.path().join(".omni-dev"))?;
1574
1575 let result = walk_up_find_config_dir(&deep);
1576 assert_eq!(result, Some(repo.path().join(".omni-dev")));
1577 Ok(())
1578 }
1579
1580 #[test]
1581 fn walk_up_nearest_wins() -> anyhow::Result<()> {
1582 let repo = make_repo_tree()?;
1583 let pkg = repo.path().join("packages").join("frontend");
1584 let src = pkg.join("src");
1585 std::fs::create_dir_all(&src)?;
1586 std::fs::create_dir(repo.path().join(".omni-dev"))?;
1588 std::fs::create_dir(pkg.join(".omni-dev"))?;
1589
1590 let result = walk_up_find_config_dir(&src);
1591 assert_eq!(result, Some(pkg.join(".omni-dev")));
1593 Ok(())
1594 }
1595
1596 #[test]
1597 fn walk_up_stops_at_git_boundary() -> anyhow::Result<()> {
1598 let dir = {
1599 std::fs::create_dir_all("tmp")?;
1600 TempDir::new_in("tmp")?
1601 };
1602 std::fs::create_dir(dir.path().join(".omni-dev"))?;
1604 let repo_root = dir.path().join("repo");
1606 std::fs::create_dir_all(&repo_root)?;
1607 std::fs::create_dir(repo_root.join(".git"))?;
1608 let sub = repo_root.join("sub");
1609 std::fs::create_dir(&sub)?;
1610
1611 let result = walk_up_find_config_dir(&sub);
1612 assert_eq!(result, None);
1614 Ok(())
1615 }
1616
1617 #[test]
1618 fn walk_up_returns_none_when_no_omni_dev() -> anyhow::Result<()> {
1619 let repo = make_repo_tree()?;
1620 let sub = repo.path().join("src");
1621 std::fs::create_dir(&sub)?;
1622
1623 let result = walk_up_find_config_dir(&sub);
1624 assert_eq!(result, None);
1625 Ok(())
1626 }
1627
1628 #[test]
1629 fn walk_up_handles_git_worktree_file() -> anyhow::Result<()> {
1630 let dir = {
1631 std::fs::create_dir_all("tmp")?;
1632 TempDir::new_in("tmp")?
1633 };
1634 std::fs::write(dir.path().join(".git"), "gitdir: /some/path")?;
1636 std::fs::create_dir(dir.path().join(".omni-dev"))?;
1637 let sub = dir.path().join("src");
1638 std::fs::create_dir(&sub)?;
1639
1640 let result = walk_up_find_config_dir(&sub);
1641 assert_eq!(result, Some(dir.path().join(".omni-dev")));
1642 Ok(())
1643 }
1644
1645 #[test]
1646 fn walk_up_no_omni_dev_in_repo_returns_none() -> anyhow::Result<()> {
1647 let repo = make_repo_tree()?;
1649 let sub = repo.path().join("a").join("b");
1650 std::fs::create_dir_all(&sub)?;
1651 let result = walk_up_find_config_dir(&sub);
1652 assert_eq!(result, None);
1653 Ok(())
1654 }
1655}