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/// Loads a config file's content via the standard resolution chain.
158///
159/// Uses [`resolve_config_file`] to find the file, then reads its content.
160/// Returns `Ok(None)` if no file exists at any tier.
161pub fn load_config_content(dir: &Path, filename: &str) -> Result<Option<String>> {
162    let path = resolve_config_file(dir, filename);
163    if path.exists() {
164        let content = fs::read_to_string(&path)
165            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
166        Ok(Some(content))
167    } else {
168        Ok(None)
169    }
170}
171
172/// Identifies which resolution tier a config file was found in.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum ConfigSourceLabel {
175    /// Found in `{dir}/local/{filename}`.
176    LocalOverride(PathBuf),
177    /// Found in `{dir}/{filename}`.
178    Project(PathBuf),
179    /// Found in `$XDG_CONFIG_HOME/omni-dev/{filename}`.
180    Xdg(PathBuf),
181    /// Found in `$HOME/.omni-dev/{filename}`.
182    Global(PathBuf),
183    /// Not found at any tier.
184    NotFound,
185}
186
187impl fmt::Display for ConfigSourceLabel {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        match self {
190            Self::LocalOverride(p) => write!(f, "Local override: {}", p.display()),
191            Self::Project(p) => write!(f, "Project: {}", p.display()),
192            Self::Xdg(p) => write!(f, "Global (XDG): {}", p.display()),
193            Self::Global(p) => write!(f, "Global: {}", p.display()),
194            Self::NotFound => write!(f, "(not found)"),
195        }
196    }
197}
198
199/// Returns the source tier for a config file (for diagnostic display).
200///
201/// Checks each tier in priority order and returns the first match.
202/// Does not read file content — only checks existence.
203pub fn config_source_label(dir: &Path, filename: &str) -> ConfigSourceLabel {
204    let local_path = dir.join("local").join(filename);
205    if local_path.exists() {
206        return ConfigSourceLabel::LocalOverride(local_path);
207    }
208
209    let project_path = dir.join(filename);
210    if project_path.exists() {
211        return ConfigSourceLabel::Project(project_path);
212    }
213
214    if let Some(xdg_dir) = xdg_config_dir() {
215        let xdg_path = xdg_dir.join(filename);
216        if xdg_path.exists() {
217            return ConfigSourceLabel::Xdg(xdg_path);
218        }
219    }
220
221    if let Some(home_dir) = dirs::home_dir() {
222        let home_path = home_dir.join(".omni-dev").join(filename);
223        if home_path.exists() {
224            return ConfigSourceLabel::Global(home_path);
225        }
226    }
227
228    ConfigSourceLabel::NotFound
229}
230
231/// Loads project scopes from config files, merging ecosystem defaults.
232///
233/// Resolves `scopes.yaml` via the standard config priority (local → project → home),
234/// then detects the project ecosystem and merges default scopes for that ecosystem.
235pub fn load_project_scopes(context_dir: &Path, repo_path: &Path) -> Vec<ScopeDefinition> {
236    let scopes_path = resolve_config_file(context_dir, "scopes.yaml");
237    let mut scopes = if scopes_path.exists() {
238        let scopes_yaml = match fs::read_to_string(&scopes_path) {
239            Ok(content) => content,
240            Err(e) => {
241                tracing::warn!("Cannot read scopes file {}: {e}", scopes_path.display());
242                return vec![];
243            }
244        };
245        match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
246            Ok(config) => config.scopes,
247            Err(e) => {
248                tracing::warn!(
249                    "Ignoring malformed scopes file {}: {e}",
250                    scopes_path.display()
251                );
252                vec![]
253            }
254        }
255    } else {
256        vec![]
257    };
258
259    merge_ecosystem_scopes(&mut scopes, repo_path);
260    scopes
261}
262
263/// Merges ecosystem-detected default scopes into the given scope list.
264///
265/// Detects the project ecosystem from marker files (Cargo.toml, package.json, etc.)
266/// and adds default scopes for that ecosystem, skipping any that already exist by name.
267fn merge_ecosystem_scopes(scopes: &mut Vec<ScopeDefinition>, repo_path: &Path) {
268    let ecosystem_scopes: Vec<(&str, &str, Vec<&str>)> = if repo_path.join("Cargo.toml").exists() {
269        vec![
270            (
271                "cargo",
272                "Cargo.toml and dependency management",
273                vec!["Cargo.toml", "Cargo.lock"],
274            ),
275            (
276                "lib",
277                "Library code and public API",
278                vec!["src/lib.rs", "src/**"],
279            ),
280            (
281                "cli",
282                "Command-line interface",
283                vec!["src/main.rs", "src/cli/**"],
284            ),
285            (
286                "core",
287                "Core application logic",
288                vec!["src/core/**", "src/lib/**"],
289            ),
290            ("test", "Test code", vec!["tests/**", "src/**/test*"]),
291            (
292                "docs",
293                "Documentation",
294                vec!["docs/**", "README.md", "**/*.md"],
295            ),
296            (
297                "ci",
298                "Continuous integration",
299                vec![".github/**", ".gitlab-ci.yml"],
300            ),
301        ]
302    } else if repo_path.join("package.json").exists() {
303        vec![
304            (
305                "deps",
306                "Dependencies and package.json",
307                vec!["package.json", "package-lock.json"],
308            ),
309            (
310                "config",
311                "Configuration files",
312                vec!["*.config.js", "*.config.json", ".env*"],
313            ),
314            (
315                "build",
316                "Build system and tooling",
317                vec!["webpack.config.js", "rollup.config.js"],
318            ),
319            (
320                "test",
321                "Test files",
322                vec!["test/**", "tests/**", "**/*.test.js"],
323            ),
324            (
325                "docs",
326                "Documentation",
327                vec!["docs/**", "README.md", "**/*.md"],
328            ),
329        ]
330    } else if repo_path.join("pyproject.toml").exists()
331        || repo_path.join("requirements.txt").exists()
332    {
333        vec![
334            (
335                "deps",
336                "Dependencies and requirements",
337                vec!["requirements.txt", "pyproject.toml", "setup.py"],
338            ),
339            (
340                "config",
341                "Configuration files",
342                vec!["*.ini", "*.cfg", "*.toml"],
343            ),
344            (
345                "test",
346                "Test files",
347                vec!["test/**", "tests/**", "**/*_test.py"],
348            ),
349            (
350                "docs",
351                "Documentation",
352                vec!["docs/**", "README.md", "**/*.md", "**/*.rst"],
353            ),
354        ]
355    } else if repo_path.join("go.mod").exists() {
356        vec![
357            (
358                "mod",
359                "Go modules and dependencies",
360                vec!["go.mod", "go.sum"],
361            ),
362            ("cmd", "Command-line applications", vec!["cmd/**"]),
363            ("pkg", "Library packages", vec!["pkg/**"]),
364            ("internal", "Internal packages", vec!["internal/**"]),
365            ("test", "Test files", vec!["**/*_test.go"]),
366            (
367                "docs",
368                "Documentation",
369                vec!["docs/**", "README.md", "**/*.md"],
370            ),
371        ]
372    } else if repo_path.join("pom.xml").exists() || repo_path.join("build.gradle").exists() {
373        vec![
374            (
375                "build",
376                "Build system",
377                vec!["pom.xml", "build.gradle", "build.gradle.kts"],
378            ),
379            (
380                "config",
381                "Configuration",
382                vec!["src/main/resources/**", "application.properties"],
383            ),
384            ("test", "Test files", vec!["src/test/**"]),
385            (
386                "docs",
387                "Documentation",
388                vec!["docs/**", "README.md", "**/*.md"],
389            ),
390        ]
391    } else {
392        vec![]
393    };
394
395    for (name, description, patterns) in ecosystem_scopes {
396        if !scopes.iter().any(|s| s.name == name) {
397            scopes.push(ScopeDefinition {
398                name: name.to_string(),
399                description: description.to_string(),
400                examples: vec![],
401                file_patterns: patterns.into_iter().map(String::from).collect(),
402            });
403        }
404    }
405}
406
407/// Project context discovery system.
408pub struct ProjectDiscovery {
409    repo_path: PathBuf,
410    context_dir: PathBuf,
411}
412
413impl ProjectDiscovery {
414    /// Creates a new project discovery instance.
415    pub fn new(repo_path: PathBuf, context_dir: PathBuf) -> Self {
416        Self {
417            repo_path,
418            context_dir,
419        }
420    }
421
422    /// Discovers all project context.
423    pub fn discover(&self) -> Result<ProjectContext> {
424        let mut context = ProjectContext::default();
425
426        // 1. Check custom context directory (highest priority)
427        let context_dir_path = if self.context_dir.is_absolute() {
428            self.context_dir.clone()
429        } else {
430            self.repo_path.join(&self.context_dir)
431        };
432        debug!(
433            context_dir = ?context_dir_path,
434            exists = context_dir_path.exists(),
435            "Looking for context directory"
436        );
437        debug!("Loading omni-dev config");
438        self.load_omni_dev_config(&mut context, &context_dir_path)?;
439        debug!("Config loading completed");
440
441        // 2. Standard git configuration files
442        self.load_git_config(&mut context)?;
443
444        // 3. Parse project documentation
445        self.parse_documentation(&mut context)?;
446
447        // 4. Detect ecosystem conventions
448        self.detect_ecosystem(&mut context)?;
449
450        Ok(context)
451    }
452
453    /// Loads configuration from .omni-dev/ directory with local override support.
454    fn load_omni_dev_config(&self, context: &mut ProjectContext, dir: &Path) -> Result<()> {
455        // Load commit guidelines (with local override)
456        let guidelines_path = resolve_config_file(dir, "commit-guidelines.md");
457        debug!(
458            path = ?guidelines_path,
459            exists = guidelines_path.exists(),
460            "Checking for commit guidelines"
461        );
462        if guidelines_path.exists() {
463            let content = fs::read_to_string(&guidelines_path)?;
464            debug!(bytes = content.len(), "Loaded commit guidelines");
465            context.commit_guidelines = Some(content);
466        } else {
467            debug!("No commit guidelines file found");
468        }
469
470        // Load PR guidelines (with local override)
471        let pr_guidelines_path = resolve_config_file(dir, "pr-guidelines.md");
472        debug!(
473            path = ?pr_guidelines_path,
474            exists = pr_guidelines_path.exists(),
475            "Checking for PR guidelines"
476        );
477        if pr_guidelines_path.exists() {
478            let content = fs::read_to_string(&pr_guidelines_path)?;
479            debug!(bytes = content.len(), "Loaded PR guidelines");
480            context.pr_guidelines = Some(content);
481        } else {
482            debug!("No PR guidelines file found");
483        }
484
485        // Load scopes configuration (with local override)
486        let scopes_path = resolve_config_file(dir, "scopes.yaml");
487        if scopes_path.exists() {
488            let scopes_yaml = fs::read_to_string(&scopes_path)?;
489            match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
490                Ok(scopes_config) => {
491                    context.valid_scopes = scopes_config.scopes;
492                }
493                Err(e) => {
494                    tracing::warn!(
495                        "Ignoring malformed scopes file {}: {e}",
496                        scopes_path.display()
497                    );
498                }
499            }
500        }
501
502        // Load feature contexts (check both local and standard directories)
503        let local_contexts_dir = dir.join("local").join("context").join("feature-contexts");
504        let contexts_dir = dir.join("context").join("feature-contexts");
505
506        // Load standard feature contexts first
507        if contexts_dir.exists() {
508            self.load_feature_contexts(context, &contexts_dir)?;
509        }
510
511        // Load local feature contexts (will override if same name)
512        if local_contexts_dir.exists() {
513            self.load_feature_contexts(context, &local_contexts_dir)?;
514        }
515
516        Ok(())
517    }
518
519    /// Loads git configuration files.
520    fn load_git_config(&self, _context: &mut ProjectContext) -> Result<()> {
521        // Git configuration loading can be extended here if needed
522        Ok(())
523    }
524
525    /// Parses project documentation for conventions.
526    fn parse_documentation(&self, context: &mut ProjectContext) -> Result<()> {
527        // Parse CONTRIBUTING.md
528        let contributing_path = self.repo_path.join("CONTRIBUTING.md");
529        if contributing_path.exists() {
530            let content = fs::read_to_string(contributing_path)?;
531            context.project_conventions = self.parse_contributing_conventions(&content)?;
532        }
533
534        // Parse README.md for additional conventions
535        let readme_path = self.repo_path.join("README.md");
536        if readme_path.exists() {
537            let content = fs::read_to_string(readme_path)?;
538            self.parse_readme_conventions(context, &content)?;
539        }
540
541        Ok(())
542    }
543
544    /// Detects project ecosystem and applies conventions.
545    fn detect_ecosystem(&self, context: &mut ProjectContext) -> Result<()> {
546        context.ecosystem = if self.repo_path.join("Cargo.toml").exists() {
547            Ecosystem::Rust
548        } else if self.repo_path.join("package.json").exists() {
549            Ecosystem::Node
550        } else if self.repo_path.join("pyproject.toml").exists()
551            || self.repo_path.join("requirements.txt").exists()
552        {
553            Ecosystem::Python
554        } else if self.repo_path.join("go.mod").exists() {
555            Ecosystem::Go
556        } else if self.repo_path.join("pom.xml").exists()
557            || self.repo_path.join("build.gradle").exists()
558        {
559            Ecosystem::Java
560        } else {
561            Ecosystem::Generic
562        };
563
564        merge_ecosystem_scopes(&mut context.valid_scopes, &self.repo_path);
565
566        Ok(())
567    }
568
569    /// Loads feature contexts from a directory.
570    fn load_feature_contexts(
571        &self,
572        context: &mut ProjectContext,
573        contexts_dir: &Path,
574    ) -> Result<()> {
575        let entries = match fs::read_dir(contexts_dir) {
576            Ok(entries) => entries,
577            Err(e) => {
578                tracing::warn!(
579                    "Cannot read feature contexts dir {}: {e}",
580                    contexts_dir.display()
581                );
582                return Ok(());
583            }
584        };
585        for entry in entries.flatten() {
586            if let Some(name) = entry.file_name().to_str() {
587                if name.ends_with(".yaml") || name.ends_with(".yml") {
588                    let content = fs::read_to_string(entry.path())?;
589                    match serde_yaml::from_str::<FeatureContext>(&content) {
590                        Ok(feature_context) => {
591                            let feature_name = name
592                                .trim_end_matches(".yaml")
593                                .trim_end_matches(".yml")
594                                .to_string();
595                            context
596                                .feature_contexts
597                                .insert(feature_name, feature_context);
598                        }
599                        Err(e) => {
600                            tracing::warn!(
601                                "Ignoring malformed feature context {}: {e}",
602                                entry.path().display()
603                            );
604                        }
605                    }
606                }
607            }
608        }
609        Ok(())
610    }
611
612    /// Parses CONTRIBUTING.md for conventions.
613    fn parse_contributing_conventions(&self, content: &str) -> Result<ProjectConventions> {
614        let mut conventions = ProjectConventions::default();
615
616        // Look for commit message sections
617        let lines: Vec<&str> = content.lines().collect();
618        let mut in_commit_section = false;
619
620        for (i, line) in lines.iter().enumerate() {
621            let line_lower = line.to_lowercase();
622
623            // Detect commit message sections
624            if line_lower.contains("commit")
625                && (line_lower.contains("message") || line_lower.contains("format"))
626            {
627                in_commit_section = true;
628                continue;
629            }
630
631            // End commit section if we hit another header
632            if in_commit_section && line.starts_with('#') && !line_lower.contains("commit") {
633                in_commit_section = false;
634            }
635
636            if in_commit_section {
637                // Extract commit format examples
638                if line.contains("type(scope):") || line.contains("<type>(<scope>):") {
639                    conventions.commit_format = Some("type(scope): description".to_string());
640                }
641
642                // Extract required trailers
643                if line_lower.contains("signed-off-by") {
644                    conventions
645                        .required_trailers
646                        .push("Signed-off-by".to_string());
647                }
648
649                if line_lower.contains("fixes") && line_lower.contains('#') {
650                    conventions.required_trailers.push("Fixes".to_string());
651                }
652
653                // Extract preferred types
654                if line.contains("feat") || line.contains("fix") || line.contains("docs") {
655                    let types = extract_commit_types(line);
656                    conventions.preferred_types.extend(types);
657                }
658
659                // Look ahead for scope examples
660                if line_lower.contains("scope") && i + 1 < lines.len() {
661                    let scope_requirements = self.extract_scope_requirements(&lines[i..]);
662                    conventions.scope_requirements = scope_requirements;
663                }
664            }
665        }
666
667        Ok(conventions)
668    }
669
670    /// Parses README.md for additional conventions.
671    fn parse_readme_conventions(&self, context: &mut ProjectContext, content: &str) -> Result<()> {
672        // Look for development or contribution sections
673        let lines: Vec<&str> = content.lines().collect();
674
675        for line in lines {
676            let _line_lower = line.to_lowercase();
677
678            // Extract additional scope information from project structure
679            if line.contains("src/") || line.contains("lib/") {
680                // Try to extract scope information from directory structure mentions
681                if let Some(scope) = extract_scope_from_structure(line) {
682                    context.valid_scopes.push(ScopeDefinition {
683                        name: scope.clone(),
684                        description: format!("{scope} related changes"),
685                        examples: vec![],
686                        file_patterns: vec![format!("{}/**", scope)],
687                    });
688                }
689            }
690        }
691
692        Ok(())
693    }
694
695    /// Extracts scope requirements from contributing documentation.
696    fn extract_scope_requirements(&self, lines: &[&str]) -> ScopeRequirements {
697        let mut requirements = ScopeRequirements::default();
698
699        for line in lines.iter().take(10) {
700            // Stop at next major section
701            if line.starts_with("##") {
702                break;
703            }
704
705            let line_lower = line.to_lowercase();
706
707            if line_lower.contains("required") || line_lower.contains("must") {
708                requirements.required = true;
709            }
710
711            // Extract scope examples
712            if line.contains(':')
713                && (line.contains("auth") || line.contains("api") || line.contains("ui"))
714            {
715                let scopes = extract_scopes_from_examples(line);
716                requirements.valid_scopes.extend(scopes);
717            }
718        }
719
720        requirements
721    }
722}
723
724/// Configuration structure for scopes.yaml.
725#[derive(serde::Deserialize)]
726struct ScopesConfig {
727    scopes: Vec<ScopeDefinition>,
728}
729
730/// Extracts commit types from a line.
731fn extract_commit_types(line: &str) -> Vec<String> {
732    let mut types = Vec::new();
733    let common_types = [
734        "feat", "fix", "docs", "style", "refactor", "test", "chore", "ci", "build", "perf",
735    ];
736
737    for &type_str in &common_types {
738        if line.to_lowercase().contains(type_str) {
739            types.push(type_str.to_string());
740        }
741    }
742
743    types
744}
745
746/// Extracts a scope from a project structure description.
747fn extract_scope_from_structure(line: &str) -> Option<String> {
748    // Look for patterns like "src/auth/", "lib/config/", etc.
749    if let Some(start) = line.find("src/") {
750        let after_src = &line[start + 4..];
751        if let Some(end) = after_src.find('/') {
752            return Some(after_src[..end].to_string());
753        }
754    }
755
756    None
757}
758
759/// Extracts scopes from examples in documentation.
760fn extract_scopes_from_examples(line: &str) -> Vec<String> {
761    let mut scopes = Vec::new();
762    let common_scopes = ["auth", "api", "ui", "db", "config", "core", "cli", "web"];
763
764    for &scope in &common_scopes {
765        if line.to_lowercase().contains(scope) {
766            scopes.push(scope.to_string());
767        }
768    }
769
770    scopes
771}
772
773#[cfg(test)]
774#[allow(clippy::unwrap_used, clippy::expect_used)]
775mod tests {
776    use super::*;
777    use tempfile::TempDir;
778
779    // ── resolve_config_file ──────────────────────────────────────────
780
781    #[test]
782    fn local_override_wins() -> anyhow::Result<()> {
783        let dir = {
784            std::fs::create_dir_all("tmp")?;
785            TempDir::new_in("tmp")?
786        };
787        let base = dir.path();
788
789        // Create both local and project files
790        std::fs::create_dir_all(base.join("local"))?;
791        std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
792        std::fs::write(base.join("scopes.yaml"), "project")?;
793
794        let resolved = resolve_config_file(base, "scopes.yaml");
795        assert_eq!(resolved, base.join("local").join("scopes.yaml"));
796        Ok(())
797    }
798
799    #[test]
800    fn project_fallback() -> anyhow::Result<()> {
801        let dir = {
802            std::fs::create_dir_all("tmp")?;
803            TempDir::new_in("tmp")?
804        };
805        let base = dir.path();
806
807        // Create only project-level file (no local/)
808        std::fs::write(base.join("scopes.yaml"), "project")?;
809
810        let resolved = resolve_config_file(base, "scopes.yaml");
811        assert_eq!(resolved, base.join("scopes.yaml"));
812        Ok(())
813    }
814
815    #[test]
816    fn returns_default_when_nothing_exists() {
817        let dir = {
818            std::fs::create_dir_all("tmp").ok();
819            TempDir::new_in("tmp").unwrap()
820        };
821        let base = dir.path();
822
823        let resolved = resolve_config_file(base, "scopes.yaml");
824        // When no local or project file exists, it either returns:
825        // - the home directory path if $HOME/.omni-dev/scopes.yaml exists
826        // - the project path as fallback default
827        // Either way, the resolved path should NOT be the local override path.
828        assert_ne!(resolved, base.join("local").join("scopes.yaml"));
829    }
830
831    // ── merge_ecosystem_scopes ───────────────────────────────────────
832
833    #[test]
834    fn rust_ecosystem_detected() -> anyhow::Result<()> {
835        let dir = {
836            std::fs::create_dir_all("tmp")?;
837            TempDir::new_in("tmp")?
838        };
839        std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
840
841        let mut scopes = vec![];
842        merge_ecosystem_scopes(&mut scopes, dir.path());
843
844        let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
845        assert!(names.contains(&"cargo"), "missing 'cargo' scope");
846        assert!(names.contains(&"cli"), "missing 'cli' scope");
847        assert!(names.contains(&"core"), "missing 'core' scope");
848        assert!(names.contains(&"test"), "missing 'test' scope");
849        assert!(names.contains(&"docs"), "missing 'docs' scope");
850        assert!(names.contains(&"ci"), "missing 'ci' scope");
851        Ok(())
852    }
853
854    #[test]
855    fn node_ecosystem_detected() -> anyhow::Result<()> {
856        let dir = {
857            std::fs::create_dir_all("tmp")?;
858            TempDir::new_in("tmp")?
859        };
860        std::fs::write(dir.path().join("package.json"), "{}")?;
861
862        let mut scopes = vec![];
863        merge_ecosystem_scopes(&mut scopes, dir.path());
864
865        let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
866        assert!(names.contains(&"deps"), "missing 'deps' scope");
867        assert!(names.contains(&"config"), "missing 'config' scope");
868        Ok(())
869    }
870
871    #[test]
872    fn go_ecosystem_detected() -> anyhow::Result<()> {
873        let dir = {
874            std::fs::create_dir_all("tmp")?;
875            TempDir::new_in("tmp")?
876        };
877        std::fs::write(dir.path().join("go.mod"), "module example")?;
878
879        let mut scopes = vec![];
880        merge_ecosystem_scopes(&mut scopes, dir.path());
881
882        let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
883        assert!(names.contains(&"mod"), "missing 'mod' scope");
884        assert!(names.contains(&"cmd"), "missing 'cmd' scope");
885        assert!(names.contains(&"pkg"), "missing 'pkg' scope");
886        Ok(())
887    }
888
889    #[test]
890    fn existing_scope_not_overridden() -> anyhow::Result<()> {
891        let dir = {
892            std::fs::create_dir_all("tmp")?;
893            TempDir::new_in("tmp")?
894        };
895        std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
896
897        let mut scopes = vec![ScopeDefinition {
898            name: "cli".to_string(),
899            description: "Custom CLI scope".to_string(),
900            examples: vec![],
901            file_patterns: vec!["custom/**".to_string()],
902        }];
903        merge_ecosystem_scopes(&mut scopes, dir.path());
904
905        // The custom "cli" scope should be preserved, not replaced
906        let cli_scope = scopes.iter().find(|s| s.name == "cli").unwrap();
907        assert_eq!(cli_scope.description, "Custom CLI scope");
908        assert_eq!(cli_scope.file_patterns, vec!["custom/**"]);
909        Ok(())
910    }
911
912    #[test]
913    fn no_marker_files_produces_empty() {
914        let dir = {
915            std::fs::create_dir_all("tmp").ok();
916            TempDir::new_in("tmp").unwrap()
917        };
918        let mut scopes = vec![];
919        merge_ecosystem_scopes(&mut scopes, dir.path());
920        assert!(scopes.is_empty());
921    }
922
923    // ── load_project_scopes ──────────────────────────────────────────
924
925    #[test]
926    fn load_project_scopes_with_yaml() -> anyhow::Result<()> {
927        let dir = {
928            std::fs::create_dir_all("tmp")?;
929            TempDir::new_in("tmp")?
930        };
931        let config_dir = dir.path().join("config");
932        std::fs::create_dir_all(&config_dir)?;
933
934        let scopes_yaml = r#"
935scopes:
936  - name: custom
937    description: Custom scope
938    examples: []
939    file_patterns:
940      - "src/custom/**"
941"#;
942        std::fs::write(config_dir.join("scopes.yaml"), scopes_yaml)?;
943
944        // Also create Cargo.toml so ecosystem scopes get merged
945        std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
946
947        let scopes = load_project_scopes(&config_dir, dir.path());
948        let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
949        assert!(names.contains(&"custom"), "missing custom scope");
950        // Ecosystem scopes should also be merged
951        assert!(names.contains(&"cargo"), "missing ecosystem scope");
952        Ok(())
953    }
954
955    #[test]
956    fn load_project_scopes_no_file() -> anyhow::Result<()> {
957        let dir = {
958            std::fs::create_dir_all("tmp")?;
959            TempDir::new_in("tmp")?
960        };
961        std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
962
963        let scopes = load_project_scopes(dir.path(), dir.path());
964        // Should still get ecosystem defaults
965        assert!(!scopes.is_empty());
966        Ok(())
967    }
968
969    // ── Helper functions ─────────────────────────────────────────────
970
971    #[test]
972    fn extract_scope_from_structure_src() {
973        assert_eq!(
974            extract_scope_from_structure("- `src/auth/` - Authentication"),
975            Some("auth".to_string())
976        );
977    }
978
979    #[test]
980    fn extract_scope_from_structure_no_match() {
981        assert_eq!(extract_scope_from_structure("No source paths here"), None);
982    }
983
984    #[test]
985    fn extract_commit_types_from_line() {
986        let types = extract_commit_types("feat, fix, docs, test");
987        assert!(types.contains(&"feat".to_string()));
988        assert!(types.contains(&"fix".to_string()));
989        assert!(types.contains(&"docs".to_string()));
990        assert!(types.contains(&"test".to_string()));
991    }
992
993    #[test]
994    fn extract_commit_types_empty_line() {
995        let types = extract_commit_types("no types here");
996        assert!(types.is_empty());
997    }
998
999    // ── resolve_context_dir ────────────────────────────────────────────
1000
1001    // Use a mutex to serialize tests that modify OMNI_DEV_CONFIG_DIR.
1002    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1003
1004    #[test]
1005    fn context_dir_defaults_to_omni_dev() {
1006        let _lock = ENV_MUTEX.lock().unwrap();
1007        std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1008        let result = resolve_context_dir(None);
1009        // Walk-up may find .omni-dev in the real repo, or fall back to ".omni-dev"
1010        assert!(
1011            result.ends_with(".omni-dev"),
1012            "expected path ending in .omni-dev, got {result:?}"
1013        );
1014    }
1015
1016    #[test]
1017    fn context_dir_uses_override() {
1018        let _lock = ENV_MUTEX.lock().unwrap();
1019        let custom = PathBuf::from("custom-config");
1020        let result = resolve_context_dir(Some(&custom));
1021        assert_eq!(result, custom);
1022    }
1023
1024    #[test]
1025    fn context_dir_env_var() {
1026        let _lock = ENV_MUTEX.lock().unwrap();
1027        std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/my-config");
1028        let result = resolve_context_dir(None);
1029        std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1030        assert_eq!(result, PathBuf::from("/tmp/my-config"));
1031    }
1032
1033    #[test]
1034    fn context_dir_cli_flag_beats_env_var() {
1035        let _lock = ENV_MUTEX.lock().unwrap();
1036        std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1037        let cli = PathBuf::from("cli-config");
1038        let result = resolve_context_dir(Some(&cli));
1039        std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1040        assert_eq!(result, cli);
1041    }
1042
1043    #[test]
1044    fn context_dir_ignores_empty_env_var() {
1045        let _lock = ENV_MUTEX.lock().unwrap();
1046        std::env::set_var("OMNI_DEV_CONFIG_DIR", "");
1047        let result = resolve_context_dir(None);
1048        std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1049        // Walk-up may find .omni-dev in the real repo, or fall back to ".omni-dev"
1050        assert!(
1051            result.ends_with(".omni-dev"),
1052            "expected path ending in .omni-dev, got {result:?}"
1053        );
1054    }
1055
1056    // ── resolve_context_dir_with_source ─────────────────────────────────
1057
1058    #[test]
1059    fn with_source_cli_flag() {
1060        let _lock = ENV_MUTEX.lock().unwrap();
1061        let custom = PathBuf::from("custom-config");
1062        let (path, source) = resolve_context_dir_with_source(Some(&custom));
1063        assert_eq!(path, custom);
1064        assert_eq!(source, ConfigDirSource::CliFlag);
1065    }
1066
1067    #[test]
1068    fn with_source_env_var() {
1069        let _lock = ENV_MUTEX.lock().unwrap();
1070        std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1071        let (path, source) = resolve_context_dir_with_source(None);
1072        std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1073        assert_eq!(path, PathBuf::from("/tmp/env-config"));
1074        assert_eq!(source, ConfigDirSource::EnvVar);
1075    }
1076
1077    #[test]
1078    fn with_source_cli_beats_env() {
1079        let _lock = ENV_MUTEX.lock().unwrap();
1080        std::env::set_var("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1081        let custom = PathBuf::from("cli-config");
1082        let (path, source) = resolve_context_dir_with_source(Some(&custom));
1083        std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1084        assert_eq!(path, custom);
1085        assert_eq!(source, ConfigDirSource::CliFlag);
1086    }
1087
1088    #[test]
1089    fn with_source_walk_up_or_default() {
1090        let _lock = ENV_MUTEX.lock().unwrap();
1091        std::env::remove_var("OMNI_DEV_CONFIG_DIR");
1092        let (path, source) = resolve_context_dir_with_source(None);
1093        // Inside this repo, walk-up finds .omni-dev; outside, falls back to default
1094        assert!(
1095            path.ends_with(".omni-dev"),
1096            "expected path ending in .omni-dev, got {path:?}"
1097        );
1098        assert!(
1099            source == ConfigDirSource::WalkUp || source == ConfigDirSource::Default,
1100            "expected WalkUp or Default, got {source:?}"
1101        );
1102    }
1103
1104    // ── ConfigDirSource Display ──────────────────────────────────────────
1105
1106    #[test]
1107    fn display_config_dir_source_cli_flag() {
1108        assert_eq!(ConfigDirSource::CliFlag.to_string(), "--context-dir");
1109    }
1110
1111    #[test]
1112    fn display_config_dir_source_env_var() {
1113        assert_eq!(ConfigDirSource::EnvVar.to_string(), "OMNI_DEV_CONFIG_DIR");
1114    }
1115
1116    #[test]
1117    fn display_config_dir_source_walk_up() {
1118        assert_eq!(ConfigDirSource::WalkUp.to_string(), "walk-up");
1119    }
1120
1121    #[test]
1122    fn display_config_dir_source_default() {
1123        assert_eq!(ConfigDirSource::Default.to_string(), "default");
1124    }
1125
1126    // ── load_config_content ────────────────────────────────────────────
1127
1128    #[test]
1129    fn load_config_content_reads_project_file() -> anyhow::Result<()> {
1130        let dir = {
1131            std::fs::create_dir_all("tmp")?;
1132            TempDir::new_in("tmp")?
1133        };
1134        let base = dir.path();
1135
1136        std::fs::write(
1137            base.join("commit-guidelines.md"),
1138            "# Guidelines\nBe concise.",
1139        )?;
1140
1141        let content = load_config_content(base, "commit-guidelines.md")?;
1142        assert_eq!(content, Some("# Guidelines\nBe concise.".to_string()));
1143        Ok(())
1144    }
1145
1146    #[test]
1147    fn load_config_content_prefers_local_override() -> anyhow::Result<()> {
1148        let dir = {
1149            std::fs::create_dir_all("tmp")?;
1150            TempDir::new_in("tmp")?
1151        };
1152        let base = dir.path();
1153
1154        std::fs::create_dir_all(base.join("local"))?;
1155        std::fs::write(base.join("local").join("guidelines.md"), "local content")?;
1156        std::fs::write(base.join("guidelines.md"), "project content")?;
1157
1158        let content = load_config_content(base, "guidelines.md")?;
1159        assert_eq!(content, Some("local content".to_string()));
1160        Ok(())
1161    }
1162
1163    #[test]
1164    fn load_config_content_returns_none_when_missing() -> anyhow::Result<()> {
1165        let dir = {
1166            std::fs::create_dir_all("tmp")?;
1167            TempDir::new_in("tmp")?
1168        };
1169
1170        let content = load_config_content(dir.path(), "nonexistent.md")?;
1171        assert_eq!(content, None);
1172        Ok(())
1173    }
1174
1175    // ── config_source_label ────────────────────────────────────────────
1176
1177    #[test]
1178    fn source_label_local_override() -> anyhow::Result<()> {
1179        let dir = {
1180            std::fs::create_dir_all("tmp")?;
1181            TempDir::new_in("tmp")?
1182        };
1183        let base = dir.path();
1184
1185        std::fs::create_dir_all(base.join("local"))?;
1186        std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
1187        std::fs::write(base.join("scopes.yaml"), "project")?;
1188
1189        let label = config_source_label(base, "scopes.yaml");
1190        assert_eq!(
1191            label,
1192            ConfigSourceLabel::LocalOverride(base.join("local").join("scopes.yaml"))
1193        );
1194        Ok(())
1195    }
1196
1197    #[test]
1198    fn source_label_project() -> anyhow::Result<()> {
1199        let dir = {
1200            std::fs::create_dir_all("tmp")?;
1201            TempDir::new_in("tmp")?
1202        };
1203        let base = dir.path();
1204
1205        std::fs::write(base.join("scopes.yaml"), "project")?;
1206
1207        let label = config_source_label(base, "scopes.yaml");
1208        assert_eq!(label, ConfigSourceLabel::Project(base.join("scopes.yaml")));
1209        Ok(())
1210    }
1211
1212    #[test]
1213    fn source_label_not_found() {
1214        let dir = {
1215            std::fs::create_dir_all("tmp").ok();
1216            TempDir::new_in("tmp").unwrap()
1217        };
1218
1219        let label = config_source_label(dir.path(), "nonexistent.yaml");
1220        assert_eq!(label, ConfigSourceLabel::NotFound);
1221    }
1222
1223    // ── ConfigSourceLabel Display ──────────────────────────────────────
1224
1225    #[test]
1226    fn display_local_override() {
1227        let label = ConfigSourceLabel::LocalOverride(PathBuf::from(".omni-dev/local/scopes.yaml"));
1228        assert_eq!(
1229            label.to_string(),
1230            "Local override: .omni-dev/local/scopes.yaml"
1231        );
1232    }
1233
1234    #[test]
1235    fn display_project() {
1236        let label = ConfigSourceLabel::Project(PathBuf::from(".omni-dev/scopes.yaml"));
1237        assert_eq!(label.to_string(), "Project: .omni-dev/scopes.yaml");
1238    }
1239
1240    #[test]
1241    fn display_global() {
1242        let label = ConfigSourceLabel::Global(PathBuf::from("/home/user/.omni-dev/scopes.yaml"));
1243        assert_eq!(
1244            label.to_string(),
1245            "Global: /home/user/.omni-dev/scopes.yaml"
1246        );
1247    }
1248
1249    #[test]
1250    fn display_xdg() {
1251        let label =
1252            ConfigSourceLabel::Xdg(PathBuf::from("/home/user/.config/omni-dev/scopes.yaml"));
1253        assert_eq!(
1254            label.to_string(),
1255            "Global (XDG): /home/user/.config/omni-dev/scopes.yaml"
1256        );
1257    }
1258
1259    #[test]
1260    fn display_not_found() {
1261        let label = ConfigSourceLabel::NotFound;
1262        assert_eq!(label.to_string(), "(not found)");
1263    }
1264
1265    // ── xdg_config_dir ─────────────────────────────────────────────────
1266
1267    #[test]
1268    fn xdg_config_dir_uses_env_var() {
1269        let _lock = ENV_MUTEX.lock().unwrap();
1270        std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-test");
1271        let result = xdg_config_dir();
1272        std::env::remove_var("XDG_CONFIG_HOME");
1273        assert_eq!(result, Some(PathBuf::from("/tmp/xdg-test/omni-dev")));
1274    }
1275
1276    #[test]
1277    fn xdg_config_dir_ignores_empty_env_var() {
1278        let _lock = ENV_MUTEX.lock().unwrap();
1279        std::env::set_var("XDG_CONFIG_HOME", "");
1280        let result = xdg_config_dir();
1281        std::env::remove_var("XDG_CONFIG_HOME");
1282        // Falls back to $HOME/.config/omni-dev
1283        if let Some(home) = dirs::home_dir() {
1284            assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1285        }
1286    }
1287
1288    #[test]
1289    fn xdg_config_dir_defaults_to_home_config() {
1290        let _lock = ENV_MUTEX.lock().unwrap();
1291        std::env::remove_var("XDG_CONFIG_HOME");
1292        let result = xdg_config_dir();
1293        if let Some(home) = dirs::home_dir() {
1294            assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1295        }
1296    }
1297
1298    // ── resolve_config_file XDG integration ─────────────────────────────
1299
1300    #[test]
1301    fn resolve_config_file_finds_xdg() -> anyhow::Result<()> {
1302        let _lock = ENV_MUTEX.lock().unwrap();
1303
1304        let xdg_dir = {
1305            std::fs::create_dir_all("tmp")?;
1306            TempDir::new_in("tmp")?
1307        };
1308        let xdg_omni = xdg_dir.path().join("omni-dev");
1309        std::fs::create_dir_all(&xdg_omni)?;
1310        std::fs::write(xdg_omni.join("commit-guidelines.md"), "xdg content")?;
1311
1312        std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1313        let project_dir = {
1314            std::fs::create_dir_all("tmp")?;
1315            TempDir::new_in("tmp")?
1316        };
1317        let resolved = resolve_config_file(project_dir.path(), "commit-guidelines.md");
1318        std::env::remove_var("XDG_CONFIG_HOME");
1319
1320        assert_eq!(resolved, xdg_omni.join("commit-guidelines.md"));
1321        Ok(())
1322    }
1323
1324    #[test]
1325    fn resolve_config_file_xdg_beats_home() -> anyhow::Result<()> {
1326        let _lock = ENV_MUTEX.lock().unwrap();
1327
1328        // Set up XDG config
1329        let xdg_dir = {
1330            std::fs::create_dir_all("tmp")?;
1331            TempDir::new_in("tmp")?
1332        };
1333        let xdg_omni = xdg_dir.path().join("omni-dev");
1334        std::fs::create_dir_all(&xdg_omni)?;
1335        std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1336
1337        std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1338
1339        // Project dir with no local config
1340        let project_dir = {
1341            std::fs::create_dir_all("tmp")?;
1342            TempDir::new_in("tmp")?
1343        };
1344
1345        let resolved = resolve_config_file(project_dir.path(), "scopes.yaml");
1346        std::env::remove_var("XDG_CONFIG_HOME");
1347
1348        // XDG path should win (home path only wins if XDG doesn't have the file)
1349        assert_eq!(resolved, xdg_omni.join("scopes.yaml"));
1350        Ok(())
1351    }
1352
1353    #[test]
1354    fn resolve_config_file_project_beats_xdg() -> anyhow::Result<()> {
1355        let _lock = ENV_MUTEX.lock().unwrap();
1356
1357        // Set up XDG config
1358        let xdg_dir = {
1359            std::fs::create_dir_all("tmp")?;
1360            TempDir::new_in("tmp")?
1361        };
1362        let xdg_omni = xdg_dir.path().join("omni-dev");
1363        std::fs::create_dir_all(&xdg_omni)?;
1364        std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1365
1366        std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1367
1368        // Project dir with project-level config
1369        let project_dir = {
1370            std::fs::create_dir_all("tmp")?;
1371            TempDir::new_in("tmp")?
1372        };
1373        std::fs::write(project_dir.path().join("scopes.yaml"), "project")?;
1374
1375        let resolved = resolve_config_file(project_dir.path(), "scopes.yaml");
1376        std::env::remove_var("XDG_CONFIG_HOME");
1377
1378        // Project path should win over XDG
1379        assert_eq!(resolved, project_dir.path().join("scopes.yaml"));
1380        Ok(())
1381    }
1382
1383    // ── config_source_label XDG integration ────────────────────────────
1384
1385    #[test]
1386    fn source_label_xdg() -> anyhow::Result<()> {
1387        let _lock = ENV_MUTEX.lock().unwrap();
1388
1389        let xdg_dir = {
1390            std::fs::create_dir_all("tmp")?;
1391            TempDir::new_in("tmp")?
1392        };
1393        let xdg_omni = xdg_dir.path().join("omni-dev");
1394        std::fs::create_dir_all(&xdg_omni)?;
1395        std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1396
1397        std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
1398
1399        let project_dir = {
1400            std::fs::create_dir_all("tmp")?;
1401            TempDir::new_in("tmp")?
1402        };
1403        let label = config_source_label(project_dir.path(), "scopes.yaml");
1404        std::env::remove_var("XDG_CONFIG_HOME");
1405
1406        assert_eq!(label, ConfigSourceLabel::Xdg(xdg_omni.join("scopes.yaml")));
1407        Ok(())
1408    }
1409
1410    // ── walk_up_find_config_dir ─────────────────────────────────────────
1411
1412    /// Creates a mock repo tree with `.git` at the root.
1413    /// Returns (root_dir, TempDir handle).
1414    fn make_repo_tree() -> anyhow::Result<TempDir> {
1415        let dir = {
1416            std::fs::create_dir_all("tmp")?;
1417            TempDir::new_in("tmp")?
1418        };
1419        // Create .git marker at root
1420        std::fs::create_dir(dir.path().join(".git"))?;
1421        Ok(dir)
1422    }
1423
1424    #[test]
1425    fn walk_up_finds_omni_dev_in_start_dir() -> anyhow::Result<()> {
1426        let repo = make_repo_tree()?;
1427        let sub = repo.path().join("packages").join("frontend");
1428        std::fs::create_dir_all(&sub)?;
1429        std::fs::create_dir(sub.join(".omni-dev"))?;
1430
1431        let result = walk_up_find_config_dir(&sub);
1432        assert_eq!(result, Some(sub.join(".omni-dev")));
1433        Ok(())
1434    }
1435
1436    #[test]
1437    fn walk_up_finds_omni_dev_in_parent() -> anyhow::Result<()> {
1438        let repo = make_repo_tree()?;
1439        let pkg = repo.path().join("packages").join("frontend");
1440        let src = pkg.join("src");
1441        std::fs::create_dir_all(&src)?;
1442        std::fs::create_dir(pkg.join(".omni-dev"))?;
1443
1444        let result = walk_up_find_config_dir(&src);
1445        assert_eq!(result, Some(pkg.join(".omni-dev")));
1446        Ok(())
1447    }
1448
1449    #[test]
1450    fn walk_up_finds_omni_dev_at_repo_root() -> anyhow::Result<()> {
1451        let repo = make_repo_tree()?;
1452        let deep = repo.path().join("a").join("b").join("c");
1453        std::fs::create_dir_all(&deep)?;
1454        std::fs::create_dir(repo.path().join(".omni-dev"))?;
1455
1456        let result = walk_up_find_config_dir(&deep);
1457        assert_eq!(result, Some(repo.path().join(".omni-dev")));
1458        Ok(())
1459    }
1460
1461    #[test]
1462    fn walk_up_nearest_wins() -> anyhow::Result<()> {
1463        let repo = make_repo_tree()?;
1464        let pkg = repo.path().join("packages").join("frontend");
1465        let src = pkg.join("src");
1466        std::fs::create_dir_all(&src)?;
1467        // Both root and package have .omni-dev
1468        std::fs::create_dir(repo.path().join(".omni-dev"))?;
1469        std::fs::create_dir(pkg.join(".omni-dev"))?;
1470
1471        let result = walk_up_find_config_dir(&src);
1472        // Nearest (packages/frontend/.omni-dev) should win
1473        assert_eq!(result, Some(pkg.join(".omni-dev")));
1474        Ok(())
1475    }
1476
1477    #[test]
1478    fn walk_up_stops_at_git_boundary() -> anyhow::Result<()> {
1479        let dir = {
1480            std::fs::create_dir_all("tmp")?;
1481            TempDir::new_in("tmp")?
1482        };
1483        // Parent has .omni-dev but is outside the repo
1484        std::fs::create_dir(dir.path().join(".omni-dev"))?;
1485        // Repo root is a subdirectory
1486        let repo_root = dir.path().join("repo");
1487        std::fs::create_dir_all(&repo_root)?;
1488        std::fs::create_dir(repo_root.join(".git"))?;
1489        let sub = repo_root.join("sub");
1490        std::fs::create_dir(&sub)?;
1491
1492        let result = walk_up_find_config_dir(&sub);
1493        // Should NOT find the .omni-dev above .git
1494        assert_eq!(result, None);
1495        Ok(())
1496    }
1497
1498    #[test]
1499    fn walk_up_returns_none_when_no_omni_dev() -> anyhow::Result<()> {
1500        let repo = make_repo_tree()?;
1501        let sub = repo.path().join("src");
1502        std::fs::create_dir(&sub)?;
1503
1504        let result = walk_up_find_config_dir(&sub);
1505        assert_eq!(result, None);
1506        Ok(())
1507    }
1508
1509    #[test]
1510    fn walk_up_handles_git_worktree_file() -> anyhow::Result<()> {
1511        let dir = {
1512            std::fs::create_dir_all("tmp")?;
1513            TempDir::new_in("tmp")?
1514        };
1515        // .git as a file (worktree)
1516        std::fs::write(dir.path().join(".git"), "gitdir: /some/path")?;
1517        std::fs::create_dir(dir.path().join(".omni-dev"))?;
1518        let sub = dir.path().join("src");
1519        std::fs::create_dir(&sub)?;
1520
1521        let result = walk_up_find_config_dir(&sub);
1522        assert_eq!(result, Some(dir.path().join(".omni-dev")));
1523        Ok(())
1524    }
1525
1526    #[test]
1527    fn walk_up_no_omni_dev_in_repo_returns_none() -> anyhow::Result<()> {
1528        // Repo with .git but no .omni-dev anywhere
1529        let repo = make_repo_tree()?;
1530        let sub = repo.path().join("a").join("b");
1531        std::fs::create_dir_all(&sub)?;
1532        let result = walk_up_find_config_dir(&sub);
1533        assert_eq!(result, None);
1534        Ok(())
1535    }
1536}