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