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.private_type_leaks {
206        groups
207            .entry(key_for(&item.path))
208            .or_default()
209            .private_type_leaks
210            .push(item.clone());
211    }
212    for item in &results.unused_enum_members {
213        groups
214            .entry(key_for(&item.path))
215            .or_default()
216            .unused_enum_members
217            .push(item.clone());
218    }
219    for item in &results.unused_class_members {
220        groups
221            .entry(key_for(&item.path))
222            .or_default()
223            .unused_class_members
224            .push(item.clone());
225    }
226    for item in &results.unresolved_imports {
227        groups
228            .entry(key_for(&item.path))
229            .or_default()
230            .unresolved_imports
231            .push(item.clone());
232    }
233
234    // ── Dependency-scoped (use package.json path) ───────────────
235    for item in &results.unused_dependencies {
236        groups
237            .entry(key_for(&item.path))
238            .or_default()
239            .unused_dependencies
240            .push(item.clone());
241    }
242    for item in &results.unused_dev_dependencies {
243        groups
244            .entry(key_for(&item.path))
245            .or_default()
246            .unused_dev_dependencies
247            .push(item.clone());
248    }
249    for item in &results.unused_optional_dependencies {
250        groups
251            .entry(key_for(&item.path))
252            .or_default()
253            .unused_optional_dependencies
254            .push(item.clone());
255    }
256    for item in &results.type_only_dependencies {
257        groups
258            .entry(key_for(&item.path))
259            .or_default()
260            .type_only_dependencies
261            .push(item.clone());
262    }
263    for item in &results.test_only_dependencies {
264        groups
265            .entry(key_for(&item.path))
266            .or_default()
267            .test_only_dependencies
268            .push(item.clone());
269    }
270
271    // ── Multi-location types (use first location) ───────────────
272    for item in &results.unlisted_dependencies {
273        let key = item
274            .imported_from
275            .first()
276            .map_or_else(|| UNOWNED_LABEL.to_string(), |site| key_for(&site.path));
277        groups
278            .entry(key)
279            .or_default()
280            .unlisted_dependencies
281            .push(item.clone());
282    }
283    for item in &results.duplicate_exports {
284        let key = item
285            .locations
286            .first()
287            .map_or_else(|| UNOWNED_LABEL.to_string(), |loc| key_for(&loc.path));
288        groups
289            .entry(key)
290            .or_default()
291            .duplicate_exports
292            .push(item.clone());
293    }
294    for item in &results.circular_dependencies {
295        let key = item
296            .files
297            .first()
298            .map_or_else(|| UNOWNED_LABEL.to_string(), |f| key_for(f));
299        groups
300            .entry(key)
301            .or_default()
302            .circular_dependencies
303            .push(item.clone());
304    }
305    for item in &results.boundary_violations {
306        groups
307            .entry(key_for(&item.from_path))
308            .or_default()
309            .boundary_violations
310            .push(item.clone());
311    }
312    for item in &results.stale_suppressions {
313        groups
314            .entry(key_for(&item.path))
315            .or_default()
316            .stale_suppressions
317            .push(item.clone());
318    }
319
320    finalize_groups(groups, group_owners, is_section_mode)
321}
322
323/// Merge per-key results and owners into sorted `ResultGroup`s.
324///
325/// Ordering: most issues first, alphabetical tiebreaker, `(unowned)` pinned to
326/// the end. `group_owners` is consumed only when `is_section_mode` is true.
327fn finalize_groups(
328    groups: FxHashMap<String, AnalysisResults>,
329    mut group_owners: FxHashMap<String, Vec<String>>,
330    is_section_mode: bool,
331) -> Vec<ResultGroup> {
332    let mut sorted: Vec<_> = groups
333        .into_iter()
334        .map(|(key, results)| {
335            let owners = if is_section_mode {
336                Some(group_owners.remove(&key).unwrap_or_default())
337            } else {
338                None
339            };
340            ResultGroup {
341                key,
342                owners,
343                results,
344            }
345        })
346        .collect();
347    sorted.sort_by(|a, b| {
348        let a_unowned = a.key == UNOWNED_LABEL;
349        let b_unowned = b.key == UNOWNED_LABEL;
350        match (a_unowned, b_unowned) {
351            (true, false) => std::cmp::Ordering::Greater,
352            (false, true) => std::cmp::Ordering::Less,
353            _ => b
354                .results
355                .total_issues()
356                .cmp(&a.results.total_issues())
357                .then_with(|| a.key.cmp(&b.key)),
358        }
359    });
360    sorted
361}
362
363/// Resolve the group key for a single path (for per-result tagging in SARIF/CodeClimate).
364pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
365    resolver.resolve(relative_path(path, root))
366}
367
368#[cfg(test)]
369mod tests {
370    use std::path::{Path, PathBuf};
371
372    use fallow_core::results::*;
373
374    use super::*;
375    use crate::codeowners::CodeOwners;
376
377    // ── Helpers ────────────────────────────────────────────────────
378
379    fn root() -> PathBuf {
380        PathBuf::from("/root")
381    }
382
383    fn unused_file(path: &str) -> UnusedFile {
384        UnusedFile {
385            path: PathBuf::from(path),
386        }
387    }
388
389    fn unused_export(path: &str, name: &str) -> UnusedExport {
390        UnusedExport {
391            path: PathBuf::from(path),
392            export_name: name.to_string(),
393            is_type_only: false,
394            line: 1,
395            col: 0,
396            span_start: 0,
397            is_re_export: false,
398        }
399    }
400
401    fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependency {
402        UnlistedDependency {
403            package_name: name.to_string(),
404            imported_from: sites,
405        }
406    }
407
408    fn import_site(path: &str) -> ImportSite {
409        ImportSite {
410            path: PathBuf::from(path),
411            line: 1,
412            col: 0,
413        }
414    }
415
416    // ── 1. Empty results ──────────────────────────────────────────
417
418    #[test]
419    fn empty_results_returns_empty_vec() {
420        let results = AnalysisResults::default();
421        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
422        assert!(groups.is_empty());
423    }
424
425    // ── 2. Single group ──────────────────────────────────────────
426
427    #[test]
428    fn single_group_all_same_directory() {
429        let mut results = AnalysisResults::default();
430        results.unused_files.push(unused_file("/root/src/a.ts"));
431        results.unused_files.push(unused_file("/root/src/b.ts"));
432        results
433            .unused_exports
434            .push(unused_export("/root/src/c.ts", "foo"));
435
436        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
437
438        assert_eq!(groups.len(), 1);
439        assert_eq!(groups[0].key, "src");
440        assert_eq!(groups[0].results.unused_files.len(), 2);
441        assert_eq!(groups[0].results.unused_exports.len(), 1);
442        assert_eq!(groups[0].results.total_issues(), 3);
443    }
444
445    // ── 3. Multiple groups ───────────────────────────────────────
446
447    #[test]
448    fn multiple_groups_split_by_directory() {
449        let mut results = AnalysisResults::default();
450        results.unused_files.push(unused_file("/root/src/a.ts"));
451        results.unused_files.push(unused_file("/root/lib/b.ts"));
452        results
453            .unused_exports
454            .push(unused_export("/root/src/c.ts", "bar"));
455
456        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
457
458        assert_eq!(groups.len(), 2);
459
460        let src_group = groups.iter().find(|g| g.key == "src").unwrap();
461        let lib_group = groups.iter().find(|g| g.key == "lib").unwrap();
462
463        assert_eq!(src_group.results.total_issues(), 2);
464        assert_eq!(lib_group.results.total_issues(), 1);
465    }
466
467    // ── 4. Sort order: most issues first ─────────────────────────
468
469    #[test]
470    fn sort_order_descending_by_total_issues() {
471        let mut results = AnalysisResults::default();
472        // lib: 1 issue
473        results.unused_files.push(unused_file("/root/lib/a.ts"));
474        // src: 3 issues
475        results.unused_files.push(unused_file("/root/src/a.ts"));
476        results.unused_files.push(unused_file("/root/src/b.ts"));
477        results
478            .unused_exports
479            .push(unused_export("/root/src/c.ts", "x"));
480        // test: 2 issues
481        results.unused_files.push(unused_file("/root/test/a.ts"));
482        results.unused_files.push(unused_file("/root/test/b.ts"));
483
484        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
485
486        assert_eq!(groups.len(), 3);
487        assert_eq!(groups[0].key, "src");
488        assert_eq!(groups[0].results.total_issues(), 3);
489        assert_eq!(groups[1].key, "test");
490        assert_eq!(groups[1].results.total_issues(), 2);
491        assert_eq!(groups[2].key, "lib");
492        assert_eq!(groups[2].results.total_issues(), 1);
493    }
494
495    #[test]
496    fn sort_order_alphabetical_tiebreaker() {
497        let mut results = AnalysisResults::default();
498        results.unused_files.push(unused_file("/root/beta/a.ts"));
499        results.unused_files.push(unused_file("/root/alpha/a.ts"));
500
501        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
502
503        assert_eq!(groups.len(), 2);
504        // Same issue count (1 each) -> alphabetical
505        assert_eq!(groups[0].key, "alpha");
506        assert_eq!(groups[1].key, "beta");
507    }
508
509    // ── 5. Unowned always last ───────────────────────────────────
510
511    #[test]
512    fn unowned_sorts_last_regardless_of_count() {
513        let mut results = AnalysisResults::default();
514        // src: 1 issue
515        results.unused_files.push(unused_file("/root/src/a.ts"));
516        // unlisted dep with empty imported_from -> goes to (unowned)
517        results
518            .unlisted_dependencies
519            .push(unlisted_dep("pkg-a", vec![]));
520        results
521            .unlisted_dependencies
522            .push(unlisted_dep("pkg-b", vec![]));
523        results
524            .unlisted_dependencies
525            .push(unlisted_dep("pkg-c", vec![]));
526
527        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
528
529        assert_eq!(groups.len(), 2);
530        // (unowned) has 3 issues vs src's 1, but must still be last
531        assert_eq!(groups[0].key, "src");
532        assert_eq!(groups[1].key, UNOWNED_LABEL);
533        assert_eq!(groups[1].results.total_issues(), 3);
534    }
535
536    // ── 6. Multi-location fallback ───────────────────────────────
537
538    #[test]
539    fn unlisted_dep_empty_imported_from_goes_to_unowned() {
540        let mut results = AnalysisResults::default();
541        results
542            .unlisted_dependencies
543            .push(unlisted_dep("missing-pkg", vec![]));
544
545        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
546
547        assert_eq!(groups.len(), 1);
548        assert_eq!(groups[0].key, UNOWNED_LABEL);
549        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
550    }
551
552    #[test]
553    fn unlisted_dep_with_import_site_goes_to_directory() {
554        let mut results = AnalysisResults::default();
555        results.unlisted_dependencies.push(unlisted_dep(
556            "lodash",
557            vec![import_site("/root/src/util.ts")],
558        ));
559
560        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
561
562        assert_eq!(groups.len(), 1);
563        assert_eq!(groups[0].key, "src");
564        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
565    }
566
567    // ── 7. Directory mode ────────────────────────────────────────
568
569    #[test]
570    fn directory_mode_groups_by_first_path_component() {
571        let mut results = AnalysisResults::default();
572        results
573            .unused_files
574            .push(unused_file("/root/packages/ui/Button.ts"));
575        results
576            .unused_files
577            .push(unused_file("/root/packages/auth/login.ts"));
578        results
579            .unused_exports
580            .push(unused_export("/root/apps/web/index.ts", "main"));
581
582        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
583
584        assert_eq!(groups.len(), 2);
585
586        let pkgs = groups.iter().find(|g| g.key == "packages").unwrap();
587        let apps = groups.iter().find(|g| g.key == "apps").unwrap();
588
589        assert_eq!(pkgs.results.total_issues(), 2);
590        assert_eq!(apps.results.total_issues(), 1);
591    }
592
593    // ── 8. Owner mode ────────────────────────────────────────────
594
595    #[test]
596    fn owner_mode_groups_by_codeowners_owner() {
597        let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
598        let resolver = OwnershipResolver::Owner(co);
599
600        let mut results = AnalysisResults::default();
601        results.unused_files.push(unused_file("/root/src/app.ts"));
602        results.unused_files.push(unused_file("/root/README.md"));
603
604        let groups = group_analysis_results(&results, &root(), &resolver);
605
606        assert_eq!(groups.len(), 2);
607
608        let frontend = groups.iter().find(|g| g.key == "@frontend").unwrap();
609        let default = groups.iter().find(|g| g.key == "@default").unwrap();
610
611        assert_eq!(frontend.results.unused_files.len(), 1);
612        assert_eq!(default.results.unused_files.len(), 1);
613    }
614
615    #[test]
616    fn owner_mode_unmatched_goes_to_unowned() {
617        // No catch-all rule -- files outside /src/ have no owner
618        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
619        let resolver = OwnershipResolver::Owner(co);
620
621        let mut results = AnalysisResults::default();
622        results.unused_files.push(unused_file("/root/README.md"));
623
624        let groups = group_analysis_results(&results, &root(), &resolver);
625
626        assert_eq!(groups.len(), 1);
627        assert_eq!(groups[0].key, UNOWNED_LABEL);
628    }
629
630    // ── Boundary violations ──────────────────────────────────────
631
632    #[test]
633    fn boundary_violations_grouped_by_from_path() {
634        let mut results = AnalysisResults::default();
635        results.boundary_violations.push(BoundaryViolation {
636            from_path: PathBuf::from("/root/src/bad.ts"),
637            to_path: PathBuf::from("/root/lib/secret.ts"),
638            from_zone: "src".to_string(),
639            to_zone: "lib".to_string(),
640            import_specifier: "../lib/secret".to_string(),
641            line: 1,
642            col: 0,
643        });
644
645        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
646
647        assert_eq!(groups.len(), 1);
648        assert_eq!(groups[0].key, "src");
649        assert_eq!(groups[0].results.boundary_violations.len(), 1);
650    }
651
652    // ── Circular dependencies ────────────────────────────────────
653
654    #[test]
655    fn circular_dep_empty_files_goes_to_unowned() {
656        let mut results = AnalysisResults::default();
657        results.circular_dependencies.push(CircularDependency {
658            files: vec![],
659            length: 0,
660            line: 0,
661            col: 0,
662            is_cross_package: false,
663        });
664
665        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
666
667        assert_eq!(groups.len(), 1);
668        assert_eq!(groups[0].key, UNOWNED_LABEL);
669    }
670
671    #[test]
672    fn circular_dep_uses_first_file() {
673        let mut results = AnalysisResults::default();
674        results.circular_dependencies.push(CircularDependency {
675            files: vec![
676                PathBuf::from("/root/src/a.ts"),
677                PathBuf::from("/root/lib/b.ts"),
678            ],
679            length: 2,
680            line: 1,
681            col: 0,
682            is_cross_package: false,
683        });
684
685        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
686
687        assert_eq!(groups.len(), 1);
688        assert_eq!(groups[0].key, "src");
689    }
690
691    // ── Duplicate exports ────────────────────────────────────────
692
693    #[test]
694    fn duplicate_exports_empty_locations_goes_to_unowned() {
695        let mut results = AnalysisResults::default();
696        results.duplicate_exports.push(DuplicateExport {
697            export_name: "dup".to_string(),
698            locations: vec![],
699        });
700
701        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
702
703        assert_eq!(groups.len(), 1);
704        assert_eq!(groups[0].key, UNOWNED_LABEL);
705    }
706
707    // ── resolve_owner ────────────────────────────────────────────
708
709    #[test]
710    fn resolve_owner_returns_directory() {
711        let owner = resolve_owner(
712            Path::new("/root/src/file.ts"),
713            &root(),
714            &OwnershipResolver::Directory,
715        );
716        assert_eq!(owner, "src");
717    }
718
719    #[test]
720    fn resolve_owner_returns_codeowner() {
721        let co = CodeOwners::parse("/src/ @team\n").unwrap();
722        let resolver = OwnershipResolver::Owner(co);
723        let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
724        assert_eq!(owner, "@team");
725    }
726
727    // ── mode_label ───────────────────────────────────────────────
728
729    #[test]
730    fn mode_label_owner() {
731        let co = CodeOwners::parse("").unwrap();
732        let resolver = OwnershipResolver::Owner(co);
733        assert_eq!(resolver.mode_label(), "owner");
734    }
735
736    #[test]
737    fn mode_label_directory() {
738        assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
739    }
740
741    #[test]
742    fn mode_label_package() {
743        let pr = PackageResolver { workspaces: vec![] };
744        assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
745    }
746
747    #[test]
748    fn mode_label_section() {
749        let co = CodeOwners::parse("[S] @owner\nfoo/\n").unwrap();
750        assert_eq!(OwnershipResolver::Section(co).mode_label(), "section");
751    }
752
753    // ── Section mode ────────────────────────────────────────────
754
755    #[test]
756    fn section_mode_groups_distinct_sections_with_shared_owners() {
757        // Issue #133 reproduction: billing and notifications share the lead
758        // owner but are separate sections, so they must produce 2 groups.
759        let content = "\
760            [billing] @core-reviewers @alice @bob\n\
761            src/billing/\n\
762            [notifications] @core-reviewers @alice @bob\n\
763            src/notifications/\n\
764        ";
765        let co = CodeOwners::parse(content).unwrap();
766        let resolver = OwnershipResolver::Section(co);
767
768        let mut results = AnalysisResults::default();
769        results
770            .unused_files
771            .push(unused_file("/root/src/billing/a.ts"));
772        results
773            .unused_files
774            .push(unused_file("/root/src/billing/b.ts"));
775        results
776            .unused_files
777            .push(unused_file("/root/src/notifications/c.ts"));
778
779        let groups = group_analysis_results(&results, &root(), &resolver);
780
781        assert_eq!(groups.len(), 2);
782        let billing = groups.iter().find(|g| g.key == "billing").unwrap();
783        let notifications = groups.iter().find(|g| g.key == "notifications").unwrap();
784        assert_eq!(billing.results.total_issues(), 2);
785        assert_eq!(notifications.results.total_issues(), 1);
786        assert_eq!(
787            billing.owners.as_deref(),
788            Some(
789                [
790                    "@core-reviewers".to_string(),
791                    "@alice".to_string(),
792                    "@bob".to_string()
793                ]
794                .as_slice()
795            )
796        );
797        assert_eq!(
798            notifications.owners.as_deref(),
799            Some(
800                [
801                    "@core-reviewers".to_string(),
802                    "@alice".to_string(),
803                    "@bob".to_string()
804                ]
805                .as_slice()
806            )
807        );
808    }
809
810    #[test]
811    fn section_mode_pre_section_rule_goes_to_no_section() {
812        let content = "\
813            * @default\n\
814            [Utilities] @utils\n\
815            src/utils/\n\
816        ";
817        let co = CodeOwners::parse(content).unwrap();
818        let resolver = OwnershipResolver::Section(co);
819
820        let mut results = AnalysisResults::default();
821        results.unused_files.push(unused_file("/root/README.md"));
822        results
823            .unused_files
824            .push(unused_file("/root/src/utils/greet.ts"));
825
826        let groups = group_analysis_results(&results, &root(), &resolver);
827
828        assert_eq!(groups.len(), 2);
829        let no_section = groups.iter().find(|g| g.key == "(no section)").unwrap();
830        let utils = groups.iter().find(|g| g.key == "Utilities").unwrap();
831        assert_eq!(no_section.owners.as_deref(), Some([].as_slice()));
832        assert_eq!(
833            utils.owners.as_deref(),
834            Some(["@utils".to_string()].as_slice())
835        );
836    }
837
838    #[test]
839    fn section_mode_unmatched_goes_to_unowned() {
840        let co = CodeOwners::parse("[Utilities] @utils\nsrc/utils/\n").unwrap();
841        let resolver = OwnershipResolver::Section(co);
842
843        let mut results = AnalysisResults::default();
844        results.unused_files.push(unused_file("/root/README.md"));
845
846        let groups = group_analysis_results(&results, &root(), &resolver);
847
848        assert_eq!(groups.len(), 1);
849        assert_eq!(groups[0].key, UNOWNED_LABEL);
850        assert_eq!(groups[0].owners.as_deref(), Some([].as_slice()));
851    }
852
853    #[test]
854    fn directory_mode_groups_have_no_owners_metadata() {
855        let mut results = AnalysisResults::default();
856        results.unused_files.push(unused_file("/root/src/a.ts"));
857        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
858        assert_eq!(groups[0].owners, None);
859    }
860
861    // ── PackageResolver ─────────────────────────────────────────
862
863    #[test]
864    fn package_resolver_matches_longest_prefix() {
865        let ws = vec![
866            fallow_config::WorkspaceInfo {
867                name: "packages/ui".to_string(),
868                root: PathBuf::from("/root/packages/ui"),
869                is_internal_dependency: false,
870            },
871            fallow_config::WorkspaceInfo {
872                name: "packages".to_string(),
873                root: PathBuf::from("/root/packages"),
874                is_internal_dependency: false,
875            },
876        ];
877        let pr = PackageResolver::new(Path::new("/root"), &ws);
878        // A file in packages/ui/ should match the more specific workspace
879        assert_eq!(
880            pr.resolve(Path::new("packages/ui/Button.ts")),
881            "packages/ui"
882        );
883    }
884
885    #[test]
886    fn package_resolver_root_fallback() {
887        let ws = vec![fallow_config::WorkspaceInfo {
888            name: "packages/ui".to_string(),
889            root: PathBuf::from("/root/packages/ui"),
890            is_internal_dependency: false,
891        }];
892        let pr = PackageResolver::new(Path::new("/root"), &ws);
893        // A file outside any workspace returns (root)
894        assert_eq!(pr.resolve(Path::new("src/app.ts")), ROOT_PACKAGE_LABEL);
895    }
896
897    #[test]
898    fn package_mode_groups_by_workspace() {
899        let ws = vec![
900            fallow_config::WorkspaceInfo {
901                name: "ui".to_string(),
902                root: PathBuf::from("/root/packages/ui"),
903                is_internal_dependency: false,
904            },
905            fallow_config::WorkspaceInfo {
906                name: "auth".to_string(),
907                root: PathBuf::from("/root/packages/auth"),
908                is_internal_dependency: false,
909            },
910        ];
911        let pr = PackageResolver::new(Path::new("/root"), &ws);
912        let resolver = OwnershipResolver::Package(pr);
913
914        let mut results = AnalysisResults::default();
915        results
916            .unused_files
917            .push(unused_file("/root/packages/ui/Button.ts"));
918        results
919            .unused_files
920            .push(unused_file("/root/packages/auth/login.ts"));
921        results.unused_files.push(unused_file("/root/src/main.ts"));
922
923        let groups = group_analysis_results(&results, &root(), &resolver);
924        assert_eq!(groups.len(), 3);
925
926        let ui_group = groups.iter().find(|g| g.key == "ui");
927        let auth_group = groups.iter().find(|g| g.key == "auth");
928        let root_group = groups.iter().find(|g| g.key == ROOT_PACKAGE_LABEL);
929
930        assert!(ui_group.is_some());
931        assert!(auth_group.is_some());
932        assert!(root_group.is_some());
933    }
934
935    // ── resolve_with_rule ───────────────────────────────────────
936
937    #[test]
938    fn resolve_with_rule_directory_mode_no_rule() {
939        let (key, rule) = OwnershipResolver::Directory.resolve_with_rule(Path::new("src/file.ts"));
940        assert_eq!(key, "src");
941        assert!(rule.is_none());
942    }
943
944    #[test]
945    fn resolve_with_rule_owner_mode_with_match() {
946        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
947        let resolver = OwnershipResolver::Owner(co);
948        let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
949        assert_eq!(key, "@frontend");
950        assert!(rule.is_some());
951        assert!(rule.unwrap().contains("src"));
952    }
953
954    #[test]
955    fn resolve_with_rule_owner_mode_no_match() {
956        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
957        let resolver = OwnershipResolver::Owner(co);
958        let (key, rule) = resolver.resolve_with_rule(Path::new("docs/readme.md"));
959        assert_eq!(key, UNOWNED_LABEL);
960        assert!(rule.is_none());
961    }
962
963    #[test]
964    fn resolve_with_rule_package_mode_no_rule() {
965        let pr = PackageResolver { workspaces: vec![] };
966        let resolver = OwnershipResolver::Package(pr);
967        let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
968        assert_eq!(key, ROOT_PACKAGE_LABEL);
969        assert!(rule.is_none());
970    }
971
972    // ── Missing issue type groupings ────────────────────────────
973
974    #[test]
975    fn group_unused_optional_deps() {
976        let mut results = AnalysisResults::default();
977        results.unused_optional_dependencies.push(UnusedDependency {
978            package_name: "fsevents".to_string(),
979            location: fallow_core::results::DependencyLocation::OptionalDependencies,
980            path: PathBuf::from("/root/package.json"),
981            line: 5,
982            used_in_workspaces: Vec::new(),
983        });
984
985        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
986        assert_eq!(groups.len(), 1);
987        assert_eq!(groups[0].results.unused_optional_dependencies.len(), 1);
988    }
989
990    #[test]
991    fn group_type_only_deps() {
992        let mut results = AnalysisResults::default();
993        results
994            .type_only_dependencies
995            .push(fallow_core::results::TypeOnlyDependency {
996                package_name: "zod".to_string(),
997                path: PathBuf::from("/root/package.json"),
998                line: 8,
999            });
1000
1001        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1002        assert_eq!(groups.len(), 1);
1003        assert_eq!(groups[0].results.type_only_dependencies.len(), 1);
1004    }
1005
1006    #[test]
1007    fn group_test_only_deps() {
1008        let mut results = AnalysisResults::default();
1009        results
1010            .test_only_dependencies
1011            .push(fallow_core::results::TestOnlyDependency {
1012                package_name: "vitest".to_string(),
1013                path: PathBuf::from("/root/package.json"),
1014                line: 10,
1015            });
1016
1017        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1018        assert_eq!(groups.len(), 1);
1019        assert_eq!(groups[0].results.test_only_dependencies.len(), 1);
1020    }
1021
1022    #[test]
1023    fn group_unused_enum_members() {
1024        let mut results = AnalysisResults::default();
1025        results
1026            .unused_enum_members
1027            .push(fallow_core::results::UnusedMember {
1028                path: PathBuf::from("/root/src/types.ts"),
1029                parent_name: "Status".to_string(),
1030                member_name: "Deprecated".to_string(),
1031                kind: fallow_core::extract::MemberKind::EnumMember,
1032                line: 5,
1033                col: 0,
1034            });
1035
1036        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1037        assert_eq!(groups.len(), 1);
1038        assert_eq!(groups[0].key, "src");
1039        assert_eq!(groups[0].results.unused_enum_members.len(), 1);
1040    }
1041
1042    #[test]
1043    fn group_unused_class_members() {
1044        let mut results = AnalysisResults::default();
1045        results
1046            .unused_class_members
1047            .push(fallow_core::results::UnusedMember {
1048                path: PathBuf::from("/root/lib/service.ts"),
1049                parent_name: "UserService".to_string(),
1050                member_name: "legacyMethod".to_string(),
1051                kind: fallow_core::extract::MemberKind::ClassMethod,
1052                line: 42,
1053                col: 0,
1054            });
1055
1056        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1057        assert_eq!(groups.len(), 1);
1058        assert_eq!(groups[0].key, "lib");
1059        assert_eq!(groups[0].results.unused_class_members.len(), 1);
1060    }
1061
1062    #[test]
1063    fn group_unresolved_imports() {
1064        let mut results = AnalysisResults::default();
1065        results
1066            .unresolved_imports
1067            .push(fallow_core::results::UnresolvedImport {
1068                path: PathBuf::from("/root/src/app.ts"),
1069                specifier: "./missing".to_string(),
1070                line: 1,
1071                col: 0,
1072                specifier_col: 0,
1073            });
1074
1075        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
1076        assert_eq!(groups.len(), 1);
1077        assert_eq!(groups[0].key, "src");
1078        assert_eq!(groups[0].results.unresolved_imports.len(), 1);
1079    }
1080}