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    CommitRules, 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 mut scopes = load_project_scopes_only(context_dir);
323    merge_ecosystem_scopes(&mut scopes, repo_path);
324    scopes
325}
326
327/// Loads only the scopes explicitly defined in `scopes.yaml`, without merging
328/// ecosystem defaults (`cargo`/`lib`/`core`/`test`, …) the way
329/// [`load_project_scopes`] does.
330///
331/// For callers that need to distinguish "a human wrote this in scopes.yaml"
332/// from "omni-dev synthesized this from the detected ecosystem" — e.g.
333/// reporting which project-defined scopes go unused, which must never flag
334/// an ecosystem default the project never asked for.
335pub fn load_project_scopes_only(context_dir: &Path) -> Vec<ScopeDefinition> {
336    let scopes_path = resolve_config_file(context_dir, "scopes.yaml");
337    if !scopes_path.exists() {
338        return vec![];
339    }
340    let scopes_yaml = match fs::read_to_string(&scopes_path) {
341        Ok(content) => content,
342        Err(e) => {
343            tracing::warn!("Cannot read scopes file {}: {e}", scopes_path.display());
344            return vec![];
345        }
346    };
347    match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
348        Ok(config) => config.scopes,
349        Err(e) => {
350            tracing::warn!(
351                "Ignoring malformed scopes file {}: {e}",
352                scopes_path.display()
353            );
354            vec![]
355        }
356    }
357}
358
359/// Strictly-parsed `scopes.yaml`, including the optional `allow:` key.
360///
361/// Distinct from the private [`ScopesConfig`] used by [`load_project_scopes`]
362/// in two ways: it's `pub` (crosses the crate boundary for
363/// `omni-dev config scopes lint`, issue #1475), and it carries `allow` — a
364/// list of glob patterns for paths that legitimately belong to no scope.
365#[derive(Debug, Clone, Default, serde::Deserialize)]
366pub struct ScopesFile {
367    /// The scope definitions, exactly as written in `scopes.yaml`.
368    #[serde(default)]
369    pub scopes: Vec<ScopeDefinition>,
370    /// Glob patterns for paths that need no covering scope.
371    #[serde(default)]
372    pub allow: Vec<String>,
373}
374
375/// Loads `scopes.yaml` strictly, for `omni-dev config scopes lint`.
376///
377/// Unlike [`load_project_scopes`], a read or parse failure is propagated as
378/// an `Err` instead of logged-and-swallowed: a malformed `scopes.yaml` must
379/// be a loud lint failure, not a silent "zero scopes" fallback that happens
380/// to (correctly, but for the wrong reason) report every file unscoped.
381///
382/// Returns `Ok(ScopesFile::default())` when no `scopes.yaml` exists at any
383/// resolution tier — an absent file is not an error, only a malformed one.
384/// Never merges ecosystem defaults; whether to do so is the caller's
385/// decision (see [`merge_ecosystem_scopes`]).
386pub fn load_scopes_file_strict(context_dir: &Path) -> Result<ScopesFile> {
387    let scopes_path = resolve_config_file(context_dir, "scopes.yaml");
388    if !scopes_path.exists() {
389        return Ok(ScopesFile::default());
390    }
391    let raw = fs::read_to_string(&scopes_path)
392        .with_context(|| format!("Failed to read scopes file: {}", scopes_path.display()))?;
393    serde_yaml::from_str(&raw)
394        .with_context(|| format!("Failed to parse scopes file: {}", scopes_path.display()))
395}
396
397/// Loads deterministic commit-lint rules from an optional config file.
398///
399/// Resolves `.omni-dev/commit-rules.yaml` via the standard config priority
400/// (local → project → home). Returns [`CommitRules::default`] when the file
401/// is absent or malformed — never errors, mirroring [`load_project_scopes`].
402pub fn load_commit_rules(context_dir: &Path) -> CommitRules {
403    let rules_path = resolve_config_file(context_dir, "commit-rules.yaml");
404    if !rules_path.exists() {
405        return CommitRules::default();
406    }
407
408    let rules_yaml = match fs::read_to_string(&rules_path) {
409        Ok(content) => content,
410        Err(e) => {
411            let path = rules_path.display();
412            tracing::warn!("Cannot read commit rules file {path}: {e}");
413            return CommitRules::default();
414        }
415    };
416
417    match serde_yaml::from_str::<CommitRules>(&rules_yaml) {
418        Ok(rules) => rules,
419        Err(e) => {
420            let path = rules_path.display();
421            tracing::warn!("Ignoring malformed commit rules file {path}: {e}");
422            CommitRules::default()
423        }
424    }
425}
426
427/// Merges ecosystem-detected default scopes into the given scope list.
428///
429/// Detects the project ecosystem from marker files (Cargo.toml, package.json, etc.)
430/// and adds default scopes for that ecosystem, skipping any that already exist by name.
431///
432/// `pub(crate)` so `omni-dev config scopes lint` (issue #1475) can call this
433/// as an explicit, caller-gated step when `--no-project-only` is passed.
434pub(crate) fn merge_ecosystem_scopes(scopes: &mut Vec<ScopeDefinition>, repo_path: &Path) {
435    let ecosystem_scopes: Vec<(&str, &str, Vec<&str>)> = if repo_path.join("Cargo.toml").exists() {
436        vec![
437            (
438                "cargo",
439                "Cargo.toml and dependency management",
440                vec!["Cargo.toml", "Cargo.lock"],
441            ),
442            (
443                "lib",
444                "Library code and public API",
445                vec!["src/lib.rs", "src/**"],
446            ),
447            (
448                "cli",
449                "Command-line interface",
450                vec!["src/main.rs", "src/cli/**"],
451            ),
452            (
453                "core",
454                "Core application logic",
455                vec!["src/core/**", "src/lib/**"],
456            ),
457            ("test", "Test code", vec!["tests/**", "src/**/test*"]),
458            (
459                "docs",
460                "Documentation",
461                vec!["docs/**", "README.md", "**/*.md"],
462            ),
463            (
464                "ci",
465                "Continuous integration",
466                vec![".github/**", ".gitlab-ci.yml"],
467            ),
468        ]
469    } else if repo_path.join("package.json").exists() {
470        vec![
471            (
472                "deps",
473                "Dependencies and package.json",
474                vec!["package.json", "package-lock.json"],
475            ),
476            (
477                "config",
478                "Configuration files",
479                vec!["*.config.js", "*.config.json", ".env*"],
480            ),
481            (
482                "build",
483                "Build system and tooling",
484                vec!["webpack.config.js", "rollup.config.js"],
485            ),
486            (
487                "test",
488                "Test files",
489                vec!["test/**", "tests/**", "**/*.test.js"],
490            ),
491            (
492                "docs",
493                "Documentation",
494                vec!["docs/**", "README.md", "**/*.md"],
495            ),
496        ]
497    } else if repo_path.join("pyproject.toml").exists()
498        || repo_path.join("requirements.txt").exists()
499    {
500        vec![
501            (
502                "deps",
503                "Dependencies and requirements",
504                vec!["requirements.txt", "pyproject.toml", "setup.py"],
505            ),
506            (
507                "config",
508                "Configuration files",
509                vec!["*.ini", "*.cfg", "*.toml"],
510            ),
511            (
512                "test",
513                "Test files",
514                vec!["test/**", "tests/**", "**/*_test.py"],
515            ),
516            (
517                "docs",
518                "Documentation",
519                vec!["docs/**", "README.md", "**/*.md", "**/*.rst"],
520            ),
521        ]
522    } else if repo_path.join("go.mod").exists() {
523        vec![
524            (
525                "mod",
526                "Go modules and dependencies",
527                vec!["go.mod", "go.sum"],
528            ),
529            ("cmd", "Command-line applications", vec!["cmd/**"]),
530            ("pkg", "Library packages", vec!["pkg/**"]),
531            ("internal", "Internal packages", vec!["internal/**"]),
532            ("test", "Test files", vec!["**/*_test.go"]),
533            (
534                "docs",
535                "Documentation",
536                vec!["docs/**", "README.md", "**/*.md"],
537            ),
538        ]
539    } else if repo_path.join("pom.xml").exists() || repo_path.join("build.gradle").exists() {
540        vec![
541            (
542                "build",
543                "Build system",
544                vec!["pom.xml", "build.gradle", "build.gradle.kts"],
545            ),
546            (
547                "config",
548                "Configuration",
549                vec!["src/main/resources/**", "application.properties"],
550            ),
551            ("test", "Test files", vec!["src/test/**"]),
552            (
553                "docs",
554                "Documentation",
555                vec!["docs/**", "README.md", "**/*.md"],
556            ),
557        ]
558    } else {
559        vec![]
560    };
561
562    for (name, description, patterns) in ecosystem_scopes {
563        if !scopes.iter().any(|s| s.name == name) {
564            scopes.push(ScopeDefinition {
565                name: name.to_string(),
566                description: description.to_string(),
567                examples: vec![],
568                file_patterns: patterns.into_iter().map(String::from).collect(),
569            });
570        }
571    }
572}
573
574/// Project context discovery system.
575pub struct ProjectDiscovery {
576    repo_path: PathBuf,
577    context_dir: PathBuf,
578}
579
580impl ProjectDiscovery {
581    /// Creates a new project discovery instance.
582    pub fn new(repo_path: PathBuf, context_dir: PathBuf) -> Self {
583        Self {
584            repo_path,
585            context_dir,
586        }
587    }
588
589    /// Discovers all project context.
590    pub fn discover(&self) -> Result<ProjectContext> {
591        let mut context = ProjectContext::default();
592
593        // 1. Check custom context directory (highest priority)
594        let context_dir_path = if self.context_dir.is_absolute() {
595            self.context_dir.clone()
596        } else {
597            self.repo_path.join(&self.context_dir)
598        };
599        debug!(
600            context_dir = ?context_dir_path,
601            exists = context_dir_path.exists(),
602            "Looking for context directory"
603        );
604        debug!("Loading omni-dev config");
605        self.load_omni_dev_config(&mut context, &context_dir_path)?;
606        debug!("Config loading completed");
607
608        // 2. Standard git configuration files
609        self.load_git_config(&mut context)?;
610
611        // 3. Parse project documentation
612        self.parse_documentation(&mut context)?;
613
614        // 4. Detect ecosystem conventions
615        self.detect_ecosystem(&mut context)?;
616
617        Ok(context)
618    }
619
620    /// Loads configuration from .omni-dev/ directory with local override support.
621    fn load_omni_dev_config(&self, context: &mut ProjectContext, dir: &Path) -> Result<()> {
622        // Load commit guidelines (with local override)
623        let guidelines_path = resolve_config_file(dir, "commit-guidelines.md");
624        debug!(
625            path = ?guidelines_path,
626            exists = guidelines_path.exists(),
627            "Checking for commit guidelines"
628        );
629        if guidelines_path.exists() {
630            let content = fs::read_to_string(&guidelines_path)?;
631            debug!(bytes = content.len(), "Loaded commit guidelines");
632            context.commit_guidelines = Some(content);
633        } else {
634            debug!("No commit guidelines file found");
635        }
636
637        // Load PR guidelines (with local override)
638        let pr_guidelines_path = resolve_config_file(dir, "pr-guidelines.md");
639        debug!(
640            path = ?pr_guidelines_path,
641            exists = pr_guidelines_path.exists(),
642            "Checking for PR guidelines"
643        );
644        if pr_guidelines_path.exists() {
645            let content = fs::read_to_string(&pr_guidelines_path)?;
646            debug!(bytes = content.len(), "Loaded PR guidelines");
647            context.pr_guidelines = Some(content);
648        } else {
649            debug!("No PR guidelines file found");
650        }
651
652        // Load scopes configuration (with local override)
653        let scopes_path = resolve_config_file(dir, "scopes.yaml");
654        if scopes_path.exists() {
655            let scopes_yaml = fs::read_to_string(&scopes_path)?;
656            match serde_yaml::from_str::<ScopesConfig>(&scopes_yaml) {
657                Ok(scopes_config) => {
658                    context.valid_scopes = scopes_config.scopes;
659                }
660                Err(e) => {
661                    tracing::warn!(
662                        "Ignoring malformed scopes file {}: {e}",
663                        scopes_path.display()
664                    );
665                }
666            }
667        }
668
669        // Load feature contexts (check both local and standard directories)
670        let local_contexts_dir = dir.join("local").join("context").join("feature-contexts");
671        let contexts_dir = dir.join("context").join("feature-contexts");
672
673        // Load standard feature contexts first
674        if contexts_dir.exists() {
675            self.load_feature_contexts(context, &contexts_dir)?;
676        }
677
678        // Load local feature contexts (will override if same name)
679        if local_contexts_dir.exists() {
680            self.load_feature_contexts(context, &local_contexts_dir)?;
681        }
682
683        Ok(())
684    }
685
686    /// Loads git configuration files.
687    fn load_git_config(&self, _context: &mut ProjectContext) -> Result<()> {
688        // Git configuration loading can be extended here if needed
689        Ok(())
690    }
691
692    /// Parses project documentation for conventions.
693    fn parse_documentation(&self, context: &mut ProjectContext) -> Result<()> {
694        // Parse CONTRIBUTING.md
695        let contributing_path = self.repo_path.join("CONTRIBUTING.md");
696        if contributing_path.exists() {
697            let content = fs::read_to_string(contributing_path)?;
698            context.project_conventions = self.parse_contributing_conventions(&content)?;
699        }
700
701        // Parse README.md for additional conventions
702        let readme_path = self.repo_path.join("README.md");
703        if readme_path.exists() {
704            let content = fs::read_to_string(readme_path)?;
705            self.parse_readme_conventions(context, &content)?;
706        }
707
708        Ok(())
709    }
710
711    /// Detects project ecosystem and applies conventions.
712    fn detect_ecosystem(&self, context: &mut ProjectContext) -> Result<()> {
713        context.ecosystem = if self.repo_path.join("Cargo.toml").exists() {
714            Ecosystem::Rust
715        } else if self.repo_path.join("package.json").exists() {
716            Ecosystem::Node
717        } else if self.repo_path.join("pyproject.toml").exists()
718            || self.repo_path.join("requirements.txt").exists()
719        {
720            Ecosystem::Python
721        } else if self.repo_path.join("go.mod").exists() {
722            Ecosystem::Go
723        } else if self.repo_path.join("pom.xml").exists()
724            || self.repo_path.join("build.gradle").exists()
725        {
726            Ecosystem::Java
727        } else {
728            Ecosystem::Generic
729        };
730
731        merge_ecosystem_scopes(&mut context.valid_scopes, &self.repo_path);
732
733        Ok(())
734    }
735
736    /// Loads feature contexts from a directory.
737    fn load_feature_contexts(
738        &self,
739        context: &mut ProjectContext,
740        contexts_dir: &Path,
741    ) -> Result<()> {
742        let entries = match fs::read_dir(contexts_dir) {
743            Ok(entries) => entries,
744            Err(e) => {
745                tracing::warn!(
746                    "Cannot read feature contexts dir {}: {e}",
747                    contexts_dir.display()
748                );
749                return Ok(());
750            }
751        };
752        for entry in entries.flatten() {
753            if let Some(name) = entry.file_name().to_str() {
754                if name.ends_with(".yaml") || name.ends_with(".yml") {
755                    let content = fs::read_to_string(entry.path())?;
756                    match serde_yaml::from_str::<FeatureContext>(&content) {
757                        Ok(feature_context) => {
758                            let feature_name = name
759                                .trim_end_matches(".yaml")
760                                .trim_end_matches(".yml")
761                                .to_string();
762                            context
763                                .feature_contexts
764                                .insert(feature_name, feature_context);
765                        }
766                        Err(e) => {
767                            tracing::warn!(
768                                "Ignoring malformed feature context {}: {e}",
769                                entry.path().display()
770                            );
771                        }
772                    }
773                }
774            }
775        }
776        Ok(())
777    }
778
779    /// Parses CONTRIBUTING.md for conventions.
780    fn parse_contributing_conventions(&self, content: &str) -> Result<ProjectConventions> {
781        let mut conventions = ProjectConventions::default();
782
783        // Look for commit message sections
784        let lines: Vec<&str> = content.lines().collect();
785        let mut in_commit_section = false;
786
787        for (i, line) in lines.iter().enumerate() {
788            let line_lower = line.to_lowercase();
789
790            // Detect commit message sections
791            if line_lower.contains("commit")
792                && (line_lower.contains("message") || line_lower.contains("format"))
793            {
794                in_commit_section = true;
795                continue;
796            }
797
798            // End commit section if we hit another header
799            if in_commit_section && line.starts_with('#') && !line_lower.contains("commit") {
800                in_commit_section = false;
801            }
802
803            if in_commit_section {
804                // Extract commit format examples
805                if line.contains("type(scope):") || line.contains("<type>(<scope>):") {
806                    conventions.commit_format = Some("type(scope): description".to_string());
807                }
808
809                // Extract required trailers
810                if line_lower.contains("signed-off-by") {
811                    conventions
812                        .required_trailers
813                        .push("Signed-off-by".to_string());
814                }
815
816                if line_lower.contains("fixes") && line_lower.contains('#') {
817                    conventions.required_trailers.push("Fixes".to_string());
818                }
819
820                // Extract preferred types
821                if line.contains("feat") || line.contains("fix") || line.contains("docs") {
822                    let types = extract_commit_types(line);
823                    conventions.preferred_types.extend(types);
824                }
825
826                // Look ahead for scope examples
827                if line_lower.contains("scope") && i + 1 < lines.len() {
828                    let scope_requirements = self.extract_scope_requirements(&lines[i..]);
829                    conventions.scope_requirements = scope_requirements;
830                }
831            }
832        }
833
834        Ok(conventions)
835    }
836
837    /// Parses README.md for additional conventions.
838    fn parse_readme_conventions(&self, context: &mut ProjectContext, content: &str) -> Result<()> {
839        // Look for development or contribution sections
840        let lines: Vec<&str> = content.lines().collect();
841
842        for line in lines {
843            let _line_lower = line.to_lowercase();
844
845            // Extract additional scope information from project structure
846            if line.contains("src/") || line.contains("lib/") {
847                // Try to extract scope information from directory structure mentions
848                if let Some(scope) = extract_scope_from_structure(line) {
849                    context.valid_scopes.push(ScopeDefinition {
850                        name: scope.clone(),
851                        description: format!("{scope} related changes"),
852                        examples: vec![],
853                        file_patterns: vec![format!("{}/**", scope)],
854                    });
855                }
856            }
857        }
858
859        Ok(())
860    }
861
862    /// Extracts scope requirements from contributing documentation.
863    fn extract_scope_requirements(&self, lines: &[&str]) -> ScopeRequirements {
864        let mut requirements = ScopeRequirements::default();
865
866        for line in lines.iter().take(10) {
867            // Stop at next major section
868            if line.starts_with("##") {
869                break;
870            }
871
872            let line_lower = line.to_lowercase();
873
874            if line_lower.contains("required") || line_lower.contains("must") {
875                requirements.required = true;
876            }
877
878            // Extract scope examples
879            if line.contains(':')
880                && (line.contains("auth") || line.contains("api") || line.contains("ui"))
881            {
882                let scopes = extract_scopes_from_examples(line);
883                requirements.valid_scopes.extend(scopes);
884            }
885        }
886
887        requirements
888    }
889}
890
891/// Configuration structure for scopes.yaml.
892#[derive(serde::Deserialize)]
893struct ScopesConfig {
894    scopes: Vec<ScopeDefinition>,
895}
896
897/// Extracts commit types from a line.
898fn extract_commit_types(line: &str) -> Vec<String> {
899    let mut types = Vec::new();
900    let common_types = [
901        "feat", "fix", "docs", "style", "refactor", "test", "chore", "ci", "build", "perf",
902    ];
903
904    for &type_str in &common_types {
905        if line.to_lowercase().contains(type_str) {
906            types.push(type_str.to_string());
907        }
908    }
909
910    types
911}
912
913/// Extracts a scope from a project structure description.
914fn extract_scope_from_structure(line: &str) -> Option<String> {
915    // Look for patterns like "src/auth/", "lib/config/", etc.
916    if let Some(start) = line.find("src/") {
917        let after_src = &line[start + 4..];
918        if let Some(end) = after_src.find('/') {
919            return Some(after_src[..end].to_string());
920        }
921    }
922
923    None
924}
925
926/// Extracts scopes from examples in documentation.
927fn extract_scopes_from_examples(line: &str) -> Vec<String> {
928    let mut scopes = Vec::new();
929    let common_scopes = ["auth", "api", "ui", "db", "config", "core", "cli", "web"];
930
931    for &scope in &common_scopes {
932        if line.to_lowercase().contains(scope) {
933            scopes.push(scope.to_string());
934        }
935    }
936
937    scopes
938}
939
940#[cfg(test)]
941#[allow(clippy::unwrap_used, clippy::expect_used)]
942mod tests {
943    use super::*;
944    use crate::test_support::env::MapEnv;
945    use tempfile::TempDir;
946
947    // ── resolve_config_file ──────────────────────────────────────────
948
949    #[test]
950    fn local_override_wins() -> anyhow::Result<()> {
951        let dir = {
952            std::fs::create_dir_all("tmp")?;
953            TempDir::new_in("tmp")?
954        };
955        let base = dir.path();
956
957        // Create both local and project files
958        std::fs::create_dir_all(base.join("local"))?;
959        std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
960        std::fs::write(base.join("scopes.yaml"), "project")?;
961
962        let resolved = resolve_config_file(base, "scopes.yaml");
963        assert_eq!(resolved, base.join("local").join("scopes.yaml"));
964        Ok(())
965    }
966
967    #[test]
968    fn project_fallback() -> anyhow::Result<()> {
969        let dir = {
970            std::fs::create_dir_all("tmp")?;
971            TempDir::new_in("tmp")?
972        };
973        let base = dir.path();
974
975        // Create only project-level file (no local/)
976        std::fs::write(base.join("scopes.yaml"), "project")?;
977
978        let resolved = resolve_config_file(base, "scopes.yaml");
979        assert_eq!(resolved, base.join("scopes.yaml"));
980        Ok(())
981    }
982
983    #[test]
984    fn returns_default_when_nothing_exists() {
985        let dir = {
986            std::fs::create_dir_all("tmp").ok();
987            TempDir::new_in("tmp").unwrap()
988        };
989        let base = dir.path();
990
991        let resolved = resolve_config_file(base, "scopes.yaml");
992        // When no local or project file exists, it either returns:
993        // - the home directory path if $HOME/.omni-dev/scopes.yaml exists
994        // - the project path as fallback default
995        // Either way, the resolved path should NOT be the local override path.
996        assert_ne!(resolved, base.join("local").join("scopes.yaml"));
997    }
998
999    // ── merge_ecosystem_scopes ───────────────────────────────────────
1000
1001    #[test]
1002    fn rust_ecosystem_detected() -> anyhow::Result<()> {
1003        let dir = {
1004            std::fs::create_dir_all("tmp")?;
1005            TempDir::new_in("tmp")?
1006        };
1007        std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1008
1009        let mut scopes = vec![];
1010        merge_ecosystem_scopes(&mut scopes, dir.path());
1011
1012        let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
1013        assert!(names.contains(&"cargo"), "missing 'cargo' scope");
1014        assert!(names.contains(&"cli"), "missing 'cli' scope");
1015        assert!(names.contains(&"core"), "missing 'core' scope");
1016        assert!(names.contains(&"test"), "missing 'test' scope");
1017        assert!(names.contains(&"docs"), "missing 'docs' scope");
1018        assert!(names.contains(&"ci"), "missing 'ci' scope");
1019        Ok(())
1020    }
1021
1022    #[test]
1023    fn node_ecosystem_detected() -> anyhow::Result<()> {
1024        let dir = {
1025            std::fs::create_dir_all("tmp")?;
1026            TempDir::new_in("tmp")?
1027        };
1028        std::fs::write(dir.path().join("package.json"), "{}")?;
1029
1030        let mut scopes = vec![];
1031        merge_ecosystem_scopes(&mut scopes, dir.path());
1032
1033        let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
1034        assert!(names.contains(&"deps"), "missing 'deps' scope");
1035        assert!(names.contains(&"config"), "missing 'config' scope");
1036        Ok(())
1037    }
1038
1039    #[test]
1040    fn go_ecosystem_detected() -> anyhow::Result<()> {
1041        let dir = {
1042            std::fs::create_dir_all("tmp")?;
1043            TempDir::new_in("tmp")?
1044        };
1045        std::fs::write(dir.path().join("go.mod"), "module example")?;
1046
1047        let mut scopes = vec![];
1048        merge_ecosystem_scopes(&mut scopes, dir.path());
1049
1050        let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
1051        assert!(names.contains(&"mod"), "missing 'mod' scope");
1052        assert!(names.contains(&"cmd"), "missing 'cmd' scope");
1053        assert!(names.contains(&"pkg"), "missing 'pkg' scope");
1054        Ok(())
1055    }
1056
1057    #[test]
1058    fn existing_scope_not_overridden() -> anyhow::Result<()> {
1059        let dir = {
1060            std::fs::create_dir_all("tmp")?;
1061            TempDir::new_in("tmp")?
1062        };
1063        std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1064
1065        let mut scopes = vec![ScopeDefinition {
1066            name: "cli".to_string(),
1067            description: "Custom CLI scope".to_string(),
1068            examples: vec![],
1069            file_patterns: vec!["custom/**".to_string()],
1070        }];
1071        merge_ecosystem_scopes(&mut scopes, dir.path());
1072
1073        // The custom "cli" scope should be preserved, not replaced
1074        let cli_scope = scopes.iter().find(|s| s.name == "cli").unwrap();
1075        assert_eq!(cli_scope.description, "Custom CLI scope");
1076        assert_eq!(cli_scope.file_patterns, vec!["custom/**"]);
1077        Ok(())
1078    }
1079
1080    #[test]
1081    fn no_marker_files_produces_empty() {
1082        let dir = {
1083            std::fs::create_dir_all("tmp").ok();
1084            TempDir::new_in("tmp").unwrap()
1085        };
1086        let mut scopes = vec![];
1087        merge_ecosystem_scopes(&mut scopes, dir.path());
1088        assert!(scopes.is_empty());
1089    }
1090
1091    // ── load_project_scopes ──────────────────────────────────────────
1092
1093    #[test]
1094    fn load_project_scopes_with_yaml() -> anyhow::Result<()> {
1095        let dir = {
1096            std::fs::create_dir_all("tmp")?;
1097            TempDir::new_in("tmp")?
1098        };
1099        let config_dir = dir.path().join("config");
1100        std::fs::create_dir_all(&config_dir)?;
1101
1102        let scopes_yaml = r#"
1103scopes:
1104  - name: custom
1105    description: Custom scope
1106    examples: []
1107    file_patterns:
1108      - "src/custom/**"
1109"#;
1110        std::fs::write(config_dir.join("scopes.yaml"), scopes_yaml)?;
1111
1112        // Also create Cargo.toml so ecosystem scopes get merged
1113        std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1114
1115        let scopes = load_project_scopes(&config_dir, dir.path());
1116        let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
1117        assert!(names.contains(&"custom"), "missing custom scope");
1118        // Ecosystem scopes should also be merged
1119        assert!(names.contains(&"cargo"), "missing ecosystem scope");
1120        Ok(())
1121    }
1122
1123    #[test]
1124    fn load_project_scopes_no_file() -> anyhow::Result<()> {
1125        let dir = {
1126            std::fs::create_dir_all("tmp")?;
1127            TempDir::new_in("tmp")?
1128        };
1129        std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1130
1131        let scopes = load_project_scopes(dir.path(), dir.path());
1132        // Should still get ecosystem defaults
1133        assert!(!scopes.is_empty());
1134        Ok(())
1135    }
1136
1137    // ── load_project_scopes_only ─────────────────────────────────────
1138
1139    #[test]
1140    fn load_project_scopes_only_skips_ecosystem_merge() -> anyhow::Result<()> {
1141        let dir = {
1142            std::fs::create_dir_all("tmp")?;
1143            TempDir::new_in("tmp")?
1144        };
1145        let config_dir = dir.path().join("config");
1146        std::fs::create_dir_all(&config_dir)?;
1147
1148        let scopes_yaml = r#"
1149scopes:
1150  - name: custom
1151    description: Custom scope
1152    examples: []
1153    file_patterns:
1154      - "src/custom/**"
1155"#;
1156        std::fs::write(config_dir.join("scopes.yaml"), scopes_yaml)?;
1157        // Cargo.toml present, but load_project_scopes_only must not merge it in.
1158        std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1159
1160        let scopes = load_project_scopes_only(&config_dir);
1161        let names: Vec<&str> = scopes.iter().map(|s| s.name.as_str()).collect();
1162        assert_eq!(names, vec!["custom"], "must not include ecosystem defaults");
1163        Ok(())
1164    }
1165
1166    // ── load_scopes_file_strict (issue #1475) ─────────────────────────
1167
1168    #[test]
1169    fn load_scopes_file_strict_parses_scopes_and_allow() -> anyhow::Result<()> {
1170        let dir = {
1171            std::fs::create_dir_all("tmp")?;
1172            TempDir::new_in("tmp")?
1173        };
1174        let scopes_yaml = r#"
1175scopes:
1176  - name: custom
1177    description: Custom scope
1178    examples: []
1179    file_patterns:
1180      - "src/custom/**"
1181allow:
1182  - "src/lib.rs"
1183  - "src/bin/**"
1184"#;
1185        std::fs::write(dir.path().join("scopes.yaml"), scopes_yaml)?;
1186
1187        let file = load_scopes_file_strict(dir.path())?;
1188        assert_eq!(file.scopes.len(), 1);
1189        assert_eq!(file.scopes[0].name, "custom");
1190        assert_eq!(
1191            file.allow,
1192            vec!["src/lib.rs".to_string(), "src/bin/**".to_string()]
1193        );
1194        Ok(())
1195    }
1196
1197    #[test]
1198    fn load_scopes_file_strict_missing_file_returns_default() -> anyhow::Result<()> {
1199        let dir = {
1200            std::fs::create_dir_all("tmp")?;
1201            TempDir::new_in("tmp")?
1202        };
1203        // No scopes.yaml under `dir`; `resolve_config_file`'s XDG/legacy-home
1204        // fallback tiers may still find a real one on the machine running
1205        // this test (same non-hermetic reality `load_project_scopes_no_file`
1206        // above already tolerates) — this only proves absence at the
1207        // project tier is not an error, not that the result is empty.
1208        assert!(load_scopes_file_strict(dir.path()).is_ok());
1209        Ok(())
1210    }
1211
1212    #[test]
1213    fn load_scopes_file_strict_malformed_yaml_errors() -> anyhow::Result<()> {
1214        let dir = {
1215            std::fs::create_dir_all("tmp")?;
1216            TempDir::new_in("tmp")?
1217        };
1218        // Unlike `load_project_scopes`, which swallows this into an empty
1219        // Vec, the strict loader must surface it as a loud failure.
1220        std::fs::write(dir.path().join("scopes.yaml"), "scopes: [not valid")?;
1221        assert!(load_scopes_file_strict(dir.path()).is_err());
1222        Ok(())
1223    }
1224
1225    #[test]
1226    fn load_scopes_file_strict_never_merges_ecosystem_defaults() -> anyhow::Result<()> {
1227        let dir = {
1228            std::fs::create_dir_all("tmp")?;
1229            TempDir::new_in("tmp")?
1230        };
1231        std::fs::write(dir.path().join("Cargo.toml"), "[package]")?;
1232        let scopes_yaml = r#"
1233scopes:
1234  - name: custom
1235    description: Custom scope
1236    examples: []
1237    file_patterns:
1238      - "src/custom/**"
1239"#;
1240        std::fs::write(dir.path().join("scopes.yaml"), scopes_yaml)?;
1241
1242        let file = load_scopes_file_strict(dir.path())?;
1243        // No ecosystem `cargo`/`lib`/... scopes leak in even though a
1244        // Cargo.toml marker is present — merging is an explicit,
1245        // caller-gated step via `merge_ecosystem_scopes`, never implicit.
1246        assert_eq!(file.scopes.len(), 1);
1247        assert_eq!(file.scopes[0].name, "custom");
1248        Ok(())
1249    }
1250
1251    // ── load_commit_rules ──────────────────────────────────────────────
1252
1253    #[test]
1254    fn load_commit_rules_no_file_returns_default() -> anyhow::Result<()> {
1255        let dir = {
1256            std::fs::create_dir_all("tmp")?;
1257            TempDir::new_in("tmp")?
1258        };
1259
1260        let rules = load_commit_rules(dir.path());
1261        assert_eq!(
1262            rules.subject_max_len,
1263            CommitRules::default().subject_max_len
1264        );
1265        Ok(())
1266    }
1267
1268    #[test]
1269    fn load_commit_rules_with_valid_yaml() -> anyhow::Result<()> {
1270        let dir = {
1271            std::fs::create_dir_all("tmp")?;
1272            TempDir::new_in("tmp")?
1273        };
1274        let rules_yaml = r#"
1275subject_max_len: 50
1276types:
1277  - feat
1278  - fix
1279require_scope: true
1280forbidden_footers:
1281  - "Co-Authored-By"
1282"#;
1283        std::fs::write(dir.path().join("commit-rules.yaml"), rules_yaml)?;
1284
1285        let rules = load_commit_rules(dir.path());
1286        assert_eq!(rules.subject_max_len, 50);
1287        assert!(rules.require_scope);
1288        assert_eq!(rules.types, vec!["feat".to_string(), "fix".to_string()]);
1289        Ok(())
1290    }
1291
1292    #[test]
1293    fn load_commit_rules_with_malformed_yaml_falls_back_to_default() -> anyhow::Result<()> {
1294        let dir = {
1295            std::fs::create_dir_all("tmp")?;
1296            TempDir::new_in("tmp")?
1297        };
1298        std::fs::write(dir.path().join("commit-rules.yaml"), "not: [valid: yaml")?;
1299
1300        let rules = load_commit_rules(dir.path());
1301        assert_eq!(
1302            rules.subject_max_len,
1303            CommitRules::default().subject_max_len
1304        );
1305        Ok(())
1306    }
1307
1308    #[test]
1309    fn load_commit_rules_unreadable_file_falls_back_to_default() -> anyhow::Result<()> {
1310        let dir = {
1311            std::fs::create_dir_all("tmp")?;
1312            TempDir::new_in("tmp")?
1313        };
1314        // A directory named `commit-rules.yaml` exists, so it passes the
1315        // `.exists()` check but fails `fs::read_to_string` (not a regular file).
1316        std::fs::create_dir_all(dir.path().join("commit-rules.yaml"))?;
1317
1318        let rules = load_commit_rules(dir.path());
1319        assert_eq!(
1320            rules.subject_max_len,
1321            CommitRules::default().subject_max_len
1322        );
1323        Ok(())
1324    }
1325
1326    // ── Helper functions ─────────────────────────────────────────────
1327
1328    #[test]
1329    fn extract_scope_from_structure_src() {
1330        assert_eq!(
1331            extract_scope_from_structure("- `src/auth/` - Authentication"),
1332            Some("auth".to_string())
1333        );
1334    }
1335
1336    #[test]
1337    fn extract_scope_from_structure_no_match() {
1338        assert_eq!(extract_scope_from_structure("No source paths here"), None);
1339    }
1340
1341    #[test]
1342    fn extract_commit_types_from_line() {
1343        let types = extract_commit_types("feat, fix, docs, test");
1344        assert!(types.contains(&"feat".to_string()));
1345        assert!(types.contains(&"fix".to_string()));
1346        assert!(types.contains(&"docs".to_string()));
1347        assert!(types.contains(&"test".to_string()));
1348    }
1349
1350    #[test]
1351    fn extract_commit_types_empty_line() {
1352        let types = extract_commit_types("no types here");
1353        assert!(types.is_empty());
1354    }
1355
1356    // ── resolve_context_dir ────────────────────────────────────────────
1357
1358    // These tests inject a `MapEnv` into the `*_env` seam instead of mutating
1359    // `OMNI_DEV_CONFIG_DIR`, so they need no lock and run fully in parallel
1360    // (STYLE-0028, issue #821).
1361
1362    #[test]
1363    fn context_dir_defaults_to_omni_dev() {
1364        let result = resolve_context_dir_with_source_env(None, &MapEnv::new()).0;
1365        // Walk-up may find .omni-dev in the real repo, or fall back to ".omni-dev"
1366        assert!(
1367            result.ends_with(".omni-dev"),
1368            "expected path ending in .omni-dev, got {result:?}"
1369        );
1370    }
1371
1372    #[test]
1373    fn context_dir_uses_override() {
1374        let custom = PathBuf::from("custom-config");
1375        let result = resolve_context_dir_with_source_env(Some(&custom), &MapEnv::new()).0;
1376        assert_eq!(result, custom);
1377    }
1378
1379    #[test]
1380    fn context_dir_env_var() {
1381        let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/my-config");
1382        let result = resolve_context_dir_with_source_env(None, &env).0;
1383        assert_eq!(result, PathBuf::from("/tmp/my-config"));
1384    }
1385
1386    #[test]
1387    fn context_dir_cli_flag_beats_env_var() {
1388        let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1389        let cli = PathBuf::from("cli-config");
1390        let result = resolve_context_dir_with_source_env(Some(&cli), &env).0;
1391        assert_eq!(result, cli);
1392    }
1393
1394    #[test]
1395    fn context_dir_ignores_empty_env_var() {
1396        let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "");
1397        let result = resolve_context_dir_with_source_env(None, &env).0;
1398        // Walk-up may find .omni-dev in the real repo, or fall back to ".omni-dev"
1399        assert!(
1400            result.ends_with(".omni-dev"),
1401            "expected path ending in .omni-dev, got {result:?}"
1402        );
1403    }
1404
1405    // ── resolve_context_dir_with_source ─────────────────────────────────
1406
1407    #[test]
1408    fn with_source_cli_flag() {
1409        let custom = PathBuf::from("custom-config");
1410        let (path, source) = resolve_context_dir_with_source_env(Some(&custom), &MapEnv::new());
1411        assert_eq!(path, custom);
1412        assert_eq!(source, ConfigDirSource::CliFlag);
1413    }
1414
1415    #[test]
1416    fn with_source_env_var() {
1417        let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1418        let (path, source) = resolve_context_dir_with_source_env(None, &env);
1419        assert_eq!(path, PathBuf::from("/tmp/env-config"));
1420        assert_eq!(source, ConfigDirSource::EnvVar);
1421    }
1422
1423    #[test]
1424    fn with_source_cli_beats_env() {
1425        let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1426        let custom = PathBuf::from("cli-config");
1427        let (path, source) = resolve_context_dir_with_source_env(Some(&custom), &env);
1428        assert_eq!(path, custom);
1429        assert_eq!(source, ConfigDirSource::CliFlag);
1430    }
1431
1432    #[test]
1433    fn with_source_walk_up_or_default() {
1434        let (path, source) = resolve_context_dir_with_source_env(None, &MapEnv::new());
1435        // Inside this repo, walk-up finds .omni-dev; outside, falls back to default
1436        assert!(
1437            path.ends_with(".omni-dev"),
1438            "expected path ending in .omni-dev, got {path:?}"
1439        );
1440        assert!(
1441            source == ConfigDirSource::WalkUp || source == ConfigDirSource::Default,
1442            "expected WalkUp or Default, got {source:?}"
1443        );
1444    }
1445
1446    // ── repo-anchored `_at` variants (#967) ──────────────────────────────
1447
1448    #[test]
1449    fn with_source_at_cli_flag() {
1450        let custom = PathBuf::from("custom-config");
1451        let (path, source) = resolve_context_dir_with_source_at_env(
1452            Some(&custom),
1453            std::path::Path::new("/unused"),
1454            &MapEnv::new(),
1455        );
1456        assert_eq!(path, custom);
1457        assert_eq!(source, ConfigDirSource::CliFlag);
1458    }
1459
1460    #[test]
1461    fn with_source_at_env_var() {
1462        let env = MapEnv::new().with("OMNI_DEV_CONFIG_DIR", "/tmp/env-config");
1463        let (path, source) =
1464            resolve_context_dir_with_source_at_env(None, std::path::Path::new("/unused"), &env);
1465        assert_eq!(path, PathBuf::from("/tmp/env-config"));
1466        assert_eq!(source, ConfigDirSource::EnvVar);
1467    }
1468
1469    #[test]
1470    fn with_source_at_default_anchors_to_repo_root() {
1471        // A repo root with a `.git` boundary but no `.omni-dev`: walk-up stops at
1472        // the boundary without escaping, so the default anchors to
1473        // `repo_root/.omni-dev` (NOT the CWD-relative `.omni-dev`). This is the
1474        // distinguishing behavior of the `_at` variant vs. its CWD sibling.
1475        let tmp = tempfile::tempdir().unwrap();
1476        std::fs::create_dir(tmp.path().join(".git")).unwrap();
1477        let (path, source) =
1478            resolve_context_dir_with_source_at_env(None, tmp.path(), &MapEnv::new());
1479        assert_eq!(path, tmp.path().join(".omni-dev"));
1480        assert_eq!(source, ConfigDirSource::Default);
1481        // The thin `resolve_context_dir_at` wrapper discards the source. It reads
1482        // the real env, but no test mutates `OMNI_DEV_CONFIG_DIR` any more, so it
1483        // is race-free and returns the same default path.
1484        assert_eq!(
1485            resolve_context_dir_at(None, tmp.path()),
1486            tmp.path().join(".omni-dev")
1487        );
1488    }
1489
1490    // ── ConfigDirSource Display ──────────────────────────────────────────
1491
1492    #[test]
1493    fn display_config_dir_source_cli_flag() {
1494        assert_eq!(ConfigDirSource::CliFlag.to_string(), "--context-dir");
1495    }
1496
1497    #[test]
1498    fn display_config_dir_source_env_var() {
1499        assert_eq!(ConfigDirSource::EnvVar.to_string(), "OMNI_DEV_CONFIG_DIR");
1500    }
1501
1502    #[test]
1503    fn display_config_dir_source_walk_up() {
1504        assert_eq!(ConfigDirSource::WalkUp.to_string(), "walk-up");
1505    }
1506
1507    #[test]
1508    fn display_config_dir_source_default() {
1509        assert_eq!(ConfigDirSource::Default.to_string(), "default");
1510    }
1511
1512    // ── load_config_content ────────────────────────────────────────────
1513
1514    #[test]
1515    fn load_config_content_reads_project_file() -> anyhow::Result<()> {
1516        let dir = {
1517            std::fs::create_dir_all("tmp")?;
1518            TempDir::new_in("tmp")?
1519        };
1520        let base = dir.path();
1521
1522        std::fs::write(
1523            base.join("commit-guidelines.md"),
1524            "# Guidelines\nBe concise.",
1525        )?;
1526
1527        let content = load_config_content(base, "commit-guidelines.md")?;
1528        assert_eq!(content, Some("# Guidelines\nBe concise.".to_string()));
1529        Ok(())
1530    }
1531
1532    #[test]
1533    fn load_config_content_prefers_local_override() -> anyhow::Result<()> {
1534        let dir = {
1535            std::fs::create_dir_all("tmp")?;
1536            TempDir::new_in("tmp")?
1537        };
1538        let base = dir.path();
1539
1540        std::fs::create_dir_all(base.join("local"))?;
1541        std::fs::write(base.join("local").join("guidelines.md"), "local content")?;
1542        std::fs::write(base.join("guidelines.md"), "project content")?;
1543
1544        let content = load_config_content(base, "guidelines.md")?;
1545        assert_eq!(content, Some("local content".to_string()));
1546        Ok(())
1547    }
1548
1549    #[test]
1550    fn load_config_content_returns_none_when_missing() -> anyhow::Result<()> {
1551        let dir = {
1552            std::fs::create_dir_all("tmp")?;
1553            TempDir::new_in("tmp")?
1554        };
1555
1556        let content = load_config_content(dir.path(), "nonexistent.md")?;
1557        assert_eq!(content, None);
1558        Ok(())
1559    }
1560
1561    // ── config_source_label ────────────────────────────────────────────
1562
1563    #[test]
1564    fn source_label_local_override() -> anyhow::Result<()> {
1565        let dir = {
1566            std::fs::create_dir_all("tmp")?;
1567            TempDir::new_in("tmp")?
1568        };
1569        let base = dir.path();
1570
1571        std::fs::create_dir_all(base.join("local"))?;
1572        std::fs::write(base.join("local").join("scopes.yaml"), "local")?;
1573        std::fs::write(base.join("scopes.yaml"), "project")?;
1574
1575        let label = config_source_label(base, "scopes.yaml");
1576        assert_eq!(
1577            label,
1578            ConfigSourceLabel::LocalOverride(base.join("local").join("scopes.yaml"))
1579        );
1580        Ok(())
1581    }
1582
1583    #[test]
1584    fn source_label_project() -> anyhow::Result<()> {
1585        let dir = {
1586            std::fs::create_dir_all("tmp")?;
1587            TempDir::new_in("tmp")?
1588        };
1589        let base = dir.path();
1590
1591        std::fs::write(base.join("scopes.yaml"), "project")?;
1592
1593        let label = config_source_label(base, "scopes.yaml");
1594        assert_eq!(label, ConfigSourceLabel::Project(base.join("scopes.yaml")));
1595        Ok(())
1596    }
1597
1598    #[test]
1599    fn source_label_not_found() {
1600        let dir = {
1601            std::fs::create_dir_all("tmp").ok();
1602            TempDir::new_in("tmp").unwrap()
1603        };
1604
1605        let label = config_source_label(dir.path(), "nonexistent.yaml");
1606        assert_eq!(label, ConfigSourceLabel::NotFound);
1607    }
1608
1609    // ── ConfigSourceLabel Display ──────────────────────────────────────
1610
1611    #[test]
1612    fn display_local_override() {
1613        let label = ConfigSourceLabel::LocalOverride(PathBuf::from(".omni-dev/local/scopes.yaml"));
1614        assert_eq!(
1615            label.to_string(),
1616            "Local override: .omni-dev/local/scopes.yaml"
1617        );
1618    }
1619
1620    #[test]
1621    fn display_project() {
1622        let label = ConfigSourceLabel::Project(PathBuf::from(".omni-dev/scopes.yaml"));
1623        assert_eq!(label.to_string(), "Project: .omni-dev/scopes.yaml");
1624    }
1625
1626    #[test]
1627    fn display_global() {
1628        let label = ConfigSourceLabel::Global(PathBuf::from("/home/user/.omni-dev/scopes.yaml"));
1629        assert_eq!(
1630            label.to_string(),
1631            "Global: /home/user/.omni-dev/scopes.yaml"
1632        );
1633    }
1634
1635    #[test]
1636    fn display_xdg() {
1637        let label =
1638            ConfigSourceLabel::Xdg(PathBuf::from("/home/user/.config/omni-dev/scopes.yaml"));
1639        assert_eq!(
1640            label.to_string(),
1641            "Global (XDG): /home/user/.config/omni-dev/scopes.yaml"
1642        );
1643    }
1644
1645    #[test]
1646    fn display_not_found() {
1647        let label = ConfigSourceLabel::NotFound;
1648        assert_eq!(label.to_string(), "(not found)");
1649    }
1650
1651    // ── xdg_config_dir ─────────────────────────────────────────────────
1652
1653    #[test]
1654    fn xdg_config_dir_uses_env_var() {
1655        let env = MapEnv::new().with("XDG_CONFIG_HOME", "/tmp/xdg-test");
1656        let result = xdg_config_dir_with(&env, None);
1657        assert_eq!(result, Some(PathBuf::from("/tmp/xdg-test/omni-dev")));
1658    }
1659
1660    #[test]
1661    fn xdg_config_dir_ignores_empty_env_var() {
1662        // An empty `XDG_CONFIG_HOME` falls back to `$HOME/.config/omni-dev`.
1663        let env = MapEnv::new().with("XDG_CONFIG_HOME", "");
1664        let home = Path::new("/test-home");
1665        let result = xdg_config_dir_with(&env, Some(home));
1666        assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1667    }
1668
1669    #[test]
1670    fn xdg_config_dir_defaults_to_home_config() {
1671        // With `XDG_CONFIG_HOME` unset, the injected home base is used.
1672        let home = Path::new("/test-home");
1673        let result = xdg_config_dir_with(&MapEnv::new(), Some(home));
1674        assert_eq!(result, Some(home.join(".config").join("omni-dev")));
1675    }
1676
1677    // ── resolve_config_file XDG integration ─────────────────────────────
1678
1679    /// Builds a `MapEnv` pointing `XDG_CONFIG_HOME` at `path`, for the
1680    /// `resolve_config_file_with` / `config_source_label_with` seams.
1681    fn xdg_env(path: &Path) -> MapEnv {
1682        MapEnv::new().with(
1683            "XDG_CONFIG_HOME",
1684            path.to_str().expect("temp path is valid UTF-8"),
1685        )
1686    }
1687
1688    #[test]
1689    fn resolve_config_file_finds_xdg() -> anyhow::Result<()> {
1690        let xdg_dir = {
1691            std::fs::create_dir_all("tmp")?;
1692            TempDir::new_in("tmp")?
1693        };
1694        let xdg_omni = xdg_dir.path().join("omni-dev");
1695        std::fs::create_dir_all(&xdg_omni)?;
1696        std::fs::write(xdg_omni.join("commit-guidelines.md"), "xdg content")?;
1697
1698        let project_dir = {
1699            std::fs::create_dir_all("tmp")?;
1700            TempDir::new_in("tmp")?
1701        };
1702        let resolved = resolve_config_file_with(
1703            project_dir.path(),
1704            "commit-guidelines.md",
1705            &xdg_env(xdg_dir.path()),
1706            None,
1707        );
1708
1709        assert_eq!(resolved, xdg_omni.join("commit-guidelines.md"));
1710        Ok(())
1711    }
1712
1713    #[test]
1714    fn resolve_config_file_xdg_beats_home() -> anyhow::Result<()> {
1715        // Set up XDG config
1716        let xdg_dir = {
1717            std::fs::create_dir_all("tmp")?;
1718            TempDir::new_in("tmp")?
1719        };
1720        let xdg_omni = xdg_dir.path().join("omni-dev");
1721        std::fs::create_dir_all(&xdg_omni)?;
1722        std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1723
1724        // Project dir with no local config
1725        let project_dir = {
1726            std::fs::create_dir_all("tmp")?;
1727            TempDir::new_in("tmp")?
1728        };
1729
1730        let resolved = resolve_config_file_with(
1731            project_dir.path(),
1732            "scopes.yaml",
1733            &xdg_env(xdg_dir.path()),
1734            None,
1735        );
1736
1737        // XDG path should win (home path only wins if XDG doesn't have the file)
1738        assert_eq!(resolved, xdg_omni.join("scopes.yaml"));
1739        Ok(())
1740    }
1741
1742    #[test]
1743    fn resolve_config_file_project_beats_xdg() -> anyhow::Result<()> {
1744        // Set up XDG config
1745        let xdg_dir = {
1746            std::fs::create_dir_all("tmp")?;
1747            TempDir::new_in("tmp")?
1748        };
1749        let xdg_omni = xdg_dir.path().join("omni-dev");
1750        std::fs::create_dir_all(&xdg_omni)?;
1751        std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1752
1753        // Project dir with project-level config
1754        let project_dir = {
1755            std::fs::create_dir_all("tmp")?;
1756            TempDir::new_in("tmp")?
1757        };
1758        std::fs::write(project_dir.path().join("scopes.yaml"), "project")?;
1759
1760        let resolved = resolve_config_file_with(
1761            project_dir.path(),
1762            "scopes.yaml",
1763            &xdg_env(xdg_dir.path()),
1764            None,
1765        );
1766
1767        // Project path should win over XDG
1768        assert_eq!(resolved, project_dir.path().join("scopes.yaml"));
1769        Ok(())
1770    }
1771
1772    // ── config_source_label XDG integration ────────────────────────────
1773
1774    #[test]
1775    fn source_label_xdg() -> anyhow::Result<()> {
1776        let xdg_dir = {
1777            std::fs::create_dir_all("tmp")?;
1778            TempDir::new_in("tmp")?
1779        };
1780        let xdg_omni = xdg_dir.path().join("omni-dev");
1781        std::fs::create_dir_all(&xdg_omni)?;
1782        std::fs::write(xdg_omni.join("scopes.yaml"), "xdg")?;
1783
1784        let project_dir = {
1785            std::fs::create_dir_all("tmp")?;
1786            TempDir::new_in("tmp")?
1787        };
1788        let label = config_source_label_with(
1789            project_dir.path(),
1790            "scopes.yaml",
1791            &xdg_env(xdg_dir.path()),
1792            None,
1793        );
1794
1795        assert_eq!(label, ConfigSourceLabel::Xdg(xdg_omni.join("scopes.yaml")));
1796        Ok(())
1797    }
1798
1799    // ── walk_up_find_config_dir ─────────────────────────────────────────
1800
1801    /// Creates a mock repo tree with `.git` at the root.
1802    /// Returns (root_dir, TempDir handle).
1803    fn make_repo_tree() -> anyhow::Result<TempDir> {
1804        let dir = {
1805            std::fs::create_dir_all("tmp")?;
1806            TempDir::new_in("tmp")?
1807        };
1808        // Create .git marker at root
1809        std::fs::create_dir(dir.path().join(".git"))?;
1810        Ok(dir)
1811    }
1812
1813    #[test]
1814    fn walk_up_finds_omni_dev_in_start_dir() -> anyhow::Result<()> {
1815        let repo = make_repo_tree()?;
1816        let sub = repo.path().join("packages").join("frontend");
1817        std::fs::create_dir_all(&sub)?;
1818        std::fs::create_dir(sub.join(".omni-dev"))?;
1819
1820        let result = walk_up_find_config_dir(&sub);
1821        assert_eq!(result, Some(sub.join(".omni-dev")));
1822        Ok(())
1823    }
1824
1825    #[test]
1826    fn walk_up_finds_omni_dev_in_parent() -> anyhow::Result<()> {
1827        let repo = make_repo_tree()?;
1828        let pkg = repo.path().join("packages").join("frontend");
1829        let src = pkg.join("src");
1830        std::fs::create_dir_all(&src)?;
1831        std::fs::create_dir(pkg.join(".omni-dev"))?;
1832
1833        let result = walk_up_find_config_dir(&src);
1834        assert_eq!(result, Some(pkg.join(".omni-dev")));
1835        Ok(())
1836    }
1837
1838    #[test]
1839    fn walk_up_finds_omni_dev_at_repo_root() -> anyhow::Result<()> {
1840        let repo = make_repo_tree()?;
1841        let deep = repo.path().join("a").join("b").join("c");
1842        std::fs::create_dir_all(&deep)?;
1843        std::fs::create_dir(repo.path().join(".omni-dev"))?;
1844
1845        let result = walk_up_find_config_dir(&deep);
1846        assert_eq!(result, Some(repo.path().join(".omni-dev")));
1847        Ok(())
1848    }
1849
1850    #[test]
1851    fn walk_up_nearest_wins() -> anyhow::Result<()> {
1852        let repo = make_repo_tree()?;
1853        let pkg = repo.path().join("packages").join("frontend");
1854        let src = pkg.join("src");
1855        std::fs::create_dir_all(&src)?;
1856        // Both root and package have .omni-dev
1857        std::fs::create_dir(repo.path().join(".omni-dev"))?;
1858        std::fs::create_dir(pkg.join(".omni-dev"))?;
1859
1860        let result = walk_up_find_config_dir(&src);
1861        // Nearest (packages/frontend/.omni-dev) should win
1862        assert_eq!(result, Some(pkg.join(".omni-dev")));
1863        Ok(())
1864    }
1865
1866    #[test]
1867    fn walk_up_stops_at_git_boundary() -> anyhow::Result<()> {
1868        let dir = {
1869            std::fs::create_dir_all("tmp")?;
1870            TempDir::new_in("tmp")?
1871        };
1872        // Parent has .omni-dev but is outside the repo
1873        std::fs::create_dir(dir.path().join(".omni-dev"))?;
1874        // Repo root is a subdirectory
1875        let repo_root = dir.path().join("repo");
1876        std::fs::create_dir_all(&repo_root)?;
1877        std::fs::create_dir(repo_root.join(".git"))?;
1878        let sub = repo_root.join("sub");
1879        std::fs::create_dir(&sub)?;
1880
1881        let result = walk_up_find_config_dir(&sub);
1882        // Should NOT find the .omni-dev above .git
1883        assert_eq!(result, None);
1884        Ok(())
1885    }
1886
1887    #[test]
1888    fn walk_up_returns_none_when_no_omni_dev() -> anyhow::Result<()> {
1889        let repo = make_repo_tree()?;
1890        let sub = repo.path().join("src");
1891        std::fs::create_dir(&sub)?;
1892
1893        let result = walk_up_find_config_dir(&sub);
1894        assert_eq!(result, None);
1895        Ok(())
1896    }
1897
1898    #[test]
1899    fn walk_up_handles_git_worktree_file() -> anyhow::Result<()> {
1900        let dir = {
1901            std::fs::create_dir_all("tmp")?;
1902            TempDir::new_in("tmp")?
1903        };
1904        // .git as a file (worktree)
1905        std::fs::write(dir.path().join(".git"), "gitdir: /some/path")?;
1906        std::fs::create_dir(dir.path().join(".omni-dev"))?;
1907        let sub = dir.path().join("src");
1908        std::fs::create_dir(&sub)?;
1909
1910        let result = walk_up_find_config_dir(&sub);
1911        assert_eq!(result, Some(dir.path().join(".omni-dev")));
1912        Ok(())
1913    }
1914
1915    #[test]
1916    fn walk_up_no_omni_dev_in_repo_returns_none() -> anyhow::Result<()> {
1917        // Repo with .git but no .omni-dev anywhere
1918        let repo = make_repo_tree()?;
1919        let sub = repo.path().join("a").join("b");
1920        std::fs::create_dir_all(&sub)?;
1921        let result = walk_up_find_config_dir(&sub);
1922        assert_eq!(result, None);
1923        Ok(())
1924    }
1925}