Skip to main content

fallow_cli/report/
grouping.rs

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