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