Skip to main content

fallow_api/
grouped_output.rs

1//! Shared grouped-output builders for programmatic and CLI consumers.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5
6use fallow_engine::duplicates::CloneFingerprintSet;
7use fallow_types::duplicates::{CloneGroup, DuplicationReport, DuplicationStats};
8use fallow_types::results::AnalysisResults;
9use rustc_hash::{FxHashMap, FxHashSet};
10
11use crate::{
12    AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneFamilyFinding,
13    DuplicationGroup, DuplicationGrouping,
14};
15
16/// Canonical label for issues that cannot be attributed to a group.
17pub const UNOWNED_GROUP_LABEL: &str = "(unowned)";
18
19/// A single grouped dead-code analysis bucket.
20pub struct ResultGroup {
21    /// Group label such as owner, directory, package, or section.
22    pub key: String,
23    /// Section default owners for section grouping.
24    ///
25    /// `None` for grouping modes without owner metadata. `Some(vec![])` for
26    /// groups that have no section owners.
27    pub owners: Option<Vec<String>>,
28    /// Issues belonging to this group.
29    pub results: AnalysisResults,
30}
31
32/// Partition analysis results into groups using caller-provided path resolvers.
33///
34/// The caller owns all environment-specific context, such as CODEOWNERS,
35/// package discovery, root-relative path normalization, or section metadata.
36#[must_use]
37pub fn group_analysis_results_with<F, O>(
38    results: &AnalysisResults,
39    mut key_for_path: F,
40    mut owners_for_path: O,
41    include_owners: bool,
42) -> Vec<ResultGroup>
43where
44    F: FnMut(&Path) -> String,
45    O: FnMut(&Path) -> Option<Vec<String>>,
46{
47    let mut group_owners: FxHashMap<String, Vec<String>> = FxHashMap::default();
48    let mut builder = GroupingBuilder::new(|path: &Path| {
49        let key = key_for_path(path);
50        if include_owners && !group_owners.contains_key(&key) {
51            let owners = owners_for_path(path).unwrap_or_default();
52            group_owners.insert(key.clone(), owners);
53        }
54        key
55    });
56    builder.group_symbol_issues(results);
57    builder.group_dependency_issues(results);
58    builder.group_relationship_issues(results);
59    builder.group_workspace_config_issues(results);
60
61    finalize_groups(builder.into_groups(), group_owners, include_owners)
62}
63
64struct GroupingBuilder<F> {
65    groups: FxHashMap<String, AnalysisResults>,
66    key_for: F,
67}
68
69impl<F> GroupingBuilder<F>
70where
71    F: FnMut(&Path) -> String,
72{
73    fn new(key_for: F) -> Self {
74        Self {
75            groups: FxHashMap::default(),
76            key_for,
77        }
78    }
79
80    fn entry_for_path(&mut self, path: &Path) -> &mut AnalysisResults {
81        let key = (self.key_for)(path);
82        self.groups.entry(key).or_default()
83    }
84
85    fn entry_for_key(&mut self, key: String) -> &mut AnalysisResults {
86        self.groups.entry(key).or_default()
87    }
88
89    fn into_groups(self) -> FxHashMap<String, AnalysisResults> {
90        self.groups
91    }
92
93    fn group_symbol_issues(&mut self, results: &AnalysisResults) {
94        for item in &results.unused_files {
95            self.entry_for_path(&item.file.path)
96                .unused_files
97                .push(item.clone());
98        }
99        for item in &results.unused_exports {
100            self.entry_for_path(&item.export.path)
101                .unused_exports
102                .push(item.clone());
103        }
104        for item in &results.unused_types {
105            self.entry_for_path(&item.export.path)
106                .unused_types
107                .push(item.clone());
108        }
109        for item in &results.private_type_leaks {
110            self.entry_for_path(&item.leak.path)
111                .private_type_leaks
112                .push(item.clone());
113        }
114        for item in &results.unused_enum_members {
115            self.entry_for_path(&item.member.path)
116                .unused_enum_members
117                .push(item.clone());
118        }
119        for item in &results.unused_class_members {
120            self.entry_for_path(&item.member.path)
121                .unused_class_members
122                .push(item.clone());
123        }
124        for item in &results.unused_store_members {
125            self.entry_for_path(&item.member.path)
126                .unused_store_members
127                .push(item.clone());
128        }
129        for item in &results.unresolved_imports {
130            self.entry_for_path(&item.import.path)
131                .unresolved_imports
132                .push(item.clone());
133        }
134    }
135
136    fn group_dependency_issues(&mut self, results: &AnalysisResults) {
137        for item in &results.unused_dependencies {
138            self.entry_for_path(&item.dep.path)
139                .unused_dependencies
140                .push(item.clone());
141        }
142        for item in &results.unused_dev_dependencies {
143            self.entry_for_path(&item.dep.path)
144                .unused_dev_dependencies
145                .push(item.clone());
146        }
147        for item in &results.unused_optional_dependencies {
148            self.entry_for_path(&item.dep.path)
149                .unused_optional_dependencies
150                .push(item.clone());
151        }
152        for item in &results.type_only_dependencies {
153            self.entry_for_path(&item.dep.path)
154                .type_only_dependencies
155                .push(item.clone());
156        }
157        for item in &results.test_only_dependencies {
158            self.entry_for_path(&item.dep.path)
159                .test_only_dependencies
160                .push(item.clone());
161        }
162
163        for item in &results.unlisted_dependencies {
164            let key = item.dep.imported_from.first().map_or_else(
165                || UNOWNED_GROUP_LABEL.to_string(),
166                |site| (self.key_for)(&site.path),
167            );
168            self.entry_for_key(key)
169                .unlisted_dependencies
170                .push(item.clone());
171        }
172        for item in &results.duplicate_exports {
173            let key = item.export.locations.first().map_or_else(
174                || UNOWNED_GROUP_LABEL.to_string(),
175                |loc| (self.key_for)(&loc.path),
176            );
177            self.entry_for_key(key).duplicate_exports.push(item.clone());
178        }
179    }
180
181    fn group_relationship_issues(&mut self, results: &AnalysisResults) {
182        self.group_structure_issues(results);
183        self.group_framework_boundary_issues(results);
184        self.group_component_contract_issues(results);
185    }
186
187    fn group_structure_issues(&mut self, results: &AnalysisResults) {
188        for item in &results.circular_dependencies {
189            let key = item
190                .cycle
191                .files
192                .first()
193                .map_or_else(|| UNOWNED_GROUP_LABEL.to_string(), |f| (self.key_for)(f));
194            self.entry_for_key(key)
195                .circular_dependencies
196                .push(item.clone());
197        }
198        for item in &results.boundary_violations {
199            self.entry_for_path(&item.violation.from_path)
200                .boundary_violations
201                .push(item.clone());
202        }
203        for item in &results.boundary_coverage_violations {
204            self.entry_for_path(&item.violation.path)
205                .boundary_coverage_violations
206                .push(item.clone());
207        }
208        for item in &results.boundary_call_violations {
209            self.entry_for_path(&item.violation.path)
210                .boundary_call_violations
211                .push(item.clone());
212        }
213        for item in &results.policy_violations {
214            self.entry_for_path(&item.violation.path)
215                .policy_violations
216                .push(item.clone());
217        }
218    }
219
220    fn group_framework_boundary_issues(&mut self, results: &AnalysisResults) {
221        for item in &results.invalid_client_exports {
222            self.entry_for_path(&item.export.path)
223                .invalid_client_exports
224                .push(item.clone());
225        }
226        for item in &results.mixed_client_server_barrels {
227            self.entry_for_path(&item.barrel.path)
228                .mixed_client_server_barrels
229                .push(item.clone());
230        }
231        for item in &results.misplaced_directives {
232            self.entry_for_path(&item.directive_site.path)
233                .misplaced_directives
234                .push(item.clone());
235        }
236        for item in &results.unprovided_injects {
237            self.entry_for_path(&item.inject.path)
238                .unprovided_injects
239                .push(item.clone());
240        }
241        for item in &results.unrendered_components {
242            self.entry_for_path(&item.component.path)
243                .unrendered_components
244                .push(item.clone());
245        }
246    }
247
248    fn group_component_contract_issues(&mut self, results: &AnalysisResults) {
249        for item in &results.unused_component_props {
250            self.entry_for_path(&item.prop.path)
251                .unused_component_props
252                .push(item.clone());
253        }
254        for item in &results.unused_component_emits {
255            self.entry_for_path(&item.emit.path)
256                .unused_component_emits
257                .push(item.clone());
258        }
259        for item in &results.unused_component_inputs {
260            self.entry_for_path(&item.input.path)
261                .unused_component_inputs
262                .push(item.clone());
263        }
264        for item in &results.unused_component_outputs {
265            self.entry_for_path(&item.output.path)
266                .unused_component_outputs
267                .push(item.clone());
268        }
269        for item in &results.unused_server_actions {
270            self.entry_for_path(&item.action.path)
271                .unused_server_actions
272                .push(item.clone());
273        }
274        for item in &results.unused_load_data_keys {
275            self.entry_for_path(&item.key.path)
276                .unused_load_data_keys
277                .push(item.clone());
278        }
279        for item in &results.stale_suppressions {
280            self.entry_for_path(&item.path)
281                .stale_suppressions
282                .push(item.clone());
283        }
284    }
285
286    fn group_workspace_config_issues(&mut self, results: &AnalysisResults) {
287        for item in &results.unused_catalog_entries {
288            self.entry_for_path(&item.entry.path)
289                .unused_catalog_entries
290                .push(item.clone());
291        }
292        for item in &results.empty_catalog_groups {
293            self.entry_for_path(&item.group.path)
294                .empty_catalog_groups
295                .push(item.clone());
296        }
297        for item in &results.unresolved_catalog_references {
298            self.entry_for_path(&item.reference.path)
299                .unresolved_catalog_references
300                .push(item.clone());
301        }
302        for item in &results.unused_dependency_overrides {
303            self.entry_for_path(&item.entry.path)
304                .unused_dependency_overrides
305                .push(item.clone());
306        }
307        for item in &results.misconfigured_dependency_overrides {
308            self.entry_for_path(&item.entry.path)
309                .misconfigured_dependency_overrides
310                .push(item.clone());
311        }
312    }
313}
314
315fn finalize_groups(
316    groups: FxHashMap<String, AnalysisResults>,
317    mut group_owners: FxHashMap<String, Vec<String>>,
318    include_owners: bool,
319) -> Vec<ResultGroup> {
320    let mut sorted: Vec<_> = groups
321        .into_iter()
322        .map(|(key, results)| {
323            let owners = if include_owners {
324                Some(group_owners.remove(&key).unwrap_or_default())
325            } else {
326                None
327            };
328            ResultGroup {
329                key,
330                owners,
331                results,
332            }
333        })
334        .collect();
335    sorted.sort_by(|a, b| {
336        let a_unowned = a.key == UNOWNED_GROUP_LABEL;
337        let b_unowned = b.key == UNOWNED_GROUP_LABEL;
338        match (a_unowned, b_unowned) {
339            (true, false) => std::cmp::Ordering::Greater,
340            (false, true) => std::cmp::Ordering::Less,
341            _ => b
342                .results
343                .total_issues()
344                .cmp(&a.results.total_issues())
345                .then_with(|| a.key.cmp(&b.key)),
346        }
347    });
348    sorted
349}
350
351/// Return the majority owner for a clone group using caller-provided path attribution.
352#[must_use]
353pub fn largest_clone_group_owner_with<F>(group: &CloneGroup, mut key_for_path: F) -> String
354where
355    F: FnMut(&Path) -> String,
356{
357    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
358    for instance in &group.instances {
359        let key = key_for_path(&instance.file);
360        *counts.entry(key).or_insert(0) += 1;
361    }
362    if counts.is_empty() {
363        return UNOWNED_GROUP_LABEL.to_string();
364    }
365    let mut best_key: Option<String> = None;
366    let mut best_count: u32 = 0;
367    for (key, count) in counts {
368        if best_key.is_none() || count > best_count {
369            best_count = count;
370            best_key = Some(key);
371        }
372    }
373    best_key.unwrap_or_else(|| UNOWNED_GROUP_LABEL.to_string())
374}
375
376/// Build grouped duplication output using caller-provided path attribution.
377#[must_use]
378pub fn build_duplication_grouping_with<F>(
379    report: &DuplicationReport,
380    mode: &'static str,
381    mut key_for_path: F,
382) -> DuplicationGrouping
383where
384    F: FnMut(&Path) -> String,
385{
386    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
387    let buckets = build_attributed_clone_buckets(report, &mut key_for_path);
388    let mut groups: Vec<DuplicationGroup> = buckets
389        .into_iter()
390        .map(|(key, groups)| duplication_group(key, groups, report, &fingerprints))
391        .collect();
392    sort_duplication_groups(&mut groups);
393
394    DuplicationGrouping { mode, groups }
395}
396
397fn build_attributed_clone_buckets<F>(
398    report: &DuplicationReport,
399    key_for_path: &mut F,
400) -> BTreeMap<String, Vec<AttributedCloneGroup>>
401where
402    F: FnMut(&Path) -> String,
403{
404    let mut buckets: BTreeMap<String, Vec<AttributedCloneGroup>> = BTreeMap::new();
405    for group in &report.clone_groups {
406        let attributed = attributed_clone_group(group, key_for_path);
407        buckets
408            .entry(attributed.primary_owner.clone())
409            .or_default()
410            .push(attributed);
411    }
412    buckets
413}
414
415fn attributed_clone_group<F>(group: &CloneGroup, key_for_path: &mut F) -> AttributedCloneGroup
416where
417    F: FnMut(&Path) -> String,
418{
419    let primary_owner = largest_clone_group_owner_with(group, &mut *key_for_path);
420    let instances = group
421        .instances
422        .iter()
423        .map(|instance| AttributedInstance {
424            owner: key_for_path(&instance.file),
425            instance: instance.clone(),
426        })
427        .collect();
428    AttributedCloneGroup {
429        primary_owner,
430        token_count: group.token_count,
431        line_count: group.line_count,
432        instances,
433    }
434}
435
436fn duplication_group(
437    key: String,
438    attributed_groups: Vec<AttributedCloneGroup>,
439    report: &DuplicationReport,
440    fingerprints: &CloneFingerprintSet,
441) -> DuplicationGroup {
442    let mut subset = duplication_subset_report(&attributed_groups, report);
443    subset.stats = fallow_engine::duplicates::recompute_stats(&subset);
444    let clone_families = clone_families_for_bucket(&attributed_groups, report, fingerprints);
445    let clone_groups = attributed_groups
446        .into_iter()
447        .map(|group| {
448            let fingerprint = group.fingerprint(fingerprints);
449            AttributedCloneGroupFinding::with_fingerprint(group, fingerprint)
450        })
451        .collect();
452
453    DuplicationGroup {
454        key,
455        stats: subset.stats,
456        clone_groups,
457        clone_families,
458    }
459}
460
461fn duplication_subset_report(
462    attributed_groups: &[AttributedCloneGroup],
463    report: &DuplicationReport,
464) -> DuplicationReport {
465    DuplicationReport {
466        clone_groups: attributed_groups
467            .iter()
468            .map(|group| CloneGroup {
469                instances: group
470                    .instances
471                    .iter()
472                    .map(|instance| instance.instance.clone())
473                    .collect(),
474                token_count: group.token_count,
475                line_count: group.line_count,
476            })
477            .collect(),
478        clone_families: Vec::new(),
479        mirrored_directories: Vec::new(),
480        stats: DuplicationStats {
481            total_files: report.stats.total_files,
482            files_with_clones: 0,
483            total_lines: report.stats.total_lines,
484            duplicated_lines: 0,
485            total_tokens: report.stats.total_tokens,
486            duplicated_tokens: 0,
487            clone_groups: 0,
488            clone_instances: 0,
489            duplication_percentage: 0.0,
490            clone_groups_below_min_occurrences: report.stats.clone_groups_below_min_occurrences,
491        },
492    }
493}
494
495fn clone_families_for_bucket(
496    attributed_groups: &[AttributedCloneGroup],
497    report: &DuplicationReport,
498    fingerprints: &CloneFingerprintSet,
499) -> Vec<CloneFamilyFinding> {
500    let bucket_files: FxHashSet<&Path> = attributed_groups
501        .iter()
502        .flat_map(|group| group.instances.iter().map(|i| i.instance.file.as_path()))
503        .collect();
504
505    report
506        .clone_families
507        .iter()
508        .filter(|family| {
509            family
510                .files
511                .iter()
512                .any(|path| bucket_files.contains(path.as_path()))
513        })
514        .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), fingerprints))
515        .collect()
516}
517
518fn sort_duplication_groups(groups: &mut [DuplicationGroup]) {
519    groups.sort_by(|a, b| {
520        let a_unowned = a.key == UNOWNED_GROUP_LABEL;
521        let b_unowned = b.key == UNOWNED_GROUP_LABEL;
522        match (a_unowned, b_unowned) {
523            (true, false) => std::cmp::Ordering::Greater,
524            (false, true) => std::cmp::Ordering::Less,
525            _ => b
526                .clone_groups
527                .len()
528                .cmp(&a.clone_groups.len())
529                .then_with(|| a.key.cmp(&b.key)),
530        }
531    });
532}