Skip to main content

omni_dev/claude/context/
discovery.rs

1//! Project context discovery system.
2
3use 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
16/// Returns the XDG-compliant config directory for omni-dev.
17///
18/// Uses `$XDG_CONFIG_HOME/omni-dev/` if the variable is set, otherwise
19/// defaults to `$HOME/.config/omni-dev/` per the XDG Base Directory
20/// Specification. Returns `None` if neither can be determined.
21///
22/// Uses `std::env::var` directly rather than `dirs::config_dir()`, which
23/// returns `~/Library/Application Support/` on macOS — not the expected
24/// location for a CLI tool.
25fn 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    // Default: $HOME/.config/omni-dev/
33    dirs::home_dir().map(|home| home.join(".config").join("omni-dev"))
34}
35
36/// Resolves configuration file path with local override support and global fallback.
37///
38/// Priority:
39/// 1. `{dir}/local/{filename}` (local override)
40/// 2. `{dir}/{filename}` (shared project config)
41/// 3. `$XDG_CONFIG_HOME/omni-dev/{filename}` (XDG global config)
42/// 4. `$HOME/.omni-dev/{filename}` (legacy global fallback)
43pub 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    // Check XDG config directory
55    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    // Check legacy home directory fallback
63    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    // Return project path as default (even if it doesn't exist)
71    project_path
72}
73
74/// Walks up from `start` toward the repository root, looking for `.omni-dev/`.
75///
76/// Returns the first `.omni-dev/` directory found. Stops at the repository
77/// root (identified by a `.git` directory or file). Returns `None` if no
78/// `.omni-dev/` is found within the repository boundary.
79fn 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        // Stop at repo root — don't escape the repository
87        if current.join(".git").exists() {
88            break;
89        }
90        if !current.pop() {
91            break;
92        }
93    }
94    None
95}
96
97/// Identifies how the context directory was resolved.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum ConfigDirSource {
100    /// Explicitly set via `--context-dir` CLI flag.
101    CliFlag,
102    /// Set via `OMNI_DEV_CONFIG_DIR` environment variable.
103    EnvVar,
104    /// Found via walk-up discovery from CWD.
105    WalkUp,
106    /// Default `.omni-dev` (no explicit override, no walk-up match).
107    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
121/// Resolves the context directory and reports how it was selected.
122///
123/// Priority:
124/// 1. `override_dir` (from `--context-dir` CLI flag; disables walk-up)
125/// 2. `OMNI_DEV_CONFIG_DIR` environment variable (disables walk-up)
126/// 3. Walk-up: nearest `.omni-dev/` from CWD to repo root
127/// 4. `.omni-dev` default
128pub 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    // Walk-up discovery: search from CWD upward to repo root
140    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
149/// Resolves the context directory from an optional CLI override.
150///
151/// Convenience wrapper around [`resolve_context_dir_with_source`] that
152/// discards the source information.
153pub fn resolve_context_dir(override_dir: Option<&Path>) -> PathBuf {
154    resolve_context_dir_with_source(override_dir).0
155}
156
157/// Like [`resolve_context_dir_with_source`], but anchored to an explicit
158/// `repo_root` instead of the process current working directory.
159///
160/// The override and `OMNI_DEV_CONFIG_DIR` tiers behave identically; only the
161/// walk-up start and the default fall back to `repo_root` rather than the CWD,
162/// so a command run with an injected `--repo` discovers config under that repo.
163pub 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    // Walk-up discovery: search from the injected repo root upward.
178    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
185/// Like [`resolve_context_dir`], but anchored to an explicit `repo_root`.
186pub 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
190/// Loads a config file's content via the standard resolution chain.
191///
192/// Uses [`resolve_config_file`] to find the file, then reads its content.
193/// Returns `Ok(None)` if no file exists at any tier.
194pub 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/// Identifies which resolution tier a config file was found in.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub enum ConfigSourceLabel {
208    /// Found in `{dir}/local/{filename}`.
209    LocalOverride(PathBuf),
210    /// Found in `{dir}/{filename}`.
211    Project(PathBuf),
212    /// Found in `$XDG_CONFIG_HOME/omni-dev/{filename}`.
213    Xdg(PathBuf),
214    /// Found in `$HOME/.omni-dev/{filename}`.
215    Global(PathBuf),
216    /// Not found at any tier.
217    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
232/// Returns the source tier for a config file (for diagnostic display).
233///
234/// Checks each tier in priority order and returns the first match.
235/// Does not read file content — only checks existence.
236pub 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
264/// Loads project scopes from config files, merging ecosystem defaults.
265///
266/// Resolves `scopes.yaml` via the standard config priority (local → project → home),
267/// then detects the project ecosystem and merges default scopes for that ecosystem.
268pub 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
296/// Merges ecosystem-detected default scopes into the given scope list.
297///
298/// Detects the project ecosystem from marker files (Cargo.toml, package.json, etc.)
299/// and adds default scopes for that ecosystem, skipping any that already exist by name.
300fn 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
440/// Project context discovery system.
441pub struct ProjectDiscovery {
442    repo_path: PathBuf,
443    context_dir: PathBuf,
444}
445
446impl ProjectDiscovery {
447    /// Creates a new project discovery instance.
448    pub fn new(repo_path: PathBuf, context_dir: PathBuf) -> Self {
449        Self {
450            repo_path,
451            context_dir,
452        }
453    }
454
455    /// Discovers all project context.
456    pub fn discover(&self) -> Result<ProjectContext> {
457        let mut context = ProjectContext::default();
458
459        // 1. Check custom context directory (highest priority)
460        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        // 2. Standard git configuration files
475        self.load_git_config(&mut context)?;
476
477        // 3. Parse project documentation
478        self.parse_documentation(&mut context)?;
479
480        // 4. Detect ecosystem conventions
481        self.detect_ecosystem(&mut context)?;
482
483        Ok(context)
484    }
485
486    /// Loads configuration from .omni-dev/ directory with local override support.
487    fn load_omni_dev_config(&self, context: &mut ProjectContext, dir: &Path) -> Result<()> {
488        // Load commit guidelines (with local override)
489        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        // Load PR guidelines (with local override)
504        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        // Load scopes configuration (with local override)
519        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        // Load feature contexts (check both local and standard directories)
536        let local_contexts_dir = dir.join("local").join("context").join("feature-contexts");
537        let contexts_dir = dir.join("context").join("feature-contexts");
538
539        // Load standard feature contexts first
540        if contexts_dir.exists() {
541            self.load_feature_contexts(context, &contexts_dir)?;
542        }
543
544        // Load local feature contexts (will override if same name)
545        if local_contexts_dir.exists() {
546            self.load_feature_contexts(context, &local_contexts_dir)?;
547        }
548
549        Ok(())
550    }
551
552    /// Loads git configuration files.
553    fn load_git_config(&self, _context: &mut ProjectContext) -> Result<()> {
554        // Git configuration loading can be extended here if needed
555        Ok(())
556    }
557
558    /// Parses project documentation for conventions.
559    fn parse_documentation(&self, context: &mut ProjectContext) -> Result<()> {
560        // Parse CONTRIBUTING.md
561        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        // Parse README.md for additional conventions
568        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    /// Detects project ecosystem and applies conventions.
578    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    /// Loads feature contexts from a directory.
603    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    /// Parses CONTRIBUTING.md for conventions.
646    fn parse_contributing_conventions(&self, content: &str) -> Result<ProjectConventions> {
647        let mut conventions = ProjectConventions::default();
648
649        // Look for commit message sections
650        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            // Detect commit message sections
657            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            // End commit section if we hit another header
665            if in_commit_section && line.starts_with('#') && !line_lower.contains("commit") {
666                in_commit_section = false;
667            }
668
669            if in_commit_section {
670                // Extract commit format examples
671                if line.contains("type(scope):") || line.contains("<type>(<scope>):") {
672                    conventions.commit_format = Some("type(scope): description".to_string());
673                }
674
675                // Extract required trailers
676                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                // Extract preferred types
687                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                // Look ahead for scope examples
693                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    /// Parses README.md for additional conventions.
704    fn parse_readme_conventions(&self, context: &mut ProjectContext, content: &str) -> Result<()> {
705        // Look for development or contribution sections
706        let lines: Vec<&str> = content.lines().collect();
707
708        for line in lines {
709            let _line_lower = line.to_lowercase();
710
711            // Extract additional scope information from project structure
712            if line.contains("src/") || line.contains("lib/") {
713                // Try to extract scope information from directory structure mentions
714                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    /// Extracts scope requirements from contributing documentation.
729    fn extract_scope_requirements(&self, lines: &[&str]) -> ScopeRequirements {
730        let mut requirements = ScopeRequirements::default();
731
732        for line in lines.iter().take(10) {
733            // Stop at next major section
734            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            // Extract scope examples
745            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/// Configuration structure for scopes.yaml.
758#[derive(serde::Deserialize)]
759struct ScopesConfig {
760    scopes: Vec<ScopeDefinition>,
761}
762
763/// Extracts commit types from a line.
764fn 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
779/// Extracts a scope from a project structure description.
780fn extract_scope_from_structure(line: &str) -> Option<String> {
781    // Look for patterns like "src/auth/", "lib/config/", etc.
782    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
792/// Extracts scopes from examples in documentation.
793fn 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    // ── resolve_config_file ──────────────────────────────────────────
813
814    #[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        // Create both local and project files
823        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        // Create only project-level file (no local/)
841        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        // When no local or project file exists, it either returns:
858        // - the home directory path if $HOME/.omni-dev/scopes.yaml exists
859        // - the project path as fallback default
860        // Either way, the resolved path should NOT be the local override path.
861        assert_ne!(resolved, base.join("local").join("scopes.yaml"));
862    }
863
864    // ── merge_ecosystem_scopes ───────────────────────────────────────
865
866    #[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        // The custom "cli" scope should be preserved, not replaced
939        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    // ── load_project_scopes ──────────────────────────────────────────
957
958    #[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        // Also create Cargo.toml so ecosystem scopes get merged
978        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        // Ecosystem scopes should also be merged
984        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        // Should still get ecosystem defaults
998        assert!(!scopes.is_empty());
999        Ok(())
1000    }
1001
1002    // ── Helper functions ─────────────────────────────────────────────
1003
1004    #[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    // ── resolve_context_dir ────────────────────────────────────────────
1033
1034    // Use a mutex to serialize tests that modify OMNI_DEV_CONFIG_DIR.
1035    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        // Walk-up may find .omni-dev in the real repo, or fall back to ".omni-dev"
1043        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        // Walk-up may find .omni-dev in the real repo, or fall back to ".omni-dev"
1083        assert!(
1084            result.ends_with(".omni-dev"),
1085            "expected path ending in .omni-dev, got {result:?}"
1086        );
1087    }
1088
1089    // ── resolve_context_dir_with_source ─────────────────────────────────
1090
1091    #[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        // Inside this repo, walk-up finds .omni-dev; outside, falls back to default
1127        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    // ── repo-anchored `_at` variants (#967) ──────────────────────────────
1138
1139    #[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        // A repo root with a `.git` boundary but no `.omni-dev`: walk-up stops at
1165        // the boundary without escaping, so the default anchors to
1166        // `repo_root/.omni-dev` (NOT the CWD-relative `.omni-dev`). This is the
1167        // distinguishing behavior of the `_at` variant vs. its CWD sibling.
1168        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        // The thin wrapper returns the same path, discarding the source.
1174        assert_eq!(
1175            resolve_context_dir_at(None, tmp.path()),
1176            tmp.path().join(".omni-dev")
1177        );
1178    }
1179
1180    // ── ConfigDirSource Display ──────────────────────────────────────────
1181
1182    #[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    // ── load_config_content ────────────────────────────────────────────
1203
1204    #[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    // ── config_source_label ────────────────────────────────────────────
1252
1253    #[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    // ── ConfigSourceLabel Display ──────────────────────────────────────
1300
1301    #[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    // ── xdg_config_dir ─────────────────────────────────────────────────
1342
1343    #[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        // Falls back to $HOME/.config/omni-dev
1359        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    // ── resolve_config_file XDG integration ─────────────────────────────
1375
1376    #[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        // Set up XDG config
1405        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        // Project dir with no local config
1416        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        // XDG path should win (home path only wins if XDG doesn't have the file)
1425        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        // Set up XDG config
1434        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        // Project dir with project-level config
1445        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        // Project path should win over XDG
1455        assert_eq!(resolved, project_dir.path().join("scopes.yaml"));
1456        Ok(())
1457    }
1458
1459    // ── config_source_label XDG integration ────────────────────────────
1460
1461    #[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    // ── walk_up_find_config_dir ─────────────────────────────────────────
1487
1488    /// Creates a mock repo tree with `.git` at the root.
1489    /// Returns (root_dir, TempDir handle).
1490    fn make_repo_tree() -> anyhow::Result<TempDir> {
1491        let dir = {
1492            std::fs::create_dir_all("tmp")?;
1493            TempDir::new_in("tmp")?
1494        };
1495        // Create .git marker at root
1496        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        // Both root and package have .omni-dev
1544        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        // Nearest (packages/frontend/.omni-dev) should win
1549        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        // Parent has .omni-dev but is outside the repo
1560        std::fs::create_dir(dir.path().join(".omni-dev"))?;
1561        // Repo root is a subdirectory
1562        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        // Should NOT find the .omni-dev above .git
1570        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        // .git as a file (worktree)
1592        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        // Repo with .git but no .omni-dev anywhere
1605        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}