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_key(|b| std::cmp::Reverse(b.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    for item in &results.stale_suppressions {
248        groups
249            .entry(key_for(&item.path))
250            .or_default()
251            .stale_suppressions
252            .push(item.clone());
253    }
254
255    // ── Sort: most issues first, alphabetical tiebreaker, (unowned) last
256    let mut sorted: Vec<_> = groups
257        .into_iter()
258        .map(|(key, results)| ResultGroup { key, results })
259        .collect();
260    sorted.sort_by(|a, b| {
261        let a_unowned = a.key == UNOWNED_LABEL;
262        let b_unowned = b.key == UNOWNED_LABEL;
263        match (a_unowned, b_unowned) {
264            (true, false) => std::cmp::Ordering::Greater,
265            (false, true) => std::cmp::Ordering::Less,
266            _ => b
267                .results
268                .total_issues()
269                .cmp(&a.results.total_issues())
270                .then_with(|| a.key.cmp(&b.key)),
271        }
272    });
273    sorted
274}
275
276/// Resolve the group key for a single path (for per-result tagging in SARIF/CodeClimate).
277pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
278    resolver.resolve(relative_path(path, root))
279}
280
281#[cfg(test)]
282mod tests {
283    use std::path::{Path, PathBuf};
284
285    use fallow_core::results::*;
286
287    use super::*;
288    use crate::codeowners::CodeOwners;
289
290    // ── Helpers ────────────────────────────────────────────────────
291
292    fn root() -> PathBuf {
293        PathBuf::from("/root")
294    }
295
296    fn unused_file(path: &str) -> UnusedFile {
297        UnusedFile {
298            path: PathBuf::from(path),
299        }
300    }
301
302    fn unused_export(path: &str, name: &str) -> UnusedExport {
303        UnusedExport {
304            path: PathBuf::from(path),
305            export_name: name.to_string(),
306            is_type_only: false,
307            line: 1,
308            col: 0,
309            span_start: 0,
310            is_re_export: false,
311        }
312    }
313
314    fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependency {
315        UnlistedDependency {
316            package_name: name.to_string(),
317            imported_from: sites,
318        }
319    }
320
321    fn import_site(path: &str) -> ImportSite {
322        ImportSite {
323            path: PathBuf::from(path),
324            line: 1,
325            col: 0,
326        }
327    }
328
329    // ── 1. Empty results ──────────────────────────────────────────
330
331    #[test]
332    fn empty_results_returns_empty_vec() {
333        let results = AnalysisResults::default();
334        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
335        assert!(groups.is_empty());
336    }
337
338    // ── 2. Single group ──────────────────────────────────────────
339
340    #[test]
341    fn single_group_all_same_directory() {
342        let mut results = AnalysisResults::default();
343        results.unused_files.push(unused_file("/root/src/a.ts"));
344        results.unused_files.push(unused_file("/root/src/b.ts"));
345        results
346            .unused_exports
347            .push(unused_export("/root/src/c.ts", "foo"));
348
349        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
350
351        assert_eq!(groups.len(), 1);
352        assert_eq!(groups[0].key, "src");
353        assert_eq!(groups[0].results.unused_files.len(), 2);
354        assert_eq!(groups[0].results.unused_exports.len(), 1);
355        assert_eq!(groups[0].results.total_issues(), 3);
356    }
357
358    // ── 3. Multiple groups ───────────────────────────────────────
359
360    #[test]
361    fn multiple_groups_split_by_directory() {
362        let mut results = AnalysisResults::default();
363        results.unused_files.push(unused_file("/root/src/a.ts"));
364        results.unused_files.push(unused_file("/root/lib/b.ts"));
365        results
366            .unused_exports
367            .push(unused_export("/root/src/c.ts", "bar"));
368
369        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
370
371        assert_eq!(groups.len(), 2);
372
373        let src_group = groups.iter().find(|g| g.key == "src").unwrap();
374        let lib_group = groups.iter().find(|g| g.key == "lib").unwrap();
375
376        assert_eq!(src_group.results.total_issues(), 2);
377        assert_eq!(lib_group.results.total_issues(), 1);
378    }
379
380    // ── 4. Sort order: most issues first ─────────────────────────
381
382    #[test]
383    fn sort_order_descending_by_total_issues() {
384        let mut results = AnalysisResults::default();
385        // lib: 1 issue
386        results.unused_files.push(unused_file("/root/lib/a.ts"));
387        // src: 3 issues
388        results.unused_files.push(unused_file("/root/src/a.ts"));
389        results.unused_files.push(unused_file("/root/src/b.ts"));
390        results
391            .unused_exports
392            .push(unused_export("/root/src/c.ts", "x"));
393        // test: 2 issues
394        results.unused_files.push(unused_file("/root/test/a.ts"));
395        results.unused_files.push(unused_file("/root/test/b.ts"));
396
397        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
398
399        assert_eq!(groups.len(), 3);
400        assert_eq!(groups[0].key, "src");
401        assert_eq!(groups[0].results.total_issues(), 3);
402        assert_eq!(groups[1].key, "test");
403        assert_eq!(groups[1].results.total_issues(), 2);
404        assert_eq!(groups[2].key, "lib");
405        assert_eq!(groups[2].results.total_issues(), 1);
406    }
407
408    #[test]
409    fn sort_order_alphabetical_tiebreaker() {
410        let mut results = AnalysisResults::default();
411        results.unused_files.push(unused_file("/root/beta/a.ts"));
412        results.unused_files.push(unused_file("/root/alpha/a.ts"));
413
414        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
415
416        assert_eq!(groups.len(), 2);
417        // Same issue count (1 each) -> alphabetical
418        assert_eq!(groups[0].key, "alpha");
419        assert_eq!(groups[1].key, "beta");
420    }
421
422    // ── 5. Unowned always last ───────────────────────────────────
423
424    #[test]
425    fn unowned_sorts_last_regardless_of_count() {
426        let mut results = AnalysisResults::default();
427        // src: 1 issue
428        results.unused_files.push(unused_file("/root/src/a.ts"));
429        // unlisted dep with empty imported_from -> goes to (unowned)
430        results
431            .unlisted_dependencies
432            .push(unlisted_dep("pkg-a", vec![]));
433        results
434            .unlisted_dependencies
435            .push(unlisted_dep("pkg-b", vec![]));
436        results
437            .unlisted_dependencies
438            .push(unlisted_dep("pkg-c", vec![]));
439
440        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
441
442        assert_eq!(groups.len(), 2);
443        // (unowned) has 3 issues vs src's 1, but must still be last
444        assert_eq!(groups[0].key, "src");
445        assert_eq!(groups[1].key, UNOWNED_LABEL);
446        assert_eq!(groups[1].results.total_issues(), 3);
447    }
448
449    // ── 6. Multi-location fallback ───────────────────────────────
450
451    #[test]
452    fn unlisted_dep_empty_imported_from_goes_to_unowned() {
453        let mut results = AnalysisResults::default();
454        results
455            .unlisted_dependencies
456            .push(unlisted_dep("missing-pkg", vec![]));
457
458        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
459
460        assert_eq!(groups.len(), 1);
461        assert_eq!(groups[0].key, UNOWNED_LABEL);
462        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
463    }
464
465    #[test]
466    fn unlisted_dep_with_import_site_goes_to_directory() {
467        let mut results = AnalysisResults::default();
468        results.unlisted_dependencies.push(unlisted_dep(
469            "lodash",
470            vec![import_site("/root/src/util.ts")],
471        ));
472
473        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
474
475        assert_eq!(groups.len(), 1);
476        assert_eq!(groups[0].key, "src");
477        assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
478    }
479
480    // ── 7. Directory mode ────────────────────────────────────────
481
482    #[test]
483    fn directory_mode_groups_by_first_path_component() {
484        let mut results = AnalysisResults::default();
485        results
486            .unused_files
487            .push(unused_file("/root/packages/ui/Button.ts"));
488        results
489            .unused_files
490            .push(unused_file("/root/packages/auth/login.ts"));
491        results
492            .unused_exports
493            .push(unused_export("/root/apps/web/index.ts", "main"));
494
495        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
496
497        assert_eq!(groups.len(), 2);
498
499        let pkgs = groups.iter().find(|g| g.key == "packages").unwrap();
500        let apps = groups.iter().find(|g| g.key == "apps").unwrap();
501
502        assert_eq!(pkgs.results.total_issues(), 2);
503        assert_eq!(apps.results.total_issues(), 1);
504    }
505
506    // ── 8. Owner mode ────────────────────────────────────────────
507
508    #[test]
509    fn owner_mode_groups_by_codeowners_owner() {
510        let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
511        let resolver = OwnershipResolver::Owner(co);
512
513        let mut results = AnalysisResults::default();
514        results.unused_files.push(unused_file("/root/src/app.ts"));
515        results.unused_files.push(unused_file("/root/README.md"));
516
517        let groups = group_analysis_results(&results, &root(), &resolver);
518
519        assert_eq!(groups.len(), 2);
520
521        let frontend = groups.iter().find(|g| g.key == "@frontend").unwrap();
522        let default = groups.iter().find(|g| g.key == "@default").unwrap();
523
524        assert_eq!(frontend.results.unused_files.len(), 1);
525        assert_eq!(default.results.unused_files.len(), 1);
526    }
527
528    #[test]
529    fn owner_mode_unmatched_goes_to_unowned() {
530        // No catch-all rule -- files outside /src/ have no owner
531        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
532        let resolver = OwnershipResolver::Owner(co);
533
534        let mut results = AnalysisResults::default();
535        results.unused_files.push(unused_file("/root/README.md"));
536
537        let groups = group_analysis_results(&results, &root(), &resolver);
538
539        assert_eq!(groups.len(), 1);
540        assert_eq!(groups[0].key, UNOWNED_LABEL);
541    }
542
543    // ── Boundary violations ──────────────────────────────────────
544
545    #[test]
546    fn boundary_violations_grouped_by_from_path() {
547        let mut results = AnalysisResults::default();
548        results.boundary_violations.push(BoundaryViolation {
549            from_path: PathBuf::from("/root/src/bad.ts"),
550            to_path: PathBuf::from("/root/lib/secret.ts"),
551            from_zone: "src".to_string(),
552            to_zone: "lib".to_string(),
553            import_specifier: "../lib/secret".to_string(),
554            line: 1,
555            col: 0,
556        });
557
558        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
559
560        assert_eq!(groups.len(), 1);
561        assert_eq!(groups[0].key, "src");
562        assert_eq!(groups[0].results.boundary_violations.len(), 1);
563    }
564
565    // ── Circular dependencies ────────────────────────────────────
566
567    #[test]
568    fn circular_dep_empty_files_goes_to_unowned() {
569        let mut results = AnalysisResults::default();
570        results.circular_dependencies.push(CircularDependency {
571            files: vec![],
572            length: 0,
573            line: 0,
574            col: 0,
575            is_cross_package: false,
576        });
577
578        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
579
580        assert_eq!(groups.len(), 1);
581        assert_eq!(groups[0].key, UNOWNED_LABEL);
582    }
583
584    #[test]
585    fn circular_dep_uses_first_file() {
586        let mut results = AnalysisResults::default();
587        results.circular_dependencies.push(CircularDependency {
588            files: vec![
589                PathBuf::from("/root/src/a.ts"),
590                PathBuf::from("/root/lib/b.ts"),
591            ],
592            length: 2,
593            line: 1,
594            col: 0,
595            is_cross_package: false,
596        });
597
598        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
599
600        assert_eq!(groups.len(), 1);
601        assert_eq!(groups[0].key, "src");
602    }
603
604    // ── Duplicate exports ────────────────────────────────────────
605
606    #[test]
607    fn duplicate_exports_empty_locations_goes_to_unowned() {
608        let mut results = AnalysisResults::default();
609        results.duplicate_exports.push(DuplicateExport {
610            export_name: "dup".to_string(),
611            locations: vec![],
612        });
613
614        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
615
616        assert_eq!(groups.len(), 1);
617        assert_eq!(groups[0].key, UNOWNED_LABEL);
618    }
619
620    // ── resolve_owner ────────────────────────────────────────────
621
622    #[test]
623    fn resolve_owner_returns_directory() {
624        let owner = resolve_owner(
625            Path::new("/root/src/file.ts"),
626            &root(),
627            &OwnershipResolver::Directory,
628        );
629        assert_eq!(owner, "src");
630    }
631
632    #[test]
633    fn resolve_owner_returns_codeowner() {
634        let co = CodeOwners::parse("/src/ @team\n").unwrap();
635        let resolver = OwnershipResolver::Owner(co);
636        let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
637        assert_eq!(owner, "@team");
638    }
639
640    // ── mode_label ───────────────────────────────────────────────
641
642    #[test]
643    fn mode_label_owner() {
644        let co = CodeOwners::parse("").unwrap();
645        let resolver = OwnershipResolver::Owner(co);
646        assert_eq!(resolver.mode_label(), "owner");
647    }
648
649    #[test]
650    fn mode_label_directory() {
651        assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
652    }
653
654    #[test]
655    fn mode_label_package() {
656        let pr = PackageResolver { workspaces: vec![] };
657        assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
658    }
659
660    // ── PackageResolver ─────────────────────────────────────────
661
662    #[test]
663    fn package_resolver_matches_longest_prefix() {
664        let ws = vec![
665            fallow_config::WorkspaceInfo {
666                name: "packages/ui".to_string(),
667                root: PathBuf::from("/root/packages/ui"),
668                is_internal_dependency: false,
669            },
670            fallow_config::WorkspaceInfo {
671                name: "packages".to_string(),
672                root: PathBuf::from("/root/packages"),
673                is_internal_dependency: false,
674            },
675        ];
676        let pr = PackageResolver::new(Path::new("/root"), &ws);
677        // A file in packages/ui/ should match the more specific workspace
678        assert_eq!(
679            pr.resolve(Path::new("packages/ui/Button.ts")),
680            "packages/ui"
681        );
682    }
683
684    #[test]
685    fn package_resolver_root_fallback() {
686        let ws = vec![fallow_config::WorkspaceInfo {
687            name: "packages/ui".to_string(),
688            root: PathBuf::from("/root/packages/ui"),
689            is_internal_dependency: false,
690        }];
691        let pr = PackageResolver::new(Path::new("/root"), &ws);
692        // A file outside any workspace returns (root)
693        assert_eq!(pr.resolve(Path::new("src/app.ts")), ROOT_PACKAGE_LABEL);
694    }
695
696    #[test]
697    fn package_mode_groups_by_workspace() {
698        let ws = vec![
699            fallow_config::WorkspaceInfo {
700                name: "ui".to_string(),
701                root: PathBuf::from("/root/packages/ui"),
702                is_internal_dependency: false,
703            },
704            fallow_config::WorkspaceInfo {
705                name: "auth".to_string(),
706                root: PathBuf::from("/root/packages/auth"),
707                is_internal_dependency: false,
708            },
709        ];
710        let pr = PackageResolver::new(Path::new("/root"), &ws);
711        let resolver = OwnershipResolver::Package(pr);
712
713        let mut results = AnalysisResults::default();
714        results
715            .unused_files
716            .push(unused_file("/root/packages/ui/Button.ts"));
717        results
718            .unused_files
719            .push(unused_file("/root/packages/auth/login.ts"));
720        results.unused_files.push(unused_file("/root/src/main.ts"));
721
722        let groups = group_analysis_results(&results, &root(), &resolver);
723        assert_eq!(groups.len(), 3);
724
725        let ui_group = groups.iter().find(|g| g.key == "ui");
726        let auth_group = groups.iter().find(|g| g.key == "auth");
727        let root_group = groups.iter().find(|g| g.key == ROOT_PACKAGE_LABEL);
728
729        assert!(ui_group.is_some());
730        assert!(auth_group.is_some());
731        assert!(root_group.is_some());
732    }
733
734    // ── resolve_with_rule ───────────────────────────────────────
735
736    #[test]
737    fn resolve_with_rule_directory_mode_no_rule() {
738        let (key, rule) = OwnershipResolver::Directory.resolve_with_rule(Path::new("src/file.ts"));
739        assert_eq!(key, "src");
740        assert!(rule.is_none());
741    }
742
743    #[test]
744    fn resolve_with_rule_owner_mode_with_match() {
745        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
746        let resolver = OwnershipResolver::Owner(co);
747        let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
748        assert_eq!(key, "@frontend");
749        assert!(rule.is_some());
750        assert!(rule.unwrap().contains("src"));
751    }
752
753    #[test]
754    fn resolve_with_rule_owner_mode_no_match() {
755        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
756        let resolver = OwnershipResolver::Owner(co);
757        let (key, rule) = resolver.resolve_with_rule(Path::new("docs/readme.md"));
758        assert_eq!(key, UNOWNED_LABEL);
759        assert!(rule.is_none());
760    }
761
762    #[test]
763    fn resolve_with_rule_package_mode_no_rule() {
764        let pr = PackageResolver { workspaces: vec![] };
765        let resolver = OwnershipResolver::Package(pr);
766        let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
767        assert_eq!(key, ROOT_PACKAGE_LABEL);
768        assert!(rule.is_none());
769    }
770
771    // ── Missing issue type groupings ────────────────────────────
772
773    #[test]
774    fn group_unused_optional_deps() {
775        let mut results = AnalysisResults::default();
776        results.unused_optional_dependencies.push(UnusedDependency {
777            package_name: "fsevents".to_string(),
778            location: fallow_core::results::DependencyLocation::OptionalDependencies,
779            path: PathBuf::from("/root/package.json"),
780            line: 5,
781        });
782
783        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
784        assert_eq!(groups.len(), 1);
785        assert_eq!(groups[0].results.unused_optional_dependencies.len(), 1);
786    }
787
788    #[test]
789    fn group_type_only_deps() {
790        let mut results = AnalysisResults::default();
791        results
792            .type_only_dependencies
793            .push(fallow_core::results::TypeOnlyDependency {
794                package_name: "zod".to_string(),
795                path: PathBuf::from("/root/package.json"),
796                line: 8,
797            });
798
799        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
800        assert_eq!(groups.len(), 1);
801        assert_eq!(groups[0].results.type_only_dependencies.len(), 1);
802    }
803
804    #[test]
805    fn group_test_only_deps() {
806        let mut results = AnalysisResults::default();
807        results
808            .test_only_dependencies
809            .push(fallow_core::results::TestOnlyDependency {
810                package_name: "vitest".to_string(),
811                path: PathBuf::from("/root/package.json"),
812                line: 10,
813            });
814
815        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
816        assert_eq!(groups.len(), 1);
817        assert_eq!(groups[0].results.test_only_dependencies.len(), 1);
818    }
819
820    #[test]
821    fn group_unused_enum_members() {
822        let mut results = AnalysisResults::default();
823        results
824            .unused_enum_members
825            .push(fallow_core::results::UnusedMember {
826                path: PathBuf::from("/root/src/types.ts"),
827                parent_name: "Status".to_string(),
828                member_name: "Deprecated".to_string(),
829                kind: fallow_core::extract::MemberKind::EnumMember,
830                line: 5,
831                col: 0,
832            });
833
834        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
835        assert_eq!(groups.len(), 1);
836        assert_eq!(groups[0].key, "src");
837        assert_eq!(groups[0].results.unused_enum_members.len(), 1);
838    }
839
840    #[test]
841    fn group_unused_class_members() {
842        let mut results = AnalysisResults::default();
843        results
844            .unused_class_members
845            .push(fallow_core::results::UnusedMember {
846                path: PathBuf::from("/root/lib/service.ts"),
847                parent_name: "UserService".to_string(),
848                member_name: "legacyMethod".to_string(),
849                kind: fallow_core::extract::MemberKind::ClassMethod,
850                line: 42,
851                col: 0,
852            });
853
854        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
855        assert_eq!(groups.len(), 1);
856        assert_eq!(groups[0].key, "lib");
857        assert_eq!(groups[0].results.unused_class_members.len(), 1);
858    }
859
860    #[test]
861    fn group_unresolved_imports() {
862        let mut results = AnalysisResults::default();
863        results
864            .unresolved_imports
865            .push(fallow_core::results::UnresolvedImport {
866                path: PathBuf::from("/root/src/app.ts"),
867                specifier: "./missing".to_string(),
868                line: 1,
869                col: 0,
870                specifier_col: 0,
871            });
872
873        let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
874        assert_eq!(groups.len(), 1);
875        assert_eq!(groups[0].key, "src");
876        assert_eq!(groups[0].results.unresolved_imports.len(), 1);
877    }
878}