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, UNOWNED_LABEL};
14
15pub enum OwnershipResolver {
20 Owner(CodeOwners),
22 Directory,
24 Package(PackageResolver),
26}
27
28pub struct PackageResolver {
33 workspaces: Vec<(PathBuf, String)>,
35}
36
37const ROOT_PACKAGE_LABEL: &str = "(root)";
38
39impl PackageResolver {
40 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 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 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 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 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
103pub struct ResultGroup {
105 pub key: String,
107 pub results: AnalysisResults,
109}
110
111pub 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 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 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 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 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
269pub 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 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 #[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 #[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 #[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 #[test]
376 fn sort_order_descending_by_total_issues() {
377 let mut results = AnalysisResults::default();
378 results.unused_files.push(unused_file("/root/lib/a.ts"));
380 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 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 assert_eq!(groups[0].key, "alpha");
412 assert_eq!(groups[1].key, "beta");
413 }
414
415 #[test]
418 fn unowned_sorts_last_regardless_of_count() {
419 let mut results = AnalysisResults::default();
420 results.unused_files.push(unused_file("/root/src/a.ts"));
422 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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}