1use 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#[derive(Parser)]
24pub struct ScopesCommand {
25 #[command(subcommand)]
27 pub command: ScopesSubcommands,
28
29 #[command(flatten)]
31 pub repo: crate::cli::repo_arg::RepoArg,
32}
33
34#[derive(Subcommand)]
36pub enum ScopesSubcommands {
37 Usage(UsageCommand),
40 Lint(LintCommand),
44}
45
46impl ScopesCommand {
47 pub fn execute(self) -> Result<()> {
50 let repo = self.repo.path();
51 match self.command {
52 ScopesSubcommands::Usage(usage_cmd) => usage_cmd.execute(repo),
53 ScopesSubcommands::Lint(cmd) => cmd.execute(repo),
54 }
55 }
56}
57
58#[derive(Parser)]
60pub struct UsageCommand {
61 #[arg(value_name = "COMMIT_RANGE", conflicts_with = "max_count")]
64 pub commit_range: Option<String>,
65
66 #[arg(short = 'n', long = "max-count", value_name = "N")]
70 pub max_count: Option<usize>,
71
72 #[arg(long)]
74 pub context_dir: Option<PathBuf>,
75
76 #[arg(long)]
79 pub project_only: bool,
80
81 #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
83 pub output: OutputFormat,
84}
85
86impl UsageCommand {
87 pub fn execute(self, repo: Option<&Path>) -> Result<()> {
92 let repo_root = match repo {
93 Some(p) => p.to_path_buf(),
94 None => std::env::current_dir().context("Failed to determine current directory")?,
95 };
96 let repo_root = repo_root.as_path();
97
98 let git_repo = crate::git::GitRepository::open_at(repo_root)
99 .context("Failed to open git repository at the given path")?;
100
101 let commits = match &self.commit_range {
102 Some(range) => git_repo.get_commits_in_range(range)?,
103 None => git_repo.get_commits_from_head(self.max_count)?,
104 };
105
106 let subjects: Vec<&str> = commits
107 .iter()
108 .map(|c| c.original_message.lines().next().unwrap_or("").trim())
109 .collect();
110
111 let context_dir =
112 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
113 let scopes_yaml_only = crate::claude::context::load_project_scopes_only(&context_dir);
114 let known_scopes = if self.project_only {
115 scopes_yaml_only.clone()
116 } else {
117 crate::claude::context::load_project_scopes(&context_dir, repo_root)
118 };
119
120 let report = tally_scope_usage(&subjects, &known_scopes, &scopes_yaml_only);
121
122 self.output_report(&report)
123 }
124
125 fn output_report(&self, report: &ScopeUsageReport) -> Result<()> {
127 match self.output {
128 OutputFormat::Text => print!("{}", format_text_report(report)),
129 OutputFormat::Json => {
130 let json = serde_json::to_string_pretty(report)
131 .context("Failed to serialize report to JSON")?;
132 println!("{json}");
133 }
134 OutputFormat::Yaml => {
135 let yaml =
136 crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
137 println!("{yaml}");
138 }
139 }
140 Ok(())
141 }
142}
143
144fn format_text_report(report: &ScopeUsageReport) -> String {
146 use std::fmt::Write;
147
148 let mut out = String::new();
149 let _ = writeln!(
150 out,
151 "📊 Scope usage: {} commit(s) analyzed",
152 report.total_commits
153 );
154 out.push('\n');
155
156 if report.declared.is_empty() {
157 out.push_str("Declared scopes: (none)\n");
158 } else {
159 out.push_str("Declared scopes:\n");
160 for sc in &report.declared {
161 let _ = writeln!(out, " {:<20} {}", sc.name, sc.count);
162 }
163 }
164 out.push('\n');
165
166 if !report.unknown.is_empty() {
167 out.push_str("⚠️ Unknown (declared, not in scopes.yaml):\n");
168 for sc in &report.unknown {
169 let _ = writeln!(out, " {:<20} {}", sc.name, sc.count);
170 }
171 out.push('\n');
172 }
173
174 if !report.unused.is_empty() {
175 out.push_str("💤 Unused (defined in scopes.yaml, never declared):\n");
176 for name in &report.unused {
177 let _ = writeln!(out, " {name}");
178 }
179 out.push('\n');
180 }
181
182 let _ = writeln!(out, "Scope-less commits: {}", report.scope_less_count);
183
184 out
185}
186
187#[derive(Parser)]
189pub struct LintCommand {
190 #[arg(long, default_value = "src")]
192 pub root: Vec<String>,
193
194 #[arg(long)]
200 pub no_project_only: bool,
201
202 #[arg(long)]
205 pub allow: Vec<String>,
206
207 #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
209 pub output: OutputFormat,
210
211 #[arg(long)]
213 pub context_dir: Option<PathBuf>,
214}
215
216impl LintCommand {
217 pub fn execute(self, repo: Option<&Path>) -> Result<()> {
219 let repo_root = match repo {
220 Some(p) => p.to_path_buf(),
221 None => std::env::current_dir().context("Failed to determine current directory")?,
222 };
223 let repo_root = repo_root.as_path();
224
225 let report = run_scopes_lint(repo_root, &self)?;
226 render_report(&report, self.output)?;
227 std::process::exit(report.exit_code());
228 }
229}
230
231pub fn run_scopes_lint(repo_root: &Path, cmd: &LintCommand) -> Result<ScopesLintReport> {
238 let repo = crate::git::GitRepository::open_at(repo_root)
239 .context("Failed to open git repository at the given path")?;
240 let tracked_files = repo.tracked_files()?;
241
242 let context_dir =
243 crate::claude::context::resolve_context_dir_at(cmd.context_dir.as_deref(), repo_root);
244 let scopes_file = crate::claude::context::load_scopes_file_strict(&context_dir)
245 .context("Failed to load .omni-dev/scopes.yaml")?;
246
247 let project_only = !cmd.no_project_only;
248 let ecosystem_scopes: Vec<ScopeDefinition> = if project_only {
257 Vec::new()
258 } else {
259 let mut combined = scopes_file.scopes.clone();
260 crate::claude::context::discovery::merge_ecosystem_scopes(&mut combined, repo_root);
261 combined
262 .into_iter()
263 .filter(|s| !scopes_file.scopes.iter().any(|p| p.name == s.name))
264 .collect()
265 };
266
267 let mut allow_globs = cmd.allow.clone();
268 allow_globs.extend(scopes_file.allow.iter().cloned());
269
270 Ok(lint_scopes(
271 &tracked_files,
272 &scopes_file.scopes,
273 &ecosystem_scopes,
274 &cmd.root,
275 &allow_globs,
276 project_only,
277 ))
278}
279
280pub fn lint_scopes(
298 tracked_files: &[String],
299 project_scopes: &[ScopeDefinition],
300 ecosystem_scopes: &[ScopeDefinition],
301 roots: &[String],
302 allow_globs: &[String],
303 project_only: bool,
304) -> ScopesLintReport {
305 let file_refs: Vec<&str> = tracked_files.iter().map(String::as_str).collect();
306
307 let mut dead_patterns = dead_patterns(&file_refs, project_scopes);
308 dead_patterns.sort_by(|a, b| (&a.scope, &a.pattern).cmp(&(&b.scope, &b.pattern)));
309
310 let mut effective_scopes: Vec<ScopeDefinition> = project_scopes.to_vec();
315 if !project_only {
316 effective_scopes.extend(ecosystem_scopes.iter().cloned());
317 }
318
319 let allow_matchers = build_allow_matchers(allow_globs);
320 let files_in_scope: Vec<&str> = file_refs
321 .iter()
322 .copied()
323 .filter(|f| file_in_roots(f, roots))
324 .filter(|f| !is_allowed(f, &allow_matchers))
325 .collect();
326
327 let mut unscoped_files = unscoped_files(&files_in_scope, &effective_scopes);
328 unscoped_files.sort();
329
330 ScopesLintReport {
331 roots: roots.to_vec(),
332 project_only,
333 scopes_checked: effective_scopes.len(),
334 files_checked: files_in_scope.len(),
335 dead_patterns,
336 unscoped_files,
337 }
338}
339
340fn dead_patterns(files: &[&str], scopes: &[ScopeDefinition]) -> Vec<DeadPattern> {
345 let mut dead = Vec::new();
346 for scope in scopes {
347 for pattern in &scope.file_patterns {
348 if pattern.starts_with('!') {
349 continue;
350 }
351 let single = std::slice::from_ref(pattern);
352 if crate::git::commit::scope_matches_files(files, single).is_none() {
353 dead.push(DeadPattern {
354 scope: scope.name.clone(),
355 pattern: pattern.clone(),
356 });
357 }
358 }
359 }
360 dead
361}
362
363fn unscoped_files(files: &[&str], scopes: &[ScopeDefinition]) -> Vec<String> {
368 files
369 .iter()
370 .filter(|f| crate::git::resolve_scope(std::slice::from_ref(*f), scopes).is_none())
371 .map(|f| (*f).to_string())
372 .collect()
373}
374
375fn file_in_roots(file: &str, roots: &[String]) -> bool {
380 roots
381 .iter()
382 .any(|root| file == root || file.starts_with(&format!("{root}/")))
383}
384
385fn build_allow_matchers(allow_globs: &[String]) -> Vec<GlobMatcher> {
386 allow_globs
387 .iter()
388 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
389 .collect()
390}
391
392fn is_allowed(file: &str, matchers: &[GlobMatcher]) -> bool {
393 matchers.iter().any(|m| m.is_match(file))
394}
395
396fn render_report(report: &ScopesLintReport, format: OutputFormat) -> Result<()> {
399 match format {
400 OutputFormat::Text => render_text_report(report),
401 OutputFormat::Json => {
402 println!("{}", serde_json::to_string_pretty(report)?);
403 Ok(())
404 }
405 OutputFormat::Yaml => {
406 println!("{}", crate::data::to_yaml(report)?);
407 Ok(())
408 }
409 }
410}
411
412const UNSCOPED_REMEDY: &str = "Fix each by adding a scope to scopes.yaml, extending an existing \
416scope's file_patterns, or — only for shared code with no subsystem owner — adding it to \
417`allow:` with a justification.";
418
419fn render_text_report(report: &ScopesLintReport) -> Result<()> {
420 let project_only_label = if report.project_only {
421 "project-only"
422 } else {
423 "project + ecosystem"
424 };
425 println!("🔍 Linting .omni-dev/scopes.yaml against the source tree...");
426 println!(
427 " 📂 Scopes checked: {} ({})",
428 report.scopes_checked, project_only_label
429 );
430 println!(" 📁 Roots: {}", report.roots.join(", "));
431 println!(" 📄 Files checked: {}", report.files_checked);
432 println!();
433
434 if report.dead_patterns.is_empty() {
435 println!("✅ No dead patterns.");
436 } else {
437 println!("❌ Dead patterns ({}):", report.dead_patterns.len());
438 for dead in &report.dead_patterns {
439 println!(" {} ({})", dead.pattern, dead.scope);
440 }
441 }
442
443 if report.unscoped_files.is_empty() {
444 println!("✅ No unscoped files.");
445 } else {
446 println!("❌ Unscoped files ({}):", report.unscoped_files.len());
447 for file in &report.unscoped_files {
448 println!(" {file}");
449 }
450 println!(" {UNSCOPED_REMEDY}");
451 }
452
453 let total = report.dead_patterns.len() + report.unscoped_files.len();
454 println!();
455 println!(
456 "Summary: {} scopes, {} files checked — {} violation{}",
457 report.scopes_checked,
458 report.files_checked,
459 total,
460 if total == 1 { "" } else { "s" }
461 );
462
463 Ok(())
464}
465
466#[cfg(test)]
467#[allow(clippy::unwrap_used, clippy::expect_used)]
468mod tests {
469 use super::*;
470 use crate::git::commit::ScopeCount;
471
472 fn make_report() -> ScopeUsageReport {
475 ScopeUsageReport {
476 total_commits: 5,
477 declared: vec![
478 ScopeCount {
479 name: "cli".to_string(),
480 count: 3,
481 },
482 ScopeCount {
483 name: "lib".to_string(),
484 count: 1,
485 },
486 ],
487 unknown: vec![ScopeCount {
488 name: "lib".to_string(),
489 count: 1,
490 }],
491 unused: vec!["workflows".to_string()],
492 scope_less_count: 1,
493 }
494 }
495
496 #[test]
497 fn text_report_includes_all_sections() {
498 let report = make_report();
499 let text = format_text_report(&report);
500 assert!(text.contains("5 commit(s) analyzed"));
501 assert!(text.contains("cli"));
502 assert!(text.contains("Unknown"));
503 assert!(text.contains("lib"));
504 assert!(text.contains("Unused"));
505 assert!(text.contains("workflows"));
506 assert!(text.contains("Scope-less commits: 1"));
507 }
508
509 #[test]
510 fn text_report_empty_omits_unknown_and_unused_sections() {
511 let report = ScopeUsageReport {
512 total_commits: 0,
513 declared: vec![],
514 unknown: vec![],
515 unused: vec![],
516 scope_less_count: 0,
517 };
518 let text = format_text_report(&report);
519 assert!(text.contains("(none)"));
520 assert!(!text.contains("Unknown"));
521 assert!(!text.contains("Unused"));
522 assert!(text.contains("Scope-less commits: 0"));
523 }
524
525 #[test]
526 fn json_output_round_trips() {
527 let report = make_report();
528 let json = serde_json::to_string_pretty(&report).unwrap();
529 let parsed: ScopeUsageReport = serde_json::from_str(&json).unwrap();
530 assert_eq!(parsed, report);
531 }
532
533 fn scope(name: &str, patterns: &[&str]) -> ScopeDefinition {
536 ScopeDefinition {
537 name: name.to_string(),
538 description: String::new(),
539 examples: Vec::new(),
540 file_patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
541 }
542 }
543
544 fn files(paths: &[&str]) -> Vec<String> {
545 paths.iter().map(|p| (*p).to_string()).collect()
546 }
547
548 #[test]
551 fn dead_pattern_reported_when_no_file_matches() {
552 let scopes = vec![scope("cli", &["src/cli/**"])];
553 let report = lint_scopes(
554 &files(&["src/git/commit.rs"]),
555 &scopes,
556 &[],
557 &["src".to_string()],
558 &[],
559 true,
560 );
561 assert_eq!(report.dead_patterns.len(), 1);
562 assert_eq!(report.dead_patterns[0].pattern, "src/cli/**");
563 assert_eq!(report.dead_patterns[0].scope, "cli");
564 }
565
566 #[test]
567 fn dead_pattern_not_reported_when_one_file_matches() {
568 let scopes = vec![scope("cli", &["src/cli/**"])];
569 let report = lint_scopes(
570 &files(&["src/cli/mod.rs"]),
571 &scopes,
572 &[],
573 &["src".to_string()],
574 &[],
575 true,
576 );
577 assert!(report.dead_patterns.is_empty());
578 }
579
580 #[test]
581 fn negative_pattern_never_reported_dead() {
582 let scopes = vec![scope("cli", &["src/cli/**", "!src/cli/generated/**"])];
586 let report = lint_scopes(
587 &files(&["src/other.rs"]),
588 &scopes,
589 &[],
590 &["src".to_string()],
591 &[],
592 true,
593 );
594 assert_eq!(report.dead_patterns.len(), 1);
595 assert_eq!(report.dead_patterns[0].pattern, "src/cli/**");
596 }
597
598 #[test]
601 fn unscoped_file_reported_under_root() {
602 let scopes = vec![scope("cli", &["src/cli/**"])];
603 let report = lint_scopes(
604 &files(&["src/newmod/foo.rs"]),
605 &scopes,
606 &[],
607 &["src".to_string()],
608 &[],
609 true,
610 );
611 assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
612 }
613
614 #[test]
615 fn unscoped_file_cleared_by_covering_scope() {
616 let scopes = vec![
617 scope("cli", &["src/cli/**"]),
618 scope("newmod", &["src/newmod/**"]),
619 ];
620 let report = lint_scopes(
621 &files(&["src/newmod/foo.rs"]),
622 &scopes,
623 &[],
624 &["src".to_string()],
625 &[],
626 true,
627 );
628 assert!(report.unscoped_files.is_empty());
629 }
630
631 #[test]
632 fn facade_pattern_does_not_cover_sibling_file() {
633 let scopes = vec![scope("foo", &["src/foo/**"])];
636 let report = lint_scopes(
637 &files(&["src/foo.rs"]),
638 &scopes,
639 &[],
640 &["src".to_string()],
641 &[],
642 true,
643 );
644 assert_eq!(report.unscoped_files, vec!["src/foo.rs".to_string()]);
645 }
646
647 #[test]
648 fn allow_glob_suppresses_only_matching_paths() {
649 let scopes = vec![scope("cli", &["src/cli/**"])];
650 let report = lint_scopes(
651 &files(&["src/lib.rs", "src/newmod/foo.rs"]),
652 &scopes,
653 &[],
654 &["src".to_string()],
655 &["src/lib.rs".to_string()],
656 true,
657 );
658 assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
659 }
660
661 #[test]
662 fn empty_scope_set_reports_every_file_under_root() {
663 let scopes = vec![scope("empty", &[])];
666 let report = lint_scopes(
667 &files(&["src/a.rs", "src/b/c.rs", "docs/outside.md"]),
668 &scopes,
669 &[],
670 &["src".to_string()],
671 &[],
672 true,
673 );
674 assert_eq!(
675 report.unscoped_files,
676 vec!["src/a.rs".to_string(), "src/b/c.rs".to_string()]
677 );
678 assert_eq!(report.files_checked, 2);
679 }
680
681 #[test]
682 fn allowed_path_also_covered_by_a_scope_is_not_reported() {
683 let scopes = vec![scope("foo", &["src/foo/**"])];
684 let report = lint_scopes(
685 &files(&["src/foo/a.rs"]),
686 &scopes,
687 &[],
688 &["src".to_string()],
689 &["src/foo/**".to_string()],
690 true,
691 );
692 assert!(report.unscoped_files.is_empty());
693 assert!(report.dead_patterns.is_empty());
694 }
695
696 #[test]
697 fn allow_does_not_make_a_scope_pattern_dead() {
698 let scopes = vec![scope("foo", &["src/foo.rs"])];
702 let report = lint_scopes(
703 &files(&["src/foo.rs"]),
704 &scopes,
705 &[],
706 &["src".to_string()],
707 &["src/foo.rs".to_string()],
708 true,
709 );
710 assert!(report.dead_patterns.is_empty());
711 }
712
713 #[test]
714 fn allow_never_hides_a_path_outside_its_globs() {
715 let report = lint_scopes(
716 &files(&["src/utils/a.rs", "src/utils2/a.rs", "src/newmod/a.rs"]),
717 &[scope("cli", &["src/cli/**"])],
718 &[],
719 &["src".to_string()],
720 &["src/utils/**".to_string()],
721 true,
722 );
723 assert_eq!(
724 report.unscoped_files,
725 vec!["src/newmod/a.rs".to_string(), "src/utils2/a.rs".to_string()]
726 );
727 }
728
729 #[test]
730 fn unscoped_remedy_names_all_three_fixes() {
731 assert!(UNSCOPED_REMEDY.contains("adding a scope"));
732 assert!(UNSCOPED_REMEDY.contains("file_patterns"));
733 assert!(UNSCOPED_REMEDY.contains("`allow:` with a justification"));
734 }
735
736 #[test]
739 fn project_only_true_reports_ecosystem_gap() {
740 let ecosystem = vec![scope("lib", &["src/lib.rs", "src/**"])];
741 let report = lint_scopes(
742 &files(&["src/newmod/foo.rs"]),
743 &[], &ecosystem,
745 &["src".to_string()],
746 &[],
747 true, );
749 assert_eq!(report.unscoped_files, vec!["src/newmod/foo.rs".to_string()]);
750 }
751
752 #[test]
753 fn project_only_false_uses_ecosystem_catchall() {
754 let ecosystem = vec![scope("lib", &["src/lib.rs", "src/**"])];
755 let report = lint_scopes(
756 &files(&["src/newmod/foo.rs"]),
757 &[],
758 &ecosystem,
759 &["src".to_string()],
760 &[],
761 false, );
763 assert!(report.unscoped_files.is_empty());
764 }
765
766 #[test]
769 fn file_in_roots_rejects_sibling_prefix() {
770 assert!(!file_in_roots("srcfoo/bar.rs", &["src".to_string()]));
771 }
772
773 #[test]
774 fn file_in_roots_matches_root_itself_and_subpath() {
775 assert!(file_in_roots("src", &["src".to_string()]));
776 assert!(file_in_roots("src/main.rs", &["src".to_string()]));
777 }
778
779 #[test]
780 fn roots_filter_excludes_files_outside_root() {
781 let scopes = vec![scope("cli", &["src/cli/**"])];
782 let report = lint_scopes(
783 &files(&["docs/newmod.md"]),
784 &scopes,
785 &[],
786 &["src".to_string()],
787 &[],
788 true,
789 );
790 assert!(report.unscoped_files.is_empty());
791 assert_eq!(report.files_checked, 0);
792 }
793
794 #[test]
795 fn multiple_roots_both_checked() {
796 let scopes = vec![scope("cli", &["src/cli/**"])];
797 let report = lint_scopes(
798 &files(&["src/newmod/foo.rs", "editors/newmod/bar.ts"]),
799 &scopes,
800 &[],
801 &["src".to_string(), "editors".to_string()],
802 &[],
803 true,
804 );
805 assert_eq!(report.unscoped_files.len(), 2);
806 }
807
808 #[test]
811 fn report_exit_code_zero_when_clean() {
812 let scopes = vec![scope("cli", &["src/cli/**"])];
813 let report = lint_scopes(
814 &files(&["src/cli/mod.rs"]),
815 &scopes,
816 &[],
817 &["src".to_string()],
818 &[],
819 true,
820 );
821 assert_eq!(report.exit_code(), 0);
822 }
823
824 #[test]
825 fn report_exit_code_one_when_dead_pattern_only() {
826 let scopes = vec![scope("cli", &["src/cli/**"])];
827 let report = lint_scopes(&files(&[]), &scopes, &[], &["src".to_string()], &[], true);
828 assert_eq!(report.exit_code(), 1);
829 }
830
831 #[test]
834 fn no_project_only_flag_parses() {
835 let cmd = LintCommand::try_parse_from(["lint", "--no-project-only"]).unwrap();
836 assert!(cmd.no_project_only);
837
838 let cmd = LintCommand::try_parse_from(["lint"]).unwrap();
839 assert!(!cmd.no_project_only);
840 }
841
842 #[test]
843 fn root_defaults_to_src() {
844 let cmd = LintCommand::try_parse_from(["lint"]).unwrap();
845 assert_eq!(cmd.root, vec!["src".to_string()]);
846 }
847
848 fn report(
859 project_only: bool,
860 dead_patterns: &[(&str, &str)],
861 unscoped_files: &[&str],
862 ) -> ScopesLintReport {
863 ScopesLintReport {
864 roots: vec!["src".to_string()],
865 project_only,
866 scopes_checked: 1,
867 files_checked: 1,
868 dead_patterns: dead_patterns
869 .iter()
870 .map(|(scope, pattern)| DeadPattern {
871 scope: (*scope).to_string(),
872 pattern: (*pattern).to_string(),
873 })
874 .collect(),
875 unscoped_files: unscoped_files.iter().map(|f| (*f).to_string()).collect(),
876 }
877 }
878
879 #[test]
880 fn render_report_dispatches_on_format() {
881 let clean = report(true, &[], &[]);
882 assert!(render_report(&clean, OutputFormat::Text).is_ok());
883 assert!(render_report(&clean, OutputFormat::Json).is_ok());
884 assert!(render_report(&clean, OutputFormat::Yaml).is_ok());
885 }
886
887 #[test]
888 fn render_text_report_project_only_with_violations_of_both_kinds() {
889 let r = report(
892 true,
893 &[("cli", "src/cli/nonexistent/**")],
894 &["src/newmod/a.rs", "src/newmod/b.rs"],
895 );
896 assert!(render_text_report(&r).is_ok());
897 }
898
899 #[test]
900 fn render_text_report_ecosystem_label_with_single_violation() {
901 let r = report(false, &[], &["src/newmod/a.rs"]);
904 assert!(render_text_report(&r).is_ok());
905 }
906
907 #[test]
908 fn render_text_report_fully_clean() {
909 let r = report(true, &[], &[]);
913 assert!(render_text_report(&r).is_ok());
914 }
915
916 #[test]
922 fn dispatches_to_lint() {
923 let cmd = ScopesCommand {
924 command: ScopesSubcommands::Lint(LintCommand {
925 root: vec!["src".to_string()],
926 no_project_only: false,
927 allow: Vec::new(),
928 output: OutputFormat::Text,
929 context_dir: None,
930 }),
931 repo: crate::cli::repo_arg::RepoArg {
932 repo: Some("/nonexistent/repo/path".into()),
933 },
934 };
935 let result = cmd.execute();
936 assert!(result.is_err());
937 }
938
939 fn init_tmp_git_repo() -> tempfile::TempDir {
944 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
945 std::fs::create_dir_all(&tmp_root).unwrap();
946 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
947 git2::Repository::init(temp_dir.path()).unwrap();
948 temp_dir
949 }
950
951 fn git_add_all(dir: &Path) {
953 let status = std::process::Command::new("git")
954 .current_dir(dir)
955 .args(["add", "."])
956 .status()
957 .unwrap();
958 assert!(status.success());
959 }
960
961 #[test]
971 fn no_project_only_does_not_duplicate_a_same_named_ecosystem_scope() {
972 let repo = init_tmp_git_repo();
973 let p = repo.path();
974 std::fs::write(p.join("package.json"), "{}").unwrap();
978 std::fs::create_dir_all(p.join("src/foo")).unwrap();
979 std::fs::write(p.join("src/foo/helper.test.js"), "// test helper").unwrap();
980 let context_dir = p.join(".omni-dev");
981 std::fs::create_dir_all(&context_dir).unwrap();
982 std::fs::write(
983 context_dir.join("scopes.yaml"),
984 r#"
985scopes:
986 - name: test
987 description: Narrow project test scope
988 examples: []
989 file_patterns:
990 - "tests/only/**"
991"#,
992 )
993 .unwrap();
994 git_add_all(p);
995
996 let cmd = LintCommand {
997 root: vec!["src".to_string()],
998 no_project_only: true, allow: Vec::new(),
1000 output: OutputFormat::Text,
1001 context_dir: Some(context_dir),
1002 };
1003 let report = run_scopes_lint(p, &cmd).expect("lint should run against the temp repo");
1004
1005 assert_eq!(
1011 report.unscoped_files,
1012 vec!["src/foo/helper.test.js".to_string()]
1013 );
1014 assert_eq!(report.scopes_checked, 5);
1018 }
1019}