Skip to main content

omni_dev/cli/config/
scopes.rs

1//! `config scopes` CLI commands — scope-taxonomy operations on
2//! `.omni-dev/scopes.yaml`.
3//!
4//! `usage` tallies declared commit scopes against `scopes.yaml`; `lint`
5//! validates `scopes.yaml` against the source tree it claims to describe
6//! (issue #1475). Lint is zero AI, zero network: both its assertions are
7//! pure globset matching against the tracked-file list, reusing
8//! `crate::git::commit::scope_matches_files` and `crate::git::resolve_scope`
9//! rather than a second matcher.
10
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use clap::{Parser, Subcommand};
15use globset::GlobMatcher;
16
17use crate::data::check::OutputFormat;
18use crate::data::context::ScopeDefinition;
19use crate::data::scopes_lint::{DeadPattern, ScopesLintReport};
20use crate::git::commit::{tally_scope_usage, ScopeUsageReport};
21
22/// Scopes operations.
23#[derive(Parser)]
24pub struct ScopesCommand {
25    /// Scopes subcommand to execute.
26    #[command(subcommand)]
27    pub command: ScopesSubcommands,
28}
29
30/// Scopes subcommands.
31#[derive(Subcommand)]
32pub enum ScopesSubcommands {
33    /// Tallies declared commit scopes against `scopes.yaml`, reporting
34    /// unknown, unused, and scope-less commits.
35    Usage(UsageCommand),
36    /// Validates scopes.yaml against the source tree: every `file_patterns`
37    /// entry must match a tracked file, and every tracked file under
38    /// `--root` must be matched by some scope.
39    Lint(LintCommand),
40}
41
42impl ScopesCommand {
43    /// Executes the scopes command.
44    pub fn execute(self, repo: Option<&Path>) -> Result<()> {
45        match self.command {
46            ScopesSubcommands::Usage(usage_cmd) => usage_cmd.execute(repo),
47            ScopesSubcommands::Lint(cmd) => cmd.execute(repo),
48        }
49    }
50}
51
52/// Usage command options — tallies declared commit scopes against history.
53#[derive(Parser)]
54pub struct UsageCommand {
55    /// Commit range to analyze (e.g., HEAD~300..HEAD, abc123..def456).
56    /// Defaults to the entire history reachable from HEAD.
57    #[arg(value_name = "COMMIT_RANGE", conflicts_with = "max_count")]
58    pub commit_range: Option<String>,
59
60    /// Limits analysis to the newest N commits reachable from HEAD (ignored
61    /// when COMMIT_RANGE is given). Useful for comparing the answer at
62    /// different window sizes, e.g. `-n 150` vs `-n 400`.
63    #[arg(short = 'n', long = "max-count", value_name = "N")]
64    pub max_count: Option<usize>,
65
66    /// Path to custom context directory (defaults to .omni-dev/).
67    #[arg(long)]
68    pub context_dir: Option<PathBuf>,
69
70    /// Excludes ecosystem default scopes (cargo/core/lib/test, …) from the
71    /// known set, so they are reported as unknown rather than accepted.
72    #[arg(long)]
73    pub project_only: bool,
74
75    /// Output format.
76    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
77    pub output: OutputFormat,
78}
79
80impl UsageCommand {
81    /// Executes the usage command: tallies declared scopes across the
82    /// resolved commit range and prints a report. Always exits 0 on a
83    /// successful tally (including an empty range) — this is a reporting
84    /// command, never a gate.
85    pub fn execute(self, repo: Option<&Path>) -> Result<()> {
86        let repo_root = match repo {
87            Some(p) => p.to_path_buf(),
88            None => std::env::current_dir().context("Failed to determine current directory")?,
89        };
90        let repo_root = repo_root.as_path();
91
92        let git_repo = crate::git::GitRepository::open_at(repo_root)
93            .context("Failed to open git repository at the given path")?;
94
95        let commits = match &self.commit_range {
96            Some(range) => git_repo.get_commits_in_range(range)?,
97            None => git_repo.get_commits_from_head(self.max_count)?,
98        };
99
100        let subjects: Vec<&str> = commits
101            .iter()
102            .map(|c| c.original_message.lines().next().unwrap_or("").trim())
103            .collect();
104
105        let context_dir =
106            crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
107        let scopes_yaml_only = crate::claude::context::load_project_scopes_only(&context_dir);
108        let known_scopes = if self.project_only {
109            scopes_yaml_only.clone()
110        } else {
111            crate::claude::context::load_project_scopes(&context_dir, repo_root)
112        };
113
114        let report = tally_scope_usage(&subjects, &known_scopes, &scopes_yaml_only);
115
116        self.output_report(&report)
117    }
118
119    /// Renders `report` per `self.output`.
120    fn output_report(&self, report: &ScopeUsageReport) -> Result<()> {
121        match self.output {
122            OutputFormat::Text => {
123                print!("{}", format_text_report(report));
124                Ok(())
125            }
126            OutputFormat::Json => {
127                let json = serde_json::to_string_pretty(report)
128                    .context("Failed to serialize report to JSON")?;
129                println!("{json}");
130                Ok(())
131            }
132            OutputFormat::Yaml => {
133                let yaml =
134                    crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
135                println!("{yaml}");
136                Ok(())
137            }
138        }
139    }
140}
141
142/// Formats a [`ScopeUsageReport`] as human-readable text.
143fn format_text_report(report: &ScopeUsageReport) -> String {
144    use std::fmt::Write;
145
146    let mut out = String::new();
147    let _ = writeln!(
148        out,
149        "📊 Scope usage: {} commit(s) analyzed",
150        report.total_commits
151    );
152    out.push('\n');
153
154    if report.declared.is_empty() {
155        out.push_str("Declared scopes: (none)\n");
156    } else {
157        out.push_str("Declared scopes:\n");
158        for sc in &report.declared {
159            let _ = writeln!(out, "  {:<20} {}", sc.name, sc.count);
160        }
161    }
162    out.push('\n');
163
164    if !report.unknown.is_empty() {
165        out.push_str("⚠️  Unknown (declared, not in scopes.yaml):\n");
166        for sc in &report.unknown {
167            let _ = writeln!(out, "  {:<20} {}", sc.name, sc.count);
168        }
169        out.push('\n');
170    }
171
172    if !report.unused.is_empty() {
173        out.push_str("💤 Unused (defined in scopes.yaml, never declared):\n");
174        for name in &report.unused {
175            let _ = writeln!(out, "  {name}");
176        }
177        out.push('\n');
178    }
179
180    let _ = writeln!(out, "Scope-less commits: {}", report.scope_less_count);
181
182    out
183}
184
185/// Lint command options.
186#[derive(Parser)]
187pub struct LintCommand {
188    /// Root path(s) to check for scope coverage (repeatable).
189    #[arg(long, default_value = "src")]
190    pub root: Vec<String>,
191
192    /// Disables `--project-only` (on by default): folds in the
193    /// ecosystem-detected default scopes (e.g. Rust's `lib` =
194    /// `["src/lib.rs", "src/**"]`) before checking coverage. Without
195    /// `--project-only`, a catch-all ecosystem scope can make the "every
196    /// file is scoped" check vacuously true.
197    #[arg(long)]
198    pub no_project_only: bool,
199
200    /// Additional glob(s) for paths that need no scope (repeatable).
201    /// Unioned with any `allow:` list in scopes.yaml.
202    #[arg(long)]
203    pub allow: Vec<String>,
204
205    /// Output format.
206    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
207    pub output: OutputFormat,
208
209    /// Path to custom context directory (defaults to .omni-dev/).
210    #[arg(long)]
211    pub context_dir: Option<PathBuf>,
212}
213
214impl LintCommand {
215    /// Executes the lint command.
216    pub fn execute(self, repo: Option<&Path>) -> Result<()> {
217        let repo_root = match repo {
218            Some(p) => p.to_path_buf(),
219            None => std::env::current_dir().context("Failed to determine current directory")?,
220        };
221        let repo_root = repo_root.as_path();
222
223        let report = run_scopes_lint(repo_root, &self)?;
224        render_report(&report, self.output)?;
225        std::process::exit(report.exit_code());
226    }
227}
228
229/// Runs the lint against a real repository.
230///
231/// Opens it, loads `scopes.yaml` strictly, conditionally merges ecosystem
232/// defaults, then delegates to the pure [`lint_scopes`]. Separated from
233/// [`LintCommand::execute`] so tests can drive it directly (STYLE-0025)
234/// without spawning the binary.
235pub fn run_scopes_lint(repo_root: &Path, cmd: &LintCommand) -> Result<ScopesLintReport> {
236    let repo = crate::git::GitRepository::open_at(repo_root)
237        .context("Failed to open git repository at the given path")?;
238    let tracked_files = repo.tracked_files()?;
239
240    let context_dir =
241        crate::claude::context::resolve_context_dir_at(cmd.context_dir.as_deref(), repo_root);
242    let scopes_file = crate::claude::context::load_scopes_file_strict(&context_dir)
243        .context("Failed to load .omni-dev/scopes.yaml")?;
244
245    let project_only = !cmd.no_project_only;
246    // `merge_ecosystem_scopes` only skips a default whose *name* already
247    // exists in the vector it's given, so it must be seeded with the
248    // project's own scopes — not called against an empty vector — or a
249    // same-named ecosystem default (e.g. Rust's `test`) gets added
250    // alongside the project's version instead of yielding to it
251    // (docs/plan/config-internals.md's documented "user-defined scopes
252    // always win" contract). Diff back out the seed so this stays the
253    // *additional* scopes assertion 2 folds in, as `lint_scopes` expects.
254    let ecosystem_scopes: Vec<ScopeDefinition> = if project_only {
255        Vec::new()
256    } else {
257        let mut combined = scopes_file.scopes.clone();
258        crate::claude::context::discovery::merge_ecosystem_scopes(&mut combined, repo_root);
259        combined
260            .into_iter()
261            .filter(|s| !scopes_file.scopes.iter().any(|p| p.name == s.name))
262            .collect()
263    };
264
265    let mut allow_globs = cmd.allow.clone();
266    allow_globs.extend(scopes_file.allow.iter().cloned());
267
268    Ok(lint_scopes(
269        &tracked_files,
270        &scopes_file.scopes,
271        &ecosystem_scopes,
272        &cmd.root,
273        &allow_globs,
274        project_only,
275    ))
276}
277
278/// Pure core of `omni-dev config scopes lint` — no filesystem or git I/O, so
279/// this is what the unit tests below drive directly.
280///
281/// - `tracked_files`: the full tracked-file set.
282/// - `project_scopes`: scopes parsed directly from `scopes.yaml`. Assertion
283///   1 (dead patterns) always checks only these — ecosystem scopes are Rust
284///   constants a user cannot fix by editing `scopes.yaml`, so flagging their
285///   patterns dead would not be actionable.
286/// - `ecosystem_scopes`: the scopes `merge_ecosystem_scopes` would inject
287///   for this repo. Folded into the effective scope set assertion 2 checks
288///   coverage against only when `project_only` is `false` — this function
289///   enforces that itself, so passing a non-empty `ecosystem_scopes`
290///   alongside `project_only: true` still excludes it.
291/// - `roots`: repo-relative root paths (no trailing slash) assertion 2
292///   walks.
293/// - `allow_globs`: globs suppressing assertion-2 violations for paths that
294///   legitimately need no scope.
295pub fn lint_scopes(
296    tracked_files: &[String],
297    project_scopes: &[ScopeDefinition],
298    ecosystem_scopes: &[ScopeDefinition],
299    roots: &[String],
300    allow_globs: &[String],
301    project_only: bool,
302) -> ScopesLintReport {
303    let file_refs: Vec<&str> = tracked_files.iter().map(String::as_str).collect();
304
305    let mut dead_patterns = dead_patterns(&file_refs, project_scopes);
306    dead_patterns.sort_by(|a, b| (&a.scope, &a.pattern).cmp(&(&b.scope, &b.pattern)));
307
308    // The pure core enforces `project_only` itself, rather than trusting
309    // the caller to have already passed an empty `ecosystem_scopes` — this
310    // is the load-bearing invariant the flag exists for, so it must not be
311    // bypassable by a caller mistake.
312    let mut effective_scopes: Vec<ScopeDefinition> = project_scopes.to_vec();
313    if !project_only {
314        effective_scopes.extend(ecosystem_scopes.iter().cloned());
315    }
316
317    let allow_matchers = build_allow_matchers(allow_globs);
318    let files_in_scope: Vec<&str> = file_refs
319        .iter()
320        .copied()
321        .filter(|f| file_in_roots(f, roots))
322        .filter(|f| !is_allowed(f, &allow_matchers))
323        .collect();
324
325    let mut unscoped_files = unscoped_files(&files_in_scope, &effective_scopes);
326    unscoped_files.sort();
327
328    ScopesLintReport {
329        roots: roots.to_vec(),
330        project_only,
331        scopes_checked: effective_scopes.len(),
332        files_checked: files_in_scope.len(),
333        dead_patterns,
334        unscoped_files,
335    }
336}
337
338/// Assertion 1: every non-negative `file_patterns` entry must match at
339/// least one tracked file. `!`-prefixed negative patterns are never
340/// reported dead — they exist to exclude matches, not to independently
341/// match anything.
342fn dead_patterns(files: &[&str], scopes: &[ScopeDefinition]) -> Vec<DeadPattern> {
343    let mut dead = Vec::new();
344    for scope in scopes {
345        for pattern in &scope.file_patterns {
346            if pattern.starts_with('!') {
347                continue;
348            }
349            let single = std::slice::from_ref(pattern);
350            if crate::git::commit::scope_matches_files(files, single).is_none() {
351                dead.push(DeadPattern {
352                    scope: scope.name.clone(),
353                    pattern: pattern.clone(),
354                });
355            }
356        }
357    }
358    dead
359}
360
361/// Assertion 2: every file in `files` must resolve to at least one scope.
362/// Calls `resolve_scope` once per file (never batched) so a facade gap like
363/// `src/foo/**` not covering `src/foo.rs` is caught per-file rather than
364/// masked by a sibling file matching the same scope.
365fn unscoped_files(files: &[&str], scopes: &[ScopeDefinition]) -> Vec<String> {
366    files
367        .iter()
368        .filter(|f| crate::git::resolve_scope(std::slice::from_ref(*f), scopes).is_none())
369        .map(|f| (*f).to_string())
370        .collect()
371}
372
373/// Whether `file` falls under one of `roots`. A bare `starts_with(root)`
374/// would wrongly match a sibling directory (root `src` matching
375/// `srcfoo/bar.rs`), so this requires an exact match or a `/`-bounded
376/// prefix.
377fn file_in_roots(file: &str, roots: &[String]) -> bool {
378    roots
379        .iter()
380        .any(|root| file == root || file.starts_with(&format!("{root}/")))
381}
382
383fn build_allow_matchers(allow_globs: &[String]) -> Vec<GlobMatcher> {
384    allow_globs
385        .iter()
386        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
387        .collect()
388}
389
390fn is_allowed(file: &str, matchers: &[GlobMatcher]) -> bool {
391    matchers.iter().any(|m| m.is_match(file))
392}
393
394/// Renders `report` to stdout in the requested `format` and returns.
395/// Exiting on the report's exit code is the caller's job.
396fn render_report(report: &ScopesLintReport, format: OutputFormat) -> Result<()> {
397    match format {
398        OutputFormat::Text => render_text_report(report),
399        OutputFormat::Json => {
400            println!("{}", serde_json::to_string_pretty(report)?);
401            Ok(())
402        }
403        OutputFormat::Yaml => {
404            println!("{}", crate::data::to_yaml(report)?);
405            Ok(())
406        }
407    }
408}
409
410fn render_text_report(report: &ScopesLintReport) -> Result<()> {
411    let project_only_label = if report.project_only {
412        "project-only"
413    } else {
414        "project + ecosystem"
415    };
416    println!("🔍 Linting .omni-dev/scopes.yaml against the source tree...");
417    println!(
418        "   📂 Scopes checked: {} ({})",
419        report.scopes_checked, project_only_label
420    );
421    println!("   📁 Roots: {}", report.roots.join(", "));
422    println!("   📄 Files checked: {}", report.files_checked);
423    println!();
424
425    if report.dead_patterns.is_empty() {
426        println!("✅ No dead patterns.");
427    } else {
428        println!("❌ Dead patterns ({}):", report.dead_patterns.len());
429        for dead in &report.dead_patterns {
430            println!("   {} ({})", dead.pattern, dead.scope);
431        }
432    }
433
434    if report.unscoped_files.is_empty() {
435        println!("✅ No unscoped files.");
436    } else {
437        println!("❌ Unscoped files ({}):", report.unscoped_files.len());
438        for file in &report.unscoped_files {
439            println!("   {file}");
440        }
441    }
442
443    let total = report.dead_patterns.len() + report.unscoped_files.len();
444    println!();
445    println!(
446        "Summary: {} scopes, {} files checked — {} violation{}",
447        report.scopes_checked,
448        report.files_checked,
449        total,
450        if total == 1 { "" } else { "s" }
451    );
452
453    Ok(())
454}
455
456#[cfg(test)]
457#[allow(clippy::unwrap_used, clippy::expect_used)]
458mod tests {
459    use super::*;
460    use crate::git::commit::ScopeCount;
461
462    // ── usage report ─────────────────────────────────────────────────
463
464    fn make_report() -> ScopeUsageReport {
465        ScopeUsageReport {
466            total_commits: 5,
467            declared: vec![
468                ScopeCount {
469                    name: "cli".to_string(),
470                    count: 3,
471                },
472                ScopeCount {
473                    name: "lib".to_string(),
474                    count: 1,
475                },
476            ],
477            unknown: vec![ScopeCount {
478                name: "lib".to_string(),
479                count: 1,
480            }],
481            unused: vec!["workflows".to_string()],
482            scope_less_count: 1,
483        }
484    }
485
486    #[test]
487    fn text_report_includes_all_sections() {
488        let report = make_report();
489        let text = format_text_report(&report);
490        assert!(text.contains("5 commit(s) analyzed"));
491        assert!(text.contains("cli"));
492        assert!(text.contains("Unknown"));
493        assert!(text.contains("lib"));
494        assert!(text.contains("Unused"));
495        assert!(text.contains("workflows"));
496        assert!(text.contains("Scope-less commits: 1"));
497    }
498
499    #[test]
500    fn text_report_empty_omits_unknown_and_unused_sections() {
501        let report = ScopeUsageReport {
502            total_commits: 0,
503            declared: vec![],
504            unknown: vec![],
505            unused: vec![],
506            scope_less_count: 0,
507        };
508        let text = format_text_report(&report);
509        assert!(text.contains("(none)"));
510        assert!(!text.contains("Unknown"));
511        assert!(!text.contains("Unused"));
512        assert!(text.contains("Scope-less commits: 0"));
513    }
514
515    #[test]
516    fn json_output_round_trips() {
517        let report = make_report();
518        let json = serde_json::to_string_pretty(&report).unwrap();
519        let parsed: ScopeUsageReport = serde_json::from_str(&json).unwrap();
520        assert_eq!(parsed, report);
521    }
522
523    // ── lint ────────────────────────────────────────────────────────
524
525    fn scope(name: &str, patterns: &[&str]) -> ScopeDefinition {
526        ScopeDefinition {
527            name: name.to_string(),
528            description: String::new(),
529            examples: Vec::new(),
530            file_patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
531        }
532    }
533
534    fn files(paths: &[&str]) -> Vec<String> {
535        paths.iter().map(|p| (*p).to_string()).collect()
536    }
537
538    // ── dead patterns ────────────────────────────────────────────────
539
540    #[test]
541    fn dead_pattern_reported_when_no_file_matches() {
542        let scopes = vec![scope("cli", &["src/cli/**"])];
543        let report = lint_scopes(
544            &files(&["src/git/commit.rs"]),
545            &scopes,
546            &[],
547            &["src".to_string()],
548            &[],
549            true,
550        );
551        assert_eq!(report.dead_patterns.len(), 1);
552        assert_eq!(report.dead_patterns[0].pattern, "src/cli/**");
553        assert_eq!(report.dead_patterns[0].scope, "cli");
554    }
555
556    #[test]
557    fn dead_pattern_not_reported_when_one_file_matches() {
558        let scopes = vec![scope("cli", &["src/cli/**"])];
559        let report = lint_scopes(
560            &files(&["src/cli/mod.rs"]),
561            &scopes,
562            &[],
563            &["src".to_string()],
564            &[],
565            true,
566        );
567        assert!(report.dead_patterns.is_empty());
568    }
569
570    #[test]
571    fn negative_pattern_never_reported_dead() {
572        // Neither the positive nor the negative pattern matches anything —
573        // only the positive one is a candidate for "dead"; the negative one
574        // must never be reported regardless of match outcome.
575        let scopes = vec![scope("cli", &["src/cli/**", "!src/cli/generated/**"])];
576        let report = lint_scopes(
577            &files(&["src/other.rs"]),
578            &scopes,
579            &[],
580            &["src".to_string()],
581            &[],
582            true,
583        );
584        assert_eq!(report.dead_patterns.len(), 1);
585        assert_eq!(report.dead_patterns[0].pattern, "src/cli/**");
586    }
587
588    // ── unscoped files ───────────────────────────────────────────────
589
590    #[test]
591    fn unscoped_file_reported_under_root() {
592        let scopes = vec![scope("cli", &["src/cli/**"])];
593        let report = lint_scopes(
594            &files(&["src/newmod/foo.rs"]),
595            &scopes,
596            &[],
597            &["src".to_string()],
598            &[],
599            true,
600        );
601        assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
602    }
603
604    #[test]
605    fn unscoped_file_cleared_by_covering_scope() {
606        let scopes = vec![
607            scope("cli", &["src/cli/**"]),
608            scope("newmod", &["src/newmod/**"]),
609        ];
610        let report = lint_scopes(
611            &files(&["src/newmod/foo.rs"]),
612            &scopes,
613            &[],
614            &["src".to_string()],
615            &[],
616            true,
617        );
618        assert!(report.unscoped_files.is_empty());
619    }
620
621    #[test]
622    fn facade_pattern_does_not_cover_sibling_file() {
623        // src/foo/** does not match src/foo.rs — pins the facade-gap
624        // semantics of the underlying resolve_scope/scope_matches_files.
625        let scopes = vec![scope("foo", &["src/foo/**"])];
626        let report = lint_scopes(
627            &files(&["src/foo.rs"]),
628            &scopes,
629            &[],
630            &["src".to_string()],
631            &[],
632            true,
633        );
634        assert_eq!(report.unscoped_files, vec!["src/foo.rs".to_string()]);
635    }
636
637    #[test]
638    fn allow_glob_suppresses_only_matching_paths() {
639        let scopes = vec![scope("cli", &["src/cli/**"])];
640        let report = lint_scopes(
641            &files(&["src/lib.rs", "src/newmod/foo.rs"]),
642            &scopes,
643            &[],
644            &["src".to_string()],
645            &["src/lib.rs".to_string()],
646            true,
647        );
648        assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
649    }
650
651    // ── --project-only regression (the one that matters most) ─────────
652
653    #[test]
654    fn project_only_true_reports_ecosystem_gap() {
655        let ecosystem = vec![scope("lib", &["src/lib.rs", "src/**"])];
656        let report = lint_scopes(
657            &files(&["src/newmod/foo.rs"]),
658            &[], // no project scopes at all
659            &ecosystem,
660            &["src".to_string()],
661            &[],
662            true, // project_only: ecosystem scopes excluded from coverage
663        );
664        assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
665    }
666
667    #[test]
668    fn project_only_false_uses_ecosystem_catchall() {
669        let ecosystem = vec![scope("lib", &["src/lib.rs", "src/**"])];
670        let report = lint_scopes(
671            &files(&["src/newmod/foo.rs"]),
672            &[],
673            &ecosystem,
674            &["src".to_string()],
675            &[],
676            false, // ecosystem scopes folded into coverage
677        );
678        assert!(report.unscoped_files.is_empty());
679    }
680
681    // ── file_in_roots ────────────────────────────────────────────────
682
683    #[test]
684    fn file_in_roots_rejects_sibling_prefix() {
685        assert!(!file_in_roots("srcfoo/bar.rs", &["src".to_string()]));
686    }
687
688    #[test]
689    fn file_in_roots_matches_root_itself_and_subpath() {
690        assert!(file_in_roots("src", &["src".to_string()]));
691        assert!(file_in_roots("src/main.rs", &["src".to_string()]));
692    }
693
694    #[test]
695    fn roots_filter_excludes_files_outside_root() {
696        let scopes = vec![scope("cli", &["src/cli/**"])];
697        let report = lint_scopes(
698            &files(&["docs/newmod.md"]),
699            &scopes,
700            &[],
701            &["src".to_string()],
702            &[],
703            true,
704        );
705        assert!(report.unscoped_files.is_empty());
706        assert_eq!(report.files_checked, 0);
707    }
708
709    #[test]
710    fn multiple_roots_both_checked() {
711        let scopes = vec![scope("cli", &["src/cli/**"])];
712        let report = lint_scopes(
713            &files(&["src/newmod/foo.rs", "editors/newmod/bar.ts"]),
714            &scopes,
715            &[],
716            &["src".to_string(), "editors".to_string()],
717            &[],
718            true,
719        );
720        assert_eq!(report.unscoped_files.len(), 2);
721    }
722
723    // ── report exit code ────────────────────────────────────────────
724
725    #[test]
726    fn report_exit_code_zero_when_clean() {
727        let scopes = vec![scope("cli", &["src/cli/**"])];
728        let report = lint_scopes(
729            &files(&["src/cli/mod.rs"]),
730            &scopes,
731            &[],
732            &["src".to_string()],
733            &[],
734            true,
735        );
736        assert_eq!(report.exit_code(), 0);
737    }
738
739    #[test]
740    fn report_exit_code_one_when_dead_pattern_only() {
741        let scopes = vec![scope("cli", &["src/cli/**"])];
742        let report = lint_scopes(&files(&[]), &scopes, &[], &["src".to_string()], &[], true);
743        assert_eq!(report.exit_code(), 1);
744    }
745
746    // ── CLI-level ────────────────────────────────────────────────────
747
748    #[test]
749    fn no_project_only_flag_parses() {
750        let cmd = LintCommand::try_parse_from(["lint", "--no-project-only"]).unwrap();
751        assert!(cmd.no_project_only);
752
753        let cmd = LintCommand::try_parse_from(["lint"]).unwrap();
754        assert!(!cmd.no_project_only);
755    }
756
757    #[test]
758    fn root_defaults_to_src() {
759        let cmd = LintCommand::try_parse_from(["lint"]).unwrap();
760        assert_eq!(cmd.root, vec!["src".to_string()]);
761    }
762
763    // ── render_report / render_text_report ──────────────────────────
764    //
765    // Exercised via direct calls rather than only end-to-end, because the
766    // "fully clean" render_text_report branches (both "No dead patterns"
767    // and "No unscoped files") are unreachable against this repo's own
768    // scopes.yaml: dead_patterns ignores `--root`/`--allow` entirely, and
769    // this repo currently carries one pre-existing dead pattern (scope
770    // "resources") that fixing is out of scope for #1475 — so no CLI
771    // invocation against the real tree can produce a clean report.
772
773    fn report(
774        project_only: bool,
775        dead_patterns: &[(&str, &str)],
776        unscoped_files: &[&str],
777    ) -> ScopesLintReport {
778        ScopesLintReport {
779            roots: vec!["src".to_string()],
780            project_only,
781            scopes_checked: 1,
782            files_checked: 1,
783            dead_patterns: dead_patterns
784                .iter()
785                .map(|(scope, pattern)| DeadPattern {
786                    scope: (*scope).to_string(),
787                    pattern: (*pattern).to_string(),
788                })
789                .collect(),
790            unscoped_files: unscoped_files.iter().map(|f| (*f).to_string()).collect(),
791        }
792    }
793
794    #[test]
795    fn render_report_dispatches_on_format() {
796        let clean = report(true, &[], &[]);
797        assert!(render_report(&clean, OutputFormat::Text).is_ok());
798        assert!(render_report(&clean, OutputFormat::Json).is_ok());
799        assert!(render_report(&clean, OutputFormat::Yaml).is_ok());
800    }
801
802    #[test]
803    fn render_text_report_project_only_with_violations_of_both_kinds() {
804        // project_only: true label, non-empty dead patterns (1 entry) and
805        // non-empty unscoped files (2 entries) loops, plural "violations".
806        let r = report(
807            true,
808            &[("cli", "src/cli/nonexistent/**")],
809            &["src/newmod/a.rs", "src/newmod/b.rs"],
810        );
811        assert!(render_text_report(&r).is_ok());
812    }
813
814    #[test]
815    fn render_text_report_ecosystem_label_with_single_violation() {
816        // project_only: false label, empty dead patterns checkmark, a
817        // single unscoped file (singular "violation").
818        let r = report(false, &[], &["src/newmod/a.rs"]);
819        assert!(render_text_report(&r).is_ok());
820    }
821
822    #[test]
823    fn render_text_report_fully_clean() {
824        // Both checkmark branches: "No dead patterns" and "No unscoped
825        // files" — unreachable end-to-end against this repo (see comment
826        // above), so only reachable via a synthetic report.
827        let r = report(true, &[], &[]);
828        assert!(render_text_report(&r).is_ok());
829    }
830
831    /// The `scopes` command dispatches to `lint`; a nonexistent repo path
832    /// makes the leaf command error before it ever reaches
833    /// `std::process::exit`, which exercises the dispatch path end-to-end
834    /// without needing a real repository (mirrors `coverage.rs`'s
835    /// `dispatches_to_diff`).
836    #[test]
837    fn dispatches_to_lint() {
838        let cmd = ScopesCommand {
839            command: ScopesSubcommands::Lint(LintCommand {
840                root: vec!["src".to_string()],
841                no_project_only: false,
842                allow: Vec::new(),
843                output: OutputFormat::Text,
844                context_dir: None,
845            }),
846        };
847        let result = cmd.execute(Some(Path::new("/nonexistent/repo/path")));
848        assert!(result.is_err());
849    }
850
851    // ── run_scopes_lint ecosystem-merge regression (issue #1475) ──────
852
853    /// Creates an empty git-inited tempdir under `$CARGO_MANIFEST_DIR/tmp`,
854    /// mirroring `git::repository`'s `init_tmp_repo` test helper.
855    fn init_tmp_git_repo() -> tempfile::TempDir {
856        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
857        std::fs::create_dir_all(&tmp_root).unwrap();
858        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
859        git2::Repository::init(temp_dir.path()).unwrap();
860        temp_dir
861    }
862
863    /// Stages every file in `dir` so `GitRepository::tracked_files` sees them.
864    fn git_add_all(dir: &Path) {
865        let status = std::process::Command::new("git")
866            .current_dir(dir)
867            .args(["add", "."])
868            .status()
869            .unwrap();
870        assert!(status.success());
871    }
872
873    /// Regression for the bug where `run_scopes_lint` seeded
874    /// `merge_ecosystem_scopes` with an empty vector instead of the
875    /// project's own scopes: a same-named ecosystem default (npm's `test`
876    /// = `["test/**", "tests/**", "**/*.test.js"]`) was added *alongside* a
877    /// narrower project-defined `test` scope instead of yielding to it,
878    /// silently widening coverage under `--no-project-only` beyond what
879    /// real `load_project_scopes` resolution grants —
880    /// `docs/plan/config-internals.md`'s documented "user-defined scopes
881    /// always win, matched by name" contract.
882    #[test]
883    fn no_project_only_does_not_duplicate_a_same_named_ecosystem_scope() {
884        let repo = init_tmp_git_repo();
885        let p = repo.path();
886        // package.json (not Cargo.toml) selects the npm ecosystem defaults,
887        // which — unlike Rust's `lib` = `src/**` — has no catch-all pattern
888        // that would mask the regression this test is pinning.
889        std::fs::write(p.join("package.json"), "{}").unwrap();
890        std::fs::create_dir_all(p.join("src/foo")).unwrap();
891        std::fs::write(p.join("src/foo/helper.test.js"), "// test helper").unwrap();
892        let context_dir = p.join(".omni-dev");
893        std::fs::create_dir_all(&context_dir).unwrap();
894        std::fs::write(
895            context_dir.join("scopes.yaml"),
896            r#"
897scopes:
898  - name: test
899    description: Narrow project test scope
900    examples: []
901    file_patterns:
902      - "tests/only/**"
903"#,
904        )
905        .unwrap();
906        git_add_all(p);
907
908        let cmd = LintCommand {
909            root: vec!["src".to_string()],
910            no_project_only: true, // --no-project-only: folds in ecosystem defaults
911            allow: Vec::new(),
912            output: OutputFormat::Text,
913            context_dir: Some(context_dir),
914        };
915        let report = run_scopes_lint(p, &cmd).expect("lint should run against the temp repo");
916
917        // npm's `test` = ["test/**", "tests/**", "**/*.test.js"] must be
918        // skipped by name — the project already defines `test` narrowly —
919        // so src/foo/helper.test.js (matched only by the ecosystem pattern)
920        // stays unscoped, exactly as real `load_project_scopes` resolution
921        // would leave it.
922        assert_eq!(
923            report.unscoped_files,
924            vec!["src/foo/helper.test.js".to_string()]
925        );
926        // Only the genuinely-new ecosystem scopes (deps, config, build,
927        // docs — 4 of npm's 5 defaults) are folded in alongside the
928        // project's own `test`: 1 + 4 = 5, never a duplicate `test` entry.
929        assert_eq!(report.scopes_checked, 5);
930    }
931}