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        for item in &results.dev_dependencies_in_production {
163            self.entry_for_path(&item.dep.path)
164                .dev_dependencies_in_production
165                .push(item.clone());
166        }
167
168        for item in &results.unlisted_dependencies {
169            let key = item.dep.imported_from.first().map_or_else(
170                || UNOWNED_GROUP_LABEL.to_string(),
171                |site| (self.key_for)(&site.path),
172            );
173            self.entry_for_key(key)
174                .unlisted_dependencies
175                .push(item.clone());
176        }
177        for item in &results.duplicate_exports {
178            let key = item.export.locations.first().map_or_else(
179                || UNOWNED_GROUP_LABEL.to_string(),
180                |loc| (self.key_for)(&loc.path),
181            );
182            self.entry_for_key(key).duplicate_exports.push(item.clone());
183        }
184    }
185
186    fn group_relationship_issues(&mut self, results: &AnalysisResults) {
187        self.group_structure_issues(results);
188        self.group_framework_boundary_issues(results);
189        self.group_component_contract_issues(results);
190    }
191
192    fn group_structure_issues(&mut self, results: &AnalysisResults) {
193        for item in &results.circular_dependencies {
194            let key = item
195                .cycle
196                .files
197                .first()
198                .map_or_else(|| UNOWNED_GROUP_LABEL.to_string(), |f| (self.key_for)(f));
199            self.entry_for_key(key)
200                .circular_dependencies
201                .push(item.clone());
202        }
203        for item in &results.boundary_violations {
204            self.entry_for_path(&item.violation.from_path)
205                .boundary_violations
206                .push(item.clone());
207        }
208        for item in &results.boundary_coverage_violations {
209            self.entry_for_path(&item.violation.path)
210                .boundary_coverage_violations
211                .push(item.clone());
212        }
213        for item in &results.boundary_call_violations {
214            self.entry_for_path(&item.violation.path)
215                .boundary_call_violations
216                .push(item.clone());
217        }
218        for item in &results.policy_violations {
219            self.entry_for_path(&item.violation.path)
220                .policy_violations
221                .push(item.clone());
222        }
223    }
224
225    fn group_framework_boundary_issues(&mut self, results: &AnalysisResults) {
226        for item in &results.invalid_client_exports {
227            self.entry_for_path(&item.export.path)
228                .invalid_client_exports
229                .push(item.clone());
230        }
231        for item in &results.mixed_client_server_barrels {
232            self.entry_for_path(&item.barrel.path)
233                .mixed_client_server_barrels
234                .push(item.clone());
235        }
236        for item in &results.misplaced_directives {
237            self.entry_for_path(&item.directive_site.path)
238                .misplaced_directives
239                .push(item.clone());
240        }
241        for item in &results.unprovided_injects {
242            self.entry_for_path(&item.inject.path)
243                .unprovided_injects
244                .push(item.clone());
245        }
246        for item in &results.unrendered_components {
247            self.entry_for_path(&item.component.path)
248                .unrendered_components
249                .push(item.clone());
250        }
251    }
252
253    fn group_component_contract_issues(&mut self, results: &AnalysisResults) {
254        for item in &results.unused_component_props {
255            self.entry_for_path(&item.prop.path)
256                .unused_component_props
257                .push(item.clone());
258        }
259        for item in &results.unused_component_emits {
260            self.entry_for_path(&item.emit.path)
261                .unused_component_emits
262                .push(item.clone());
263        }
264        for item in &results.unused_component_inputs {
265            self.entry_for_path(&item.input.path)
266                .unused_component_inputs
267                .push(item.clone());
268        }
269        for item in &results.unused_component_outputs {
270            self.entry_for_path(&item.output.path)
271                .unused_component_outputs
272                .push(item.clone());
273        }
274        for item in &results.unused_server_actions {
275            self.entry_for_path(&item.action.path)
276                .unused_server_actions
277                .push(item.clone());
278        }
279        for item in &results.unused_load_data_keys {
280            self.entry_for_path(&item.key.path)
281                .unused_load_data_keys
282                .push(item.clone());
283        }
284        for item in &results.stale_suppressions {
285            self.entry_for_path(&item.path)
286                .stale_suppressions
287                .push(item.clone());
288        }
289    }
290
291    fn group_workspace_config_issues(&mut self, results: &AnalysisResults) {
292        for item in &results.unused_catalog_entries {
293            self.entry_for_path(&item.entry.path)
294                .unused_catalog_entries
295                .push(item.clone());
296        }
297        for item in &results.empty_catalog_groups {
298            self.entry_for_path(&item.group.path)
299                .empty_catalog_groups
300                .push(item.clone());
301        }
302        for item in &results.unresolved_catalog_references {
303            self.entry_for_path(&item.reference.path)
304                .unresolved_catalog_references
305                .push(item.clone());
306        }
307        for item in &results.unused_dependency_overrides {
308            self.entry_for_path(&item.entry.path)
309                .unused_dependency_overrides
310                .push(item.clone());
311        }
312        for item in &results.misconfigured_dependency_overrides {
313            self.entry_for_path(&item.entry.path)
314                .misconfigured_dependency_overrides
315                .push(item.clone());
316        }
317    }
318}
319
320fn finalize_groups(
321    groups: FxHashMap<String, AnalysisResults>,
322    mut group_owners: FxHashMap<String, Vec<String>>,
323    include_owners: bool,
324) -> Vec<ResultGroup> {
325    let mut sorted: Vec<_> = groups
326        .into_iter()
327        .map(|(key, results)| {
328            let owners = if include_owners {
329                Some(group_owners.remove(&key).unwrap_or_default())
330            } else {
331                None
332            };
333            ResultGroup {
334                key,
335                owners,
336                results,
337            }
338        })
339        .collect();
340    sorted.sort_by(|a, b| {
341        let a_unowned = a.key == UNOWNED_GROUP_LABEL;
342        let b_unowned = b.key == UNOWNED_GROUP_LABEL;
343        match (a_unowned, b_unowned) {
344            (true, false) => std::cmp::Ordering::Greater,
345            (false, true) => std::cmp::Ordering::Less,
346            _ => b
347                .results
348                .total_issues()
349                .cmp(&a.results.total_issues())
350                .then_with(|| a.key.cmp(&b.key)),
351        }
352    });
353    sorted
354}
355
356/// Return the majority owner for a clone group using caller-provided path attribution.
357#[must_use]
358pub fn largest_clone_group_owner_with<F>(group: &CloneGroup, mut key_for_path: F) -> String
359where
360    F: FnMut(&Path) -> String,
361{
362    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
363    for instance in &group.instances {
364        let key = key_for_path(&instance.file);
365        *counts.entry(key).or_insert(0) += 1;
366    }
367    if counts.is_empty() {
368        return UNOWNED_GROUP_LABEL.to_string();
369    }
370    let mut best_key: Option<String> = None;
371    let mut best_count: u32 = 0;
372    for (key, count) in counts {
373        if best_key.is_none() || count > best_count {
374            best_count = count;
375            best_key = Some(key);
376        }
377    }
378    best_key.unwrap_or_else(|| UNOWNED_GROUP_LABEL.to_string())
379}
380
381/// Build grouped duplication output using caller-provided path attribution.
382#[must_use]
383pub fn build_duplication_grouping_with<F>(
384    report: &DuplicationReport,
385    mode: &'static str,
386    mut key_for_path: F,
387) -> DuplicationGrouping
388where
389    F: FnMut(&Path) -> String,
390{
391    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
392    let buckets = build_attributed_clone_buckets(report, &mut key_for_path);
393    let mut groups: Vec<DuplicationGroup> = buckets
394        .into_iter()
395        .map(|(key, groups)| duplication_group(key, groups, report, &fingerprints))
396        .collect();
397    sort_duplication_groups(&mut groups);
398
399    DuplicationGrouping { mode, groups }
400}
401
402fn build_attributed_clone_buckets<F>(
403    report: &DuplicationReport,
404    key_for_path: &mut F,
405) -> BTreeMap<String, Vec<AttributedCloneGroup>>
406where
407    F: FnMut(&Path) -> String,
408{
409    let mut buckets: BTreeMap<String, Vec<AttributedCloneGroup>> = BTreeMap::new();
410    for group in &report.clone_groups {
411        let attributed = attributed_clone_group(group, key_for_path);
412        buckets
413            .entry(attributed.primary_owner.clone())
414            .or_default()
415            .push(attributed);
416    }
417    buckets
418}
419
420fn attributed_clone_group<F>(group: &CloneGroup, key_for_path: &mut F) -> AttributedCloneGroup
421where
422    F: FnMut(&Path) -> String,
423{
424    let primary_owner = largest_clone_group_owner_with(group, &mut *key_for_path);
425    let instances = group
426        .instances
427        .iter()
428        .map(|instance| AttributedInstance {
429            owner: key_for_path(&instance.file),
430            instance: instance.clone(),
431        })
432        .collect();
433    AttributedCloneGroup {
434        primary_owner,
435        token_count: group.token_count,
436        line_count: group.line_count,
437        instances,
438    }
439}
440
441fn duplication_group(
442    key: String,
443    attributed_groups: Vec<AttributedCloneGroup>,
444    report: &DuplicationReport,
445    fingerprints: &CloneFingerprintSet,
446) -> DuplicationGroup {
447    let mut subset = duplication_subset_report(&attributed_groups, report);
448    subset.stats = fallow_engine::duplicates::recompute_stats(&subset);
449    let clone_families = clone_families_for_bucket(&attributed_groups, report, fingerprints);
450    let clone_groups = attributed_groups
451        .into_iter()
452        .map(|group| {
453            let fingerprint = group.fingerprint(fingerprints);
454            AttributedCloneGroupFinding::with_fingerprint(group, fingerprint)
455        })
456        .collect();
457
458    DuplicationGroup {
459        key,
460        stats: subset.stats,
461        clone_groups,
462        clone_families,
463    }
464}
465
466fn duplication_subset_report(
467    attributed_groups: &[AttributedCloneGroup],
468    report: &DuplicationReport,
469) -> DuplicationReport {
470    DuplicationReport {
471        clone_groups: attributed_groups
472            .iter()
473            .map(|group| CloneGroup {
474                instances: group
475                    .instances
476                    .iter()
477                    .map(|instance| instance.instance.clone())
478                    .collect(),
479                token_count: group.token_count,
480                line_count: group.line_count,
481            })
482            .collect(),
483        clone_families: Vec::new(),
484        mirrored_directories: Vec::new(),
485        stats: DuplicationStats {
486            total_files: report.stats.total_files,
487            files_with_clones: 0,
488            total_lines: report.stats.total_lines,
489            duplicated_lines: 0,
490            total_tokens: report.stats.total_tokens,
491            duplicated_tokens: 0,
492            clone_groups: 0,
493            clone_instances: 0,
494            duplication_percentage: 0.0,
495            clone_groups_below_min_occurrences: report.stats.clone_groups_below_min_occurrences,
496        },
497    }
498}
499
500fn clone_families_for_bucket(
501    attributed_groups: &[AttributedCloneGroup],
502    report: &DuplicationReport,
503    fingerprints: &CloneFingerprintSet,
504) -> Vec<CloneFamilyFinding> {
505    let bucket_files: FxHashSet<&Path> = attributed_groups
506        .iter()
507        .flat_map(|group| group.instances.iter().map(|i| i.instance.file.as_path()))
508        .collect();
509
510    report
511        .clone_families
512        .iter()
513        .filter(|family| {
514            family
515                .files
516                .iter()
517                .any(|path| bucket_files.contains(path.as_path()))
518        })
519        .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), fingerprints))
520        .collect()
521}
522
523fn sort_duplication_groups(groups: &mut [DuplicationGroup]) {
524    groups.sort_by(|a, b| {
525        let a_unowned = a.key == UNOWNED_GROUP_LABEL;
526        let b_unowned = b.key == UNOWNED_GROUP_LABEL;
527        match (a_unowned, b_unowned) {
528            (true, false) => std::cmp::Ordering::Greater,
529            (false, true) => std::cmp::Ordering::Less,
530            _ => b
531                .clone_groups
532                .len()
533                .cmp(&a.clone_groups.len())
534                .then_with(|| a.key.cmp(&b.key)),
535        }
536    });
537}