Skip to main content

fallow_cli/report/
grouping.rs

1//! Grouping infrastructure for `--group-by owner|directory|package`.
2//!
3//! Partitions `AnalysisResults` into labeled groups by ownership (CODEOWNERS),
4//! by first directory component, or by workspace package.
5
6use std::path::{Path, PathBuf};
7
8use fallow_config::WorkspaceInfo;
9use fallow_core::results::AnalysisResults;
10use rustc_hash::FxHashMap;
11
12use super::relative_path;
13use crate::codeowners::{self, CodeOwners, NO_SECTION_LABEL, UNOWNED_LABEL};
14
15/// Ownership resolver for `--group-by`.
16///
17/// Owns the `CodeOwners` data when grouping by owner, avoiding lifetime
18/// complexity in the report context.
19pub enum OwnershipResolver {
20    /// Group by CODEOWNERS file (first owner, last matching rule).
21    Owner(CodeOwners),
22    /// Group by first directory component.
23    Directory,
24    /// Group by workspace package (monorepo).
25    Package(PackageResolver),
26    /// Group by GitLab CODEOWNERS section name (`[Section]` headers).
27    ///
28    /// Distinct sections produce distinct groups even when they share the
29    /// same default owner. Rules that appear before any section header
30    /// fall into the `(no section)` bucket.
31    Section(CodeOwners),
32}
33
34/// Resolves file paths to workspace package names via longest-prefix matching.
35///
36/// Stores workspace roots as paths relative to the project root so that
37/// resolution works with the relative paths passed to `OwnershipResolver::resolve`.
38pub struct PackageResolver {
39    /// `(relative_root, package_name)` sorted by path length descending.
40    workspaces: Vec<(PathBuf, String)>,
41}
42
43const ROOT_PACKAGE_LABEL: &str = "(root)";
44
45impl PackageResolver {
46    /// Build a resolver from discovered workspace info.
47    ///
48    /// Workspace roots are stored relative to `project_root` and sorted by path
49    /// length descending so the first match is always the most specific prefix.
50    pub fn new(project_root: &Path, workspaces: &[WorkspaceInfo]) -> Self {
51        let mut ws: Vec<(PathBuf, String)> = workspaces
52            .iter()
53            .map(|w| {
54                let rel = w.root.strip_prefix(project_root).unwrap_or(&w.root);
55                (rel.to_path_buf(), w.name.clone())
56            })
57            .collect();
58        ws.sort_by_key(|b| std::cmp::Reverse(b.0.as_os_str().len()));
59        Self { workspaces: ws }
60    }
61
62    /// Find the workspace package that owns `rel_path`, or `"(root)"` if none match.
63    fn resolve(&self, rel_path: &Path) -> &str {
64        self.workspaces
65            .iter()
66            .find(|(root, _)| rel_path.starts_with(root))
67            .map_or(ROOT_PACKAGE_LABEL, |(_, name)| name.as_str())
68    }
69}
70
71impl OwnershipResolver {
72    /// Resolve the group key for a file path (relative to project root).
73    pub fn resolve(&self, rel_path: &Path) -> String {
74        match self {
75            Self::Owner(co) => co.owner_of(rel_path).unwrap_or(UNOWNED_LABEL).to_string(),
76            Self::Directory => codeowners::directory_group(rel_path).to_string(),
77            Self::Package(pr) => pr.resolve(rel_path).to_string(),
78            Self::Section(co) => match co.section_of(rel_path) {
79                Some(Some(name)) => name.to_string(),
80                Some(None) => NO_SECTION_LABEL.to_string(),
81                None => UNOWNED_LABEL.to_string(),
82            },
83        }
84    }
85
86    /// Resolve the group key and matching rule for a path.
87    ///
88    /// Returns `(owner, Some(pattern))` for Owner mode,
89    /// `(directory, None)` for Directory/Package mode,
90    /// `(section, Some(pattern))` for Section mode (pattern is the raw
91    /// CODEOWNERS pattern from the last matching rule).
92    pub fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>) {
93        match self {
94            Self::Owner(co) => {
95                if let Some((owner, rule)) = co.owner_and_rule_of(rel_path) {
96                    (owner.to_string(), Some(rule.to_string()))
97                } else {
98                    (UNOWNED_LABEL.to_string(), None)
99                }
100            }
101            Self::Directory => (codeowners::directory_group(rel_path).to_string(), None),
102            Self::Package(pr) => (pr.resolve(rel_path).to_string(), None),
103            Self::Section(co) => {
104                if let Some((section, _owners, rule)) = co.section_owners_and_rule_of(rel_path) {
105                    let key = section.map_or_else(|| NO_SECTION_LABEL.to_string(), str::to_string);
106                    (key, Some(rule.to_string()))
107                } else {
108                    (UNOWNED_LABEL.to_string(), None)
109                }
110            }
111        }
112    }
113
114    /// Label for the grouping mode (used in JSON `grouped_by` field).
115    pub fn mode_label(&self) -> &'static str {
116        match self {
117            Self::Owner(_) => "owner",
118            Self::Directory => "directory",
119            Self::Package(_) => "package",
120            Self::Section(_) => "section",
121        }
122    }
123
124    /// Look up the section default owners for a group key.
125    ///
126    /// Returns `Some(&[...])` only in Section mode when `rel_path` resolves
127    /// to a rule inside a named section. Used to emit the `owners` metadata
128    /// array in grouped JSON output.
129    pub fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]> {
130        if let Self::Section(co) = self
131            && let Some((_, owners)) = co.section_and_owners_of(rel_path)
132        {
133            Some(owners)
134        } else {
135            None
136        }
137    }
138}
139
140/// A single group: a label and its subset of results.
141pub struct ResultGroup {
142    /// Group label (owner name, directory, package, or section).
143    pub key: String,
144    /// Section default owners for `--group-by section`.
145    ///
146    /// `None` for all other grouping modes. `Some(vec![])` for the
147    /// `(no section)` and `(unowned)` buckets in Section mode.
148    pub owners: Option<Vec<String>>,
149    /// Issues belonging to this group.
150    pub results: AnalysisResults,
151}
152
153/// Partition analysis results into groups by ownership or directory.
154///
155/// Each issue is assigned to a group by extracting its primary file path
156/// and resolving the group key via the `OwnershipResolver`.
157/// Returns groups sorted alphabetically by key, with `(unowned)` last.
158pub fn group_analysis_results(
159    results: &AnalysisResults,
160    root: &Path,
161    resolver: &OwnershipResolver,
162) -> Vec<ResultGroup> {
163    let mut groups: FxHashMap<String, AnalysisResults> = FxHashMap::default();
164    // Section-mode: remember the default owners for each section key. Written
165    // once per key (the first path that lands there); all subsequent paths in
166    // the same section share the same defaults.
167    let mut group_owners: FxHashMap<String, Vec<String>> = FxHashMap::default();
168    let is_section_mode = matches!(resolver, OwnershipResolver::Section(_));
169
170    let mut key_for = |path: &Path| -> String {
171        let rel = relative_path(path, root);
172        let key = resolver.resolve(rel);
173        if is_section_mode && !group_owners.contains_key(&key) {
174            let owners = resolver
175                .section_owners_of(rel)
176                .map(<[String]>::to_vec)
177                .unwrap_or_default();
178            group_owners.insert(key.clone(), owners);
179        }
180        key
181    };
182
183    // ── File-scoped issue types ─────────────────────────────────
184    for item in &results.unused_files {
185        groups
186            .entry(key_for(&item.path))
187            .or_default()
188            .unused_files
189            .push(item.clone());
190    }
191    for item in &results.unused_exports {
192        groups
193            .entry(key_for(&item.path))
194            .or_default()
195            .unused_exports
196            .push(item.clone());
197    }
198    for item in &results.unused_types {
199        groups
200            .entry(key_for(&item.path))
201            .or_default()
202            .unused_types
203            .push(item.clone());
204    }
205    for item in &results.unused_enum_members {
206        groups
207            .entry(key_for(&item.path))
208            .or_default()
209            .unused_enum_members
210            .push(item.clone());
211    }
212    for item in &results.unused_class_members {
213        groups
214            .entry(key_for(&item.path))
215            .or_default()
216            .unused_class_members
217            .push(item.clone());
218    }
219    for item in &results.unresolved_imports {
220        groups
221            .entry(key_for(&item.path))
222            .or_default()
223            .unresolved_imports
224            .push(item.clone());
225    }
226
227    // ── Dependency-scoped (use package.json path) ───────────────
228    for item in &results.unused_dependencies {
229        groups
230            .entry(key_for(&item.path))
231            .or_default()
232            .unused_dependencies
233            .push(item.clone());
234    }
235    for item in &results.unused_dev_dependencies {
236        groups
237            .entry(key_for(&item.path))
238            .or_default()
239            .unused_dev_dependencies
240            .push(item.clone());
241    }
242    for item in &results.unused_optional_dependencies {
243        groups
244            .entry(key_for(&item.path))
245            .or_default()
246            .unused_optional_dependencies
247            .push(item.clone());
248    }
249    for item in &results.type_only_dependencies {
250        groups
251            .entry(key_for(&item.path))
252            .or_default()
253            .type_only_dependencies
254            .push(item.clone());
255    }
256    for item in &results.test_only_dependencies {
257        groups
258            .entry(key_for(&item.path))
259            .or_default()
260            .test_only_dependencies
261            .push(item.clone());
262    }
263
264    // ── Multi-location types (use first location) ───────────────
265    for item in &results.unlisted_dependencies {
266        let key = item
267            .imported_from
268            .first()
269            .map_or_else(|| UNOWNED_LABEL.to_string(), |site| key_for(&site.path));
270        groups
271            .entry(key)
272            .or_default()
273            .unlisted_dependencies
274            .push(item.clone());
275    }
276    for item in &results.duplicate_exports {
277        let key = item
278            .locations
279            .first()
280            .map_or_else(|| UNOWNED_LABEL.to_string(), |loc| key_for(&loc.path));
281        groups
282            .entry(key)
283            .or_default()
284            .duplicate_exports
285            .push(item.clone());
286    }
287    for item in &results.circular_dependencies {
288        let key = item
289            .files
290            .first()
291            .map_or_else(|| UNOWNED_LABEL.to_string(), |f| key_for(f));
292        groups
293            .entry(key)
294            .or_default()
295            .circular_dependencies
296            .push(item.clone());
297    }
298    for item in &results.boundary_violations {
299        groups
300            .entry(key_for(&item.from_path))
301            .or_default()
302            .boundary_violations
303            .push(item.clone());
304    }
305    for item in &results.stale_suppressions {
306        groups
307            .entry(key_for(&item.path))
308            .or_default()
309            .stale_suppressions
310            .push(item.clone());
311    }
312
313    finalize_groups(groups, group_owners, is_section_mode)
314}
315
316/// Merge per-key results and owners into sorted `ResultGroup`s.
317///
318/// Ordering: most issues first, alphabetical tiebreaker, `(unowned)` pinned to
319/// the end. `group_owners` is consumed only when `is_section_mode` is true.
320fn finalize_groups(
321    groups: FxHashMap<String, AnalysisResults>,
322    mut group_owners: FxHashMap<String, Vec<String>>,
323    is_section_mode: bool,
324) -> Vec<ResultGroup> {
325    let mut sorted: Vec<_> = groups
326        .into_iter()
327        .map(|(key, results)| {
328            let owners = if is_section_mode {
329                Some(group_owners.remove(&key).unwrap_or_default())
330            } else {
331                None
332            };
333            ResultGroup {
334                key,
335                owners,
336                results,
337            }
338        })
339        .collect();
340    sorted.sort_by(|a, b| {
341        let a_unowned = a.key == UNOWNED_LABEL;
342        let b_unowned = b.key == UNOWNED_LABEL;
343        match (a_unowned, b_unowned) {
344            (true, false) => std::cmp::Ordering::Greater,
345            (false, true) => std::cmp::Ordering::Less,
346            _ => b
347                .results
348                .total_issues()
349                .cmp(&a.results.total_issues())
350                .then_with(|| a.key.cmp(&b.key)),
351        }
352    });
353    sorted
354}
355
356/// Resolve the group key for a single path (for per-result tagging in SARIF/CodeClimate).
357pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
358    resolver.resolve(relative_path(path, root))
359}
360
361#[cfg(test)]
362mod tests {
363    use std::path::{Path, PathBuf};
364
365    use fallow_core::results::*;
366
367    use super::*;
368    use crate::codeowners::CodeOwners;
369
370    // ── Helpers ────────────────────────────────────────────────────
371
372    fn root() -> PathBuf {
373        PathBuf::from("/root")
374    }
375
376    fn unused_file(path: &str) -> UnusedFile {
377        UnusedFile {
378            path: PathBuf::from(path),
379        }
380    }
381
382    fn unused_export(path: &str, name: &str) -> UnusedExport {
383        UnusedExport {
384            path: PathBuf::from(path),
385            export_name: name.to_string(),
386            is_type_only: false,
387            line: 1,
388            col: 0,
389            span_start: 0,
390            is_re_export: false,
391        }
392    }
393
394    fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependency {
395        UnlistedDependency {
396            package_name: name.to_string(),
397            imported_from: sites,
398        }
399    }
400
401    fn import_site(path: &str) -> ImportSite {
402        ImportSite {
403            path: PathBuf::from(path),
404            line: 1,
405            col: 0,
406        }
407    }
408
409    // ── 1. Empty results ──────────────────────────────────────────
410
411    #[test]
412    fn empty_results_returns_empty_vec() {
413        let results = AnalysisResults::default();
414        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
415        assert!(groups.is_empty());
416    }
417
418    // ── 2. Single group ──────────────────────────────────────────
419
420    #[test]
421    fn single_group_all_same_directory() {
422        let mut results = AnalysisResults::default();
423        results.unused_files.push(unused_file("/root/src/a.ts"));
424        results.unused_files.push(unused_file("/root/src/b.ts"));
425        results
426            .unused_exports
427            .push(unused_export("/root/src/c.ts", "foo"));
428
429        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
430
431        assert_eq!(groups.len(), 1);
432        assert_eq!(groups[0].key, "src");
433        assert_eq!(groups[0].results.unused_files.len(), 2);
434        assert_eq!(groups[0].results.unused_exports.len(), 1);
435        assert_eq!(groups[0].results.total_issues(), 3);
436    }
437
438    // ── 3. Multiple groups ───────────────────────────────────────
439
440    #[test]
441    fn multiple_groups_split_by_directory() {
442        let mut results = AnalysisResults::default();
443        results.unused_files.push(unused_file("/root/src/a.ts"));
444        results.unused_files.push(unused_file("/root/lib/b.ts"));
445        results
446            .unused_exports
447            .push(unused_export("/root/src/c.ts", "bar"));
448
449        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
450
451        assert_eq!(groups.len(), 2);
452
453        let src_group = groups.iter().find(|g| g.key == "src").unwrap();
454        let lib_group = groups.iter().find(|g| g.key == "lib").unwrap();
455
456        assert_eq!(src_group.results.total_issues(), 2);
457        assert_eq!(lib_group.results.total_issues(), 1);
458    }
459
460    // ── 4. Sort order: most issues first ─────────────────────────
461
462    #[test]
463    fn sort_order_descending_by_total_issues() {
464        let mut results = AnalysisResults::default();
465        // lib: 1 issue
466        results.unused_files.push(unused_file("/root/lib/a.ts"));
467        // src: 3 issues
468        results.unused_files.push(unused_file("/root/src/a.ts"));
469        results.unused_files.push(unused_file("/root/src/b.ts"));
470        results
471            .unused_exports
472            .push(unused_export("/root/src/c.ts", "x"));
473        // test: 2 issues
474        results.unused_files.push(unused_file("/root/test/a.ts"));
475        results.unused_files.push(unused_file("/root/test/b.ts"));
476
477        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
478
479        assert_eq!(groups.len(), 3);
480        assert_eq!(groups[0].key, "src");
481        assert_eq!(groups[0].results.total_issues(), 3);
482        assert_eq!(groups[1].key, "test");
483        assert_eq!(groups[1].results.total_issues(), 2);
484        assert_eq!(groups[2].key, "lib");
485        assert_eq!(groups[2].results.total_issues(), 1);
486    }
487
488    #[test]
489    fn sort_order_alphabetical_tiebreaker() {
490        let mut results = AnalysisResults::default();
491        results.unused_files.push(unused_file("/root/beta/a.ts"));
492        results.unused_files.push(unused_file("/root/alpha/a.ts"));
493
494        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
495
496        assert_eq!(groups.len(), 2);
497        // Same issue count (1 each) -> alphabetical
498        assert_eq!(groups[0].key, "alpha");
499        assert_eq!(groups[1].key, "beta");
500    }
501
502    // ── 5. Unowned always last ───────────────────────────────────
503
504    #[test]
505    fn unowned_sorts_last_regardless_of_count() {
506        let mut results = AnalysisResults::default();
507        // src: 1 issue
508        results.unused_files.push(unused_file("/root/src/a.ts"));
509        // unlisted dep with empty imported_from -> goes to (unowned)
510        results
511            .unlisted_dependencies
512            .push(unlisted_dep("pkg-a", vec![]));
513        results
514            .unlisted_dependencies
515            .push(unlisted_dep("pkg-b", vec![]));
516        results
517            .unlisted_dependencies
518            .push(unlisted_dep("pkg-c", vec![]));
519
520        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
521
522        assert_eq!(groups.len(), 2);
523        // (unowned) has 3 issues vs src's 1, but must still be last
524        assert_eq!(groups[0].key, "src");
525        assert_eq!(groups[1].key, UNOWNED_LABEL);
526        assert_eq!(groups[1].results.total_issues(), 3);
527    }
528
529    // ── 6. Multi-location fallback ───────────────────────────────
530
531    #[test]
532    fn unlisted_dep_empty_imported_from_goes_to_unowned() {
533        let mut results = AnalysisResults::default();
534        results
535            .unlisted_dependencies
536            .push(unlisted_dep("missing-pkg", vec![]));
537
538        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
539
540        assert_eq!(groups.len(), 1);
541        assert_eq!(groups[0].key, UNOWNED_LABEL);
542        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
543    }
544
545    #[test]
546    fn unlisted_dep_with_import_site_goes_to_directory() {
547        let mut results = AnalysisResults::default();
548        results.unlisted_dependencies.push(unlisted_dep(
549            "lodash",
550            vec![import_site("/root/src/util.ts")],
551        ));
552
553        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
554
555        assert_eq!(groups.len(), 1);
556        assert_eq!(groups[0].key, "src");
557        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
558    }
559
560    // ── 7. Directory mode ────────────────────────────────────────
561
562    #[test]
563    fn directory_mode_groups_by_first_path_component() {
564        let mut results = AnalysisResults::default();
565        results
566            .unused_files
567            .push(unused_file("/root/packages/ui/Button.ts"));
568        results
569            .unused_files
570            .push(unused_file("/root/packages/auth/login.ts"));
571        results
572            .unused_exports
573            .push(unused_export("/root/apps/web/index.ts", "main"));
574
575        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
576
577        assert_eq!(groups.len(), 2);
578
579        let pkgs = groups.iter().find(|g| g.key == "packages").unwrap();
580        let apps = groups.iter().find(|g| g.key == "apps").unwrap();
581
582        assert_eq!(pkgs.results.total_issues(), 2);
583        assert_eq!(apps.results.total_issues(), 1);
584    }
585
586    // ── 8. Owner mode ────────────────────────────────────────────
587
588    #[test]
589    fn owner_mode_groups_by_codeowners_owner() {
590        let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
591        let resolver = OwnershipResolver::Owner(co);
592
593        let mut results = AnalysisResults::default();
594        results.unused_files.push(unused_file("/root/src/app.ts"));
595        results.unused_files.push(unused_file("/root/README.md"));
596
597        let groups = group_analysis_results(&results, &root(), &resolver);
598
599        assert_eq!(groups.len(), 2);
600
601        let frontend = groups.iter().find(|g| g.key == "@frontend").unwrap();
602        let default = groups.iter().find(|g| g.key == "@default").unwrap();
603
604        assert_eq!(frontend.results.unused_files.len(), 1);
605        assert_eq!(default.results.unused_files.len(), 1);
606    }
607
608    #[test]
609    fn owner_mode_unmatched_goes_to_unowned() {
610        // No catch-all rule -- files outside /src/ have no owner
611        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
612        let resolver = OwnershipResolver::Owner(co);
613
614        let mut results = AnalysisResults::default();
615        results.unused_files.push(unused_file("/root/README.md"));
616
617        let groups = group_analysis_results(&results, &root(), &resolver);
618
619        assert_eq!(groups.len(), 1);
620        assert_eq!(groups[0].key, UNOWNED_LABEL);
621    }
622
623    // ── Boundary violations ──────────────────────────────────────
624
625    #[test]
626    fn boundary_violations_grouped_by_from_path() {
627        let mut results = AnalysisResults::default();
628        results.boundary_violations.push(BoundaryViolation {
629            from_path: PathBuf::from("/root/src/bad.ts"),
630            to_path: PathBuf::from("/root/lib/secret.ts"),
631            from_zone: "src".to_string(),
632            to_zone: "lib".to_string(),
633            import_specifier: "../lib/secret".to_string(),
634            line: 1,
635            col: 0,
636        });
637
638        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
639
640        assert_eq!(groups.len(), 1);
641        assert_eq!(groups[0].key, "src");
642        assert_eq!(groups[0].results.boundary_violations.len(), 1);
643    }
644
645    // ── Circular dependencies ────────────────────────────────────
646
647    #[test]
648    fn circular_dep_empty_files_goes_to_unowned() {
649        let mut results = AnalysisResults::default();
650        results.circular_dependencies.push(CircularDependency {
651            files: vec![],
652            length: 0,
653            line: 0,
654            col: 0,
655            is_cross_package: false,
656        });
657
658        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
659
660        assert_eq!(groups.len(), 1);
661        assert_eq!(groups[0].key, UNOWNED_LABEL);
662    }
663
664    #[test]
665    fn circular_dep_uses_first_file() {
666        let mut results = AnalysisResults::default();
667        results.circular_dependencies.push(CircularDependency {
668            files: vec![
669                PathBuf::from("/root/src/a.ts"),
670                PathBuf::from("/root/lib/b.ts"),
671            ],
672            length: 2,
673            line: 1,
674            col: 0,
675            is_cross_package: false,
676        });
677
678        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
679
680        assert_eq!(groups.len(), 1);
681        assert_eq!(groups[0].key, "src");
682    }
683
684    // ── Duplicate exports ────────────────────────────────────────
685
686    #[test]
687    fn duplicate_exports_empty_locations_goes_to_unowned() {
688        let mut results = AnalysisResults::default();
689        results.duplicate_exports.push(DuplicateExport {
690            export_name: "dup".to_string(),
691            locations: vec![],
692        });
693
694        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
695
696        assert_eq!(groups.len(), 1);
697        assert_eq!(groups[0].key, UNOWNED_LABEL);
698    }
699
700    // ── resolve_owner ────────────────────────────────────────────
701
702    #[test]
703    fn resolve_owner_returns_directory() {
704        let owner = resolve_owner(
705            Path::new("/root/src/file.ts"),
706            &root(),
707            &OwnershipResolver::Directory,
708        );
709        assert_eq!(owner, "src");
710    }
711
712    #[test]
713    fn resolve_owner_returns_codeowner() {
714        let co = CodeOwners::parse("/src/ @team\n").unwrap();
715        let resolver = OwnershipResolver::Owner(co);
716        let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
717        assert_eq!(owner, "@team");
718    }
719
720    // ── mode_label ───────────────────────────────────────────────
721
722    #[test]
723    fn mode_label_owner() {
724        let co = CodeOwners::parse("").unwrap();
725        let resolver = OwnershipResolver::Owner(co);
726        assert_eq!(resolver.mode_label(), "owner");
727    }
728
729    #[test]
730    fn mode_label_directory() {
731        assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
732    }
733
734    #[test]
735    fn mode_label_package() {
736        let pr = PackageResolver { workspaces: vec![] };
737        assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
738    }
739
740    #[test]
741    fn mode_label_section() {
742        let co = CodeOwners::parse("[S] @owner\nfoo/\n").unwrap();
743        assert_eq!(OwnershipResolver::Section(co).mode_label(), "section");
744    }
745
746    // ── Section mode ────────────────────────────────────────────
747
748    #[test]
749    fn section_mode_groups_distinct_sections_with_shared_owners() {
750        // Issue #133 reproduction: billing and notifications share the lead
751        // owner but are separate sections, so they must produce 2 groups.
752        let content = "\
753            [billing] @core-reviewers @alice @bob\n\
754            src/billing/\n\
755            [notifications] @core-reviewers @alice @bob\n\
756            src/notifications/\n\
757        ";
758        let co = CodeOwners::parse(content).unwrap();
759        let resolver = OwnershipResolver::Section(co);
760
761        let mut results = AnalysisResults::default();
762        results
763            .unused_files
764            .push(unused_file("/root/src/billing/a.ts"));
765        results
766            .unused_files
767            .push(unused_file("/root/src/billing/b.ts"));
768        results
769            .unused_files
770            .push(unused_file("/root/src/notifications/c.ts"));
771
772        let groups = group_analysis_results(&results, &root(), &resolver);
773
774        assert_eq!(groups.len(), 2);
775        let billing = groups.iter().find(|g| g.key == "billing").unwrap();
776        let notifications = groups.iter().find(|g| g.key == "notifications").unwrap();
777        assert_eq!(billing.results.total_issues(), 2);
778        assert_eq!(notifications.results.total_issues(), 1);
779        assert_eq!(
780            billing.owners.as_deref(),
781            Some(
782                [
783                    "@core-reviewers".to_string(),
784                    "@alice".to_string(),
785                    "@bob".to_string()
786                ]
787                .as_slice()
788            )
789        );
790        assert_eq!(
791            notifications.owners.as_deref(),
792            Some(
793                [
794                    "@core-reviewers".to_string(),
795                    "@alice".to_string(),
796                    "@bob".to_string()
797                ]
798                .as_slice()
799            )
800        );
801    }
802
803    #[test]
804    fn section_mode_pre_section_rule_goes_to_no_section() {
805        let content = "\
806            * @default\n\
807            [Utilities] @utils\n\
808            src/utils/\n\
809        ";
810        let co = CodeOwners::parse(content).unwrap();
811        let resolver = OwnershipResolver::Section(co);
812
813        let mut results = AnalysisResults::default();
814        results.unused_files.push(unused_file("/root/README.md"));
815        results
816            .unused_files
817            .push(unused_file("/root/src/utils/greet.ts"));
818
819        let groups = group_analysis_results(&results, &root(), &resolver);
820
821        assert_eq!(groups.len(), 2);
822        let no_section = groups.iter().find(|g| g.key == "(no section)").unwrap();
823        let utils = groups.iter().find(|g| g.key == "Utilities").unwrap();
824        assert_eq!(no_section.owners.as_deref(), Some([].as_slice()));
825        assert_eq!(
826            utils.owners.as_deref(),
827            Some(["@utils".to_string()].as_slice())
828        );
829    }
830
831    #[test]
832    fn section_mode_unmatched_goes_to_unowned() {
833        let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
834        let resolver = OwnershipResolver::Section(co);
835
836        let mut results = AnalysisResults::default();
837        results.unused_files.push(unused_file("/root/README.md"));
838
839        let groups = group_analysis_results(&results, &root(), &resolver);
840
841        assert_eq!(groups.len(), 1);
842        assert_eq!(groups[0].key, UNOWNED_LABEL);
843        assert_eq!(groups[0].owners.as_deref(), Some([].as_slice()));
844    }
845
846    #[test]
847    fn directory_mode_groups_have_no_owners_metadata() {
848        let mut results = AnalysisResults::default();
849        results.unused_files.push(unused_file("/root/src/a.ts"));
850        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
851        assert_eq!(groups[0].owners, None);
852    }
853
854    // ── PackageResolver ─────────────────────────────────────────
855
856    #[test]
857    fn package_resolver_matches_longest_prefix() {
858        let ws = vec![
859            fallow_config::WorkspaceInfo {
860                name: "packages/ui".to_string(),
861                root: PathBuf::from("/root/packages/ui"),
862                is_internal_dependency: false,
863            },
864            fallow_config::WorkspaceInfo {
865                name: "packages".to_string(),
866                root: PathBuf::from("/root/packages"),
867                is_internal_dependency: false,
868            },
869        ];
870        let pr = PackageResolver::new(Path::new("/root"), &ws);
871        // A file in packages/ui/ should match the more specific workspace
872        assert_eq!(
873            pr.resolve(Path::new("packages/ui/Button.ts")),
874            "packages/ui"
875        );
876    }
877
878    #[test]
879    fn package_resolver_root_fallback() {
880        let ws = vec![fallow_config::WorkspaceInfo {
881            name: "packages/ui".to_string(),
882            root: PathBuf::from("/root/packages/ui"),
883            is_internal_dependency: false,
884        }];
885        let pr = PackageResolver::new(Path::new("/root"), &ws);
886        // A file outside any workspace returns (root)
887        assert_eq!(pr.resolve(Path::new("src/app.ts")), ROOT_PACKAGE_LABEL);
888    }
889
890    #[test]
891    fn package_mode_groups_by_workspace() {
892        let ws = vec![
893            fallow_config::WorkspaceInfo {
894                name: "ui".to_string(),
895                root: PathBuf::from("/root/packages/ui"),
896                is_internal_dependency: false,
897            },
898            fallow_config::WorkspaceInfo {
899                name: "auth".to_string(),
900                root: PathBuf::from("/root/packages/auth"),
901                is_internal_dependency: false,
902            },
903        ];
904        let pr = PackageResolver::new(Path::new("/root"), &ws);
905        let resolver = OwnershipResolver::Package(pr);
906
907        let mut results = AnalysisResults::default();
908        results
909            .unused_files
910            .push(unused_file("/root/packages/ui/Button.ts"));
911        results
912            .unused_files
913            .push(unused_file("/root/packages/auth/login.ts"));
914        results.unused_files.push(unused_file("/root/src/main.ts"));
915
916        let groups = group_analysis_results(&results, &root(), &resolver);
917        assert_eq!(groups.len(), 3);
918
919        let ui_group = groups.iter().find(|g| g.key == "ui");
920        let auth_group = groups.iter().find(|g| g.key == "auth");
921        let root_group = groups.iter().find(|g| g.key == ROOT_PACKAGE_LABEL);
922
923        assert!(ui_group.is_some());
924        assert!(auth_group.is_some());
925        assert!(root_group.is_some());
926    }
927
928    // ── resolve_with_rule ───────────────────────────────────────
929
930    #[test]
931    fn resolve_with_rule_directory_mode_no_rule() {
932        let (key, rule) = OwnershipResolver::Directory.resolve_with_rule(Path::new("src/file.ts"));
933        assert_eq!(key, "src");
934        assert!(rule.is_none());
935    }
936
937    #[test]
938    fn resolve_with_rule_owner_mode_with_match() {
939        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
940        let resolver = OwnershipResolver::Owner(co);
941        let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
942        assert_eq!(key, "@frontend");
943        assert!(rule.is_some());
944        assert!(rule.unwrap().contains("src"));
945    }
946
947    #[test]
948    fn resolve_with_rule_owner_mode_no_match() {
949        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
950        let resolver = OwnershipResolver::Owner(co);
951        let (key, rule) = resolver.resolve_with_rule(Path::new("docs/readme.md"));
952        assert_eq!(key, UNOWNED_LABEL);
953        assert!(rule.is_none());
954    }
955
956    #[test]
957    fn resolve_with_rule_package_mode_no_rule() {
958        let pr = PackageResolver { workspaces: vec![] };
959        let resolver = OwnershipResolver::Package(pr);
960        let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
961        assert_eq!(key, ROOT_PACKAGE_LABEL);
962        assert!(rule.is_none());
963    }
964
965    // ── Missing issue type groupings ────────────────────────────
966
967    #[test]
968    fn group_unused_optional_deps() {
969        let mut results = AnalysisResults::default();
970        results.unused_optional_dependencies.push(UnusedDependency {
971            package_name: "fsevents".to_string(),
972            location: fallow_core::results::DependencyLocation::OptionalDependencies,
973            path: PathBuf::from("/root/package.json"),
974            line: 5,
975            used_in_workspaces: Vec::new(),
976        });
977
978        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
979        assert_eq!(groups.len(), 1);
980        assert_eq!(groups[0].results.unused_optional_dependencies.len(), 1);
981    }
982
983    #[test]
984    fn group_type_only_deps() {
985        let mut results = AnalysisResults::default();
986        results
987            .type_only_dependencies
988            .push(fallow_core::results::TypeOnlyDependency {
989                package_name: "zod".to_string(),
990                path: PathBuf::from("/root/package.json"),
991                line: 8,
992            });
993
994        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
995        assert_eq!(groups.len(), 1);
996        assert_eq!(groups[0].results.type_only_dependencies.len(), 1);
997    }
998
999    #[test]
1000    fn group_test_only_deps() {
1001        let mut results = AnalysisResults::default();
1002        results
1003            .test_only_dependencies
1004            .push(fallow_core::results::TestOnlyDependency {
1005                package_name: "vitest".to_string(),
1006                path: PathBuf::from("/root/package.json"),
1007                line: 10,
1008            });
1009
1010        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1011        assert_eq!(groups.len(), 1);
1012        assert_eq!(groups[0].results.test_only_dependencies.len(), 1);
1013    }
1014
1015    #[test]
1016    fn group_unused_enum_members() {
1017        let mut results = AnalysisResults::default();
1018        results
1019            .unused_enum_members
1020            .push(fallow_core::results::UnusedMember {
1021                path: PathBuf::from("/root/src/types.ts"),
1022                parent_name: "Status".to_string(),
1023                member_name: "Deprecated".to_string(),
1024                kind: fallow_core::extract::MemberKind::EnumMember,
1025                line: 5,
1026                col: 0,
1027            });
1028
1029        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1030        assert_eq!(groups.len(), 1);
1031        assert_eq!(groups[0].key, "src");
1032        assert_eq!(groups[0].results.unused_enum_members.len(), 1);
1033    }
1034
1035    #[test]
1036    fn group_unused_class_members() {
1037        let mut results = AnalysisResults::default();
1038        results
1039            .unused_class_members
1040            .push(fallow_core::results::UnusedMember {
1041                path: PathBuf::from("/root/lib/service.ts"),
1042                parent_name: "UserService".to_string(),
1043                member_name: "legacyMethod".to_string(),
1044                kind: fallow_core::extract::MemberKind::ClassMethod,
1045                line: 42,
1046                col: 0,
1047            });
1048
1049        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1050        assert_eq!(groups.len(), 1);
1051        assert_eq!(groups[0].key, "lib");
1052        assert_eq!(groups[0].results.unused_class_members.len(), 1);
1053    }
1054
1055    #[test]
1056    fn group_unresolved_imports() {
1057        let mut results = AnalysisResults::default();
1058        results
1059            .unresolved_imports
1060            .push(fallow_core::results::UnresolvedImport {
1061                path: PathBuf::from("/root/src/app.ts"),
1062                specifier: "./missing".to_string(),
1063                line: 1,
1064                col: 0,
1065                specifier_col: 0,
1066            });
1067
1068        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1069        assert_eq!(groups.len(), 1);
1070        assert_eq!(groups[0].key, "src");
1071        assert_eq!(groups[0].results.unresolved_imports.len(), 1);
1072    }
1073}