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