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, 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}
27
28/// Resolves file paths to workspace package names via longest-prefix matching.
29///
30/// Stores workspace roots as paths relative to the project root so that
31/// resolution works with the relative paths passed to `OwnershipResolver::resolve`.
32pub struct PackageResolver {
33    /// `(relative_root, package_name)` sorted by path length descending.
34    workspaces: Vec<(PathBuf, String)>,
35}
36
37const ROOT_PACKAGE_LABEL: &str = "(root)";
38
39impl PackageResolver {
40    /// Build a resolver from discovered workspace info.
41    ///
42    /// Workspace roots are stored relative to `project_root` and sorted by path
43    /// length descending so the first match is always the most specific prefix.
44    pub fn new(project_root: &Path, workspaces: &[WorkspaceInfo]) -> Self {
45        let mut ws: Vec<(PathBuf, String)> = workspaces
46            .iter()
47            .map(|w| {
48                let rel = w.root.strip_prefix(project_root).unwrap_or(&w.root);
49                (rel.to_path_buf(), w.name.clone())
50            })
51            .collect();
52        ws.sort_by(|a, b| b.0.as_os_str().len().cmp(&a.0.as_os_str().len()));
53        Self { workspaces: ws }
54    }
55
56    /// Find the workspace package that owns `rel_path`, or `"(root)"` if none match.
57    fn resolve(&self, rel_path: &Path) -> &str {
58        self.workspaces
59            .iter()
60            .find(|(root, _)| rel_path.starts_with(root))
61            .map_or(ROOT_PACKAGE_LABEL, |(_, name)| name.as_str())
62    }
63}
64
65impl OwnershipResolver {
66    /// Resolve the group key for a file path (relative to project root).
67    pub fn resolve(&self, rel_path: &Path) -> String {
68        match self {
69            Self::Owner(co) => co.owner_of(rel_path).unwrap_or(UNOWNED_LABEL).to_string(),
70            Self::Directory => codeowners::directory_group(rel_path).to_string(),
71            Self::Package(pr) => pr.resolve(rel_path).to_string(),
72        }
73    }
74
75    /// Resolve the group key and matching rule for a path.
76    ///
77    /// Returns `(owner, Some(pattern))` for Owner mode,
78    /// `(directory, None)` for Directory/Package mode.
79    pub fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>) {
80        match self {
81            Self::Owner(co) => {
82                if let Some((owner, rule)) = co.owner_and_rule_of(rel_path) {
83                    (owner.to_string(), Some(rule.to_string()))
84                } else {
85                    (UNOWNED_LABEL.to_string(), None)
86                }
87            }
88            Self::Directory => (codeowners::directory_group(rel_path).to_string(), None),
89            Self::Package(pr) => (pr.resolve(rel_path).to_string(), None),
90        }
91    }
92
93    /// Label for the grouping mode (used in JSON `grouped_by` field).
94    pub fn mode_label(&self) -> &'static str {
95        match self {
96            Self::Owner(_) => "owner",
97            Self::Directory => "directory",
98            Self::Package(_) => "package",
99        }
100    }
101}
102
103/// A single group: a label and its subset of results.
104pub struct ResultGroup {
105    /// Group label (owner name or directory).
106    pub key: String,
107    /// Issues belonging to this group.
108    pub results: AnalysisResults,
109}
110
111/// Partition analysis results into groups by ownership or directory.
112///
113/// Each issue is assigned to a group by extracting its primary file path
114/// and resolving the group key via the `OwnershipResolver`.
115/// Returns groups sorted alphabetically by key, with `(unowned)` last.
116pub fn group_analysis_results(
117    results: &AnalysisResults,
118    root: &Path,
119    resolver: &OwnershipResolver,
120) -> Vec<ResultGroup> {
121    let mut groups: FxHashMap<String, AnalysisResults> = FxHashMap::default();
122
123    let key_for = |path: &Path| -> String { resolver.resolve(relative_path(path, root)) };
124
125    // ── File-scoped issue types ─────────────────────────────────
126    for item in &results.unused_files {
127        groups
128            .entry(key_for(&item.path))
129            .or_default()
130            .unused_files
131            .push(item.clone());
132    }
133    for item in &results.unused_exports {
134        groups
135            .entry(key_for(&item.path))
136            .or_default()
137            .unused_exports
138            .push(item.clone());
139    }
140    for item in &results.unused_types {
141        groups
142            .entry(key_for(&item.path))
143            .or_default()
144            .unused_types
145            .push(item.clone());
146    }
147    for item in &results.unused_enum_members {
148        groups
149            .entry(key_for(&item.path))
150            .or_default()
151            .unused_enum_members
152            .push(item.clone());
153    }
154    for item in &results.unused_class_members {
155        groups
156            .entry(key_for(&item.path))
157            .or_default()
158            .unused_class_members
159            .push(item.clone());
160    }
161    for item in &results.unresolved_imports {
162        groups
163            .entry(key_for(&item.path))
164            .or_default()
165            .unresolved_imports
166            .push(item.clone());
167    }
168
169    // ── Dependency-scoped (use package.json path) ───────────────
170    for item in &results.unused_dependencies {
171        groups
172            .entry(key_for(&item.path))
173            .or_default()
174            .unused_dependencies
175            .push(item.clone());
176    }
177    for item in &results.unused_dev_dependencies {
178        groups
179            .entry(key_for(&item.path))
180            .or_default()
181            .unused_dev_dependencies
182            .push(item.clone());
183    }
184    for item in &results.unused_optional_dependencies {
185        groups
186            .entry(key_for(&item.path))
187            .or_default()
188            .unused_optional_dependencies
189            .push(item.clone());
190    }
191    for item in &results.type_only_dependencies {
192        groups
193            .entry(key_for(&item.path))
194            .or_default()
195            .type_only_dependencies
196            .push(item.clone());
197    }
198    for item in &results.test_only_dependencies {
199        groups
200            .entry(key_for(&item.path))
201            .or_default()
202            .test_only_dependencies
203            .push(item.clone());
204    }
205
206    // ── Multi-location types (use first location) ───────────────
207    for item in &results.unlisted_dependencies {
208        let key = item
209            .imported_from
210            .first()
211            .map_or_else(|| UNOWNED_LABEL.to_string(), |site| key_for(&site.path));
212        groups
213            .entry(key)
214            .or_default()
215            .unlisted_dependencies
216            .push(item.clone());
217    }
218    for item in &results.duplicate_exports {
219        let key = item
220            .locations
221            .first()
222            .map_or_else(|| UNOWNED_LABEL.to_string(), |loc| key_for(&loc.path));
223        groups
224            .entry(key)
225            .or_default()
226            .duplicate_exports
227            .push(item.clone());
228    }
229    for item in &results.circular_dependencies {
230        let key = item
231            .files
232            .first()
233            .map_or_else(|| UNOWNED_LABEL.to_string(), |f| key_for(f));
234        groups
235            .entry(key)
236            .or_default()
237            .circular_dependencies
238            .push(item.clone());
239    }
240    for item in &results.boundary_violations {
241        groups
242            .entry(key_for(&item.from_path))
243            .or_default()
244            .boundary_violations
245            .push(item.clone());
246    }
247
248    // ── Sort: most issues first, alphabetical tiebreaker, (unowned) last
249    let mut sorted: Vec<_> = groups
250        .into_iter()
251        .map(|(key, results)| ResultGroup { key, results })
252        .collect();
253    sorted.sort_by(|a, b| {
254        let a_unowned = a.key == UNOWNED_LABEL;
255        let b_unowned = b.key == UNOWNED_LABEL;
256        match (a_unowned, b_unowned) {
257            (true, false) => std::cmp::Ordering::Greater,
258            (false, true) => std::cmp::Ordering::Less,
259            _ => b
260                .results
261                .total_issues()
262                .cmp(&a.results.total_issues())
263                .then_with(|| a.key.cmp(&b.key)),
264        }
265    });
266    sorted
267}
268
269/// Resolve the group key for a single path (for per-result tagging in SARIF/CodeClimate).
270pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
271    resolver.resolve(relative_path(path, root))
272}
273
274#[cfg(test)]
275mod tests {
276    use std::path::{Path, PathBuf};
277
278    use fallow_core::results::*;
279
280    use super::*;
281    use crate::codeowners::CodeOwners;
282
283    // ── Helpers ────────────────────────────────────────────────────
284
285    fn root() -> PathBuf {
286        PathBuf::from("/root")
287    }
288
289    fn unused_file(path: &str) -> UnusedFile {
290        UnusedFile {
291            path: PathBuf::from(path),
292        }
293    }
294
295    fn unused_export(path: &str, name: &str) -> UnusedExport {
296        UnusedExport {
297            path: PathBuf::from(path),
298            export_name: name.to_string(),
299            is_type_only: false,
300            line: 1,
301            col: 0,
302            span_start: 0,
303            is_re_export: false,
304        }
305    }
306
307    fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependency {
308        UnlistedDependency {
309            package_name: name.to_string(),
310            imported_from: sites,
311        }
312    }
313
314    fn import_site(path: &str) -> ImportSite {
315        ImportSite {
316            path: PathBuf::from(path),
317            line: 1,
318            col: 0,
319        }
320    }
321
322    // ── 1. Empty results ──────────────────────────────────────────
323
324    #[test]
325    fn empty_results_returns_empty_vec() {
326        let results = AnalysisResults::default();
327        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
328        assert!(groups.is_empty());
329    }
330
331    // ── 2. Single group ──────────────────────────────────────────
332
333    #[test]
334    fn single_group_all_same_directory() {
335        let mut results = AnalysisResults::default();
336        results.unused_files.push(unused_file("/root/src/a.ts"));
337        results.unused_files.push(unused_file("/root/src/b.ts"));
338        results
339            .unused_exports
340            .push(unused_export("/root/src/c.ts", "foo"));
341
342        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
343
344        assert_eq!(groups.len(), 1);
345        assert_eq!(groups[0].key, "src");
346        assert_eq!(groups[0].results.unused_files.len(), 2);
347        assert_eq!(groups[0].results.unused_exports.len(), 1);
348        assert_eq!(groups[0].results.total_issues(), 3);
349    }
350
351    // ── 3. Multiple groups ───────────────────────────────────────
352
353    #[test]
354    fn multiple_groups_split_by_directory() {
355        let mut results = AnalysisResults::default();
356        results.unused_files.push(unused_file("/root/src/a.ts"));
357        results.unused_files.push(unused_file("/root/lib/b.ts"));
358        results
359            .unused_exports
360            .push(unused_export("/root/src/c.ts", "bar"));
361
362        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
363
364        assert_eq!(groups.len(), 2);
365
366        let src_group = groups.iter().find(|g| g.key == "src").unwrap();
367        let lib_group = groups.iter().find(|g| g.key == "lib").unwrap();
368
369        assert_eq!(src_group.results.total_issues(), 2);
370        assert_eq!(lib_group.results.total_issues(), 1);
371    }
372
373    // ── 4. Sort order: most issues first ─────────────────────────
374
375    #[test]
376    fn sort_order_descending_by_total_issues() {
377        let mut results = AnalysisResults::default();
378        // lib: 1 issue
379        results.unused_files.push(unused_file("/root/lib/a.ts"));
380        // src: 3 issues
381        results.unused_files.push(unused_file("/root/src/a.ts"));
382        results.unused_files.push(unused_file("/root/src/b.ts"));
383        results
384            .unused_exports
385            .push(unused_export("/root/src/c.ts", "x"));
386        // test: 2 issues
387        results.unused_files.push(unused_file("/root/test/a.ts"));
388        results.unused_files.push(unused_file("/root/test/b.ts"));
389
390        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
391
392        assert_eq!(groups.len(), 3);
393        assert_eq!(groups[0].key, "src");
394        assert_eq!(groups[0].results.total_issues(), 3);
395        assert_eq!(groups[1].key, "test");
396        assert_eq!(groups[1].results.total_issues(), 2);
397        assert_eq!(groups[2].key, "lib");
398        assert_eq!(groups[2].results.total_issues(), 1);
399    }
400
401    #[test]
402    fn sort_order_alphabetical_tiebreaker() {
403        let mut results = AnalysisResults::default();
404        results.unused_files.push(unused_file("/root/beta/a.ts"));
405        results.unused_files.push(unused_file("/root/alpha/a.ts"));
406
407        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
408
409        assert_eq!(groups.len(), 2);
410        // Same issue count (1 each) -> alphabetical
411        assert_eq!(groups[0].key, "alpha");
412        assert_eq!(groups[1].key, "beta");
413    }
414
415    // ── 5. Unowned always last ───────────────────────────────────
416
417    #[test]
418    fn unowned_sorts_last_regardless_of_count() {
419        let mut results = AnalysisResults::default();
420        // src: 1 issue
421        results.unused_files.push(unused_file("/root/src/a.ts"));
422        // unlisted dep with empty imported_from -> goes to (unowned)
423        results
424            .unlisted_dependencies
425            .push(unlisted_dep("pkg-a", vec![]));
426        results
427            .unlisted_dependencies
428            .push(unlisted_dep("pkg-b", vec![]));
429        results
430            .unlisted_dependencies
431            .push(unlisted_dep("pkg-c", vec![]));
432
433        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
434
435        assert_eq!(groups.len(), 2);
436        // (unowned) has 3 issues vs src's 1, but must still be last
437        assert_eq!(groups[0].key, "src");
438        assert_eq!(groups[1].key, UNOWNED_LABEL);
439        assert_eq!(groups[1].results.total_issues(), 3);
440    }
441
442    // ── 6. Multi-location fallback ───────────────────────────────
443
444    #[test]
445    fn unlisted_dep_empty_imported_from_goes_to_unowned() {
446        let mut results = AnalysisResults::default();
447        results
448            .unlisted_dependencies
449            .push(unlisted_dep("missing-pkg", vec![]));
450
451        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
452
453        assert_eq!(groups.len(), 1);
454        assert_eq!(groups[0].key, UNOWNED_LABEL);
455        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
456    }
457
458    #[test]
459    fn unlisted_dep_with_import_site_goes_to_directory() {
460        let mut results = AnalysisResults::default();
461        results.unlisted_dependencies.push(unlisted_dep(
462            "lodash",
463            vec![import_site("/root/src/util.ts")],
464        ));
465
466        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
467
468        assert_eq!(groups.len(), 1);
469        assert_eq!(groups[0].key, "src");
470        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
471    }
472
473    // ── 7. Directory mode ────────────────────────────────────────
474
475    #[test]
476    fn directory_mode_groups_by_first_path_component() {
477        let mut results = AnalysisResults::default();
478        results
479            .unused_files
480            .push(unused_file("/root/packages/ui/Button.ts"));
481        results
482            .unused_files
483            .push(unused_file("/root/packages/auth/login.ts"));
484        results
485            .unused_exports
486            .push(unused_export("/root/apps/web/index.ts", "main"));
487
488        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
489
490        assert_eq!(groups.len(), 2);
491
492        let pkgs = groups.iter().find(|g| g.key == "packages").unwrap();
493        let apps = groups.iter().find(|g| g.key == "apps").unwrap();
494
495        assert_eq!(pkgs.results.total_issues(), 2);
496        assert_eq!(apps.results.total_issues(), 1);
497    }
498
499    // ── 8. Owner mode ────────────────────────────────────────────
500
501    #[test]
502    fn owner_mode_groups_by_codeowners_owner() {
503        let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
504        let resolver = OwnershipResolver::Owner(co);
505
506        let mut results = AnalysisResults::default();
507        results.unused_files.push(unused_file("/root/src/app.ts"));
508        results.unused_files.push(unused_file("/root/README.md"));
509
510        let groups = group_analysis_results(&results, &root(), &resolver);
511
512        assert_eq!(groups.len(), 2);
513
514        let frontend = groups.iter().find(|g| g.key == "@frontend").unwrap();
515        let default = groups.iter().find(|g| g.key == "@default").unwrap();
516
517        assert_eq!(frontend.results.unused_files.len(), 1);
518        assert_eq!(default.results.unused_files.len(), 1);
519    }
520
521    #[test]
522    fn owner_mode_unmatched_goes_to_unowned() {
523        // No catch-all rule -- files outside /src/ have no owner
524        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
525        let resolver = OwnershipResolver::Owner(co);
526
527        let mut results = AnalysisResults::default();
528        results.unused_files.push(unused_file("/root/README.md"));
529
530        let groups = group_analysis_results(&results, &root(), &resolver);
531
532        assert_eq!(groups.len(), 1);
533        assert_eq!(groups[0].key, UNOWNED_LABEL);
534    }
535
536    // ── Boundary violations ──────────────────────────────────────
537
538    #[test]
539    fn boundary_violations_grouped_by_from_path() {
540        let mut results = AnalysisResults::default();
541        results.boundary_violations.push(BoundaryViolation {
542            from_path: PathBuf::from("/root/src/bad.ts"),
543            to_path: PathBuf::from("/root/lib/secret.ts"),
544            from_zone: "src".to_string(),
545            to_zone: "lib".to_string(),
546            import_specifier: "../lib/secret".to_string(),
547            line: 1,
548            col: 0,
549        });
550
551        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
552
553        assert_eq!(groups.len(), 1);
554        assert_eq!(groups[0].key, "src");
555        assert_eq!(groups[0].results.boundary_violations.len(), 1);
556    }
557
558    // ── Circular dependencies ────────────────────────────────────
559
560    #[test]
561    fn circular_dep_empty_files_goes_to_unowned() {
562        let mut results = AnalysisResults::default();
563        results.circular_dependencies.push(CircularDependency {
564            files: vec![],
565            length: 0,
566            line: 0,
567            col: 0,
568            is_cross_package: false,
569        });
570
571        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
572
573        assert_eq!(groups.len(), 1);
574        assert_eq!(groups[0].key, UNOWNED_LABEL);
575    }
576
577    #[test]
578    fn circular_dep_uses_first_file() {
579        let mut results = AnalysisResults::default();
580        results.circular_dependencies.push(CircularDependency {
581            files: vec![
582                PathBuf::from("/root/src/a.ts"),
583                PathBuf::from("/root/lib/b.ts"),
584            ],
585            length: 2,
586            line: 1,
587            col: 0,
588            is_cross_package: false,
589        });
590
591        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
592
593        assert_eq!(groups.len(), 1);
594        assert_eq!(groups[0].key, "src");
595    }
596
597    // ── Duplicate exports ────────────────────────────────────────
598
599    #[test]
600    fn duplicate_exports_empty_locations_goes_to_unowned() {
601        let mut results = AnalysisResults::default();
602        results.duplicate_exports.push(DuplicateExport {
603            export_name: "dup".to_string(),
604            locations: vec![],
605        });
606
607        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
608
609        assert_eq!(groups.len(), 1);
610        assert_eq!(groups[0].key, UNOWNED_LABEL);
611    }
612
613    // ── resolve_owner ────────────────────────────────────────────
614
615    #[test]
616    fn resolve_owner_returns_directory() {
617        let owner = resolve_owner(
618            Path::new("/root/src/file.ts"),
619            &root(),
620            &OwnershipResolver::Directory,
621        );
622        assert_eq!(owner, "src");
623    }
624
625    #[test]
626    fn resolve_owner_returns_codeowner() {
627        let co = CodeOwners::parse("/src/ @team\n").unwrap();
628        let resolver = OwnershipResolver::Owner(co);
629        let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
630        assert_eq!(owner, "@team");
631    }
632
633    // ── mode_label ───────────────────────────────────────────────
634
635    #[test]
636    fn mode_label_owner() {
637        let co = CodeOwners::parse("").unwrap();
638        let resolver = OwnershipResolver::Owner(co);
639        assert_eq!(resolver.mode_label(), "owner");
640    }
641
642    #[test]
643    fn mode_label_directory() {
644        assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
645    }
646}