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