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