1use 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
14pub enum OwnershipResolver {
19 Owner(CodeOwners),
21 Directory,
23}
24
25impl OwnershipResolver {
26 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 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 pub fn mode_label(&self) -> &'static str {
53 match self {
54 Self::Owner(_) => "owner",
55 Self::Directory => "directory",
56 }
57 }
58}
59
60pub struct ResultGroup {
62 pub key: String,
64 pub results: AnalysisResults,
66}
67
68pub 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 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 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 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 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
226pub 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 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 #[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 #[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 #[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 #[test]
333 fn sort_order_descending_by_total_issues() {
334 let mut results = AnalysisResults::default();
335 results.unused_files.push(unused_file("/root/lib/a.ts"));
337 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 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 assert_eq!(groups[0].key, "alpha");
369 assert_eq!(groups[1].key, "beta");
370 }
371
372 #[test]
375 fn unowned_sorts_last_regardless_of_count() {
376 let mut results = AnalysisResults::default();
377 results.unused_files.push(unused_file("/root/src/a.ts"));
379 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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}