Skip to main content

dry4rust/
grouper.rs

1// Copyright (c) 2026 Matjaz Domen Pecan
2// Copyright 2026 Umberto Gotti <umberto.gotti@umbertogotti.dev>
3// Licensed under the MIT License
4// SPDX-License-Identifier: MIT
5
6use std::collections::HashMap;
7
8use crate::code_unit::CodeUnit;
9use crate::fingerprint::Fingerprint;
10use crate::near_duplicate::near_duplicate_finder::NearDuplicateFinder;
11
12/// A group of duplicate code units.
13#[derive(Debug, Clone)]
14pub struct DuplicateGroup {
15    /// Shared fingerprint for exact duplicates, or composite fingerprint
16    /// (derived from sorted member fingerprints) for near-duplicate groups.
17    pub fingerprint: Fingerprint,
18    /// The code units in this group.
19    pub members: Vec<CodeUnit>,
20    /// Similarity score (1.0 for exact duplicates).
21    pub similarity: f64,
22}
23
24/// Statistics about duplication in the analyzed codebase.
25#[derive(Debug, Clone, serde::Serialize)]
26pub struct DuplicationStats {
27    pub total_code_units: usize,
28    pub total_lines: usize,
29    pub exact_duplicate_groups: usize,
30    pub exact_duplicate_units: usize,
31    pub near_duplicate_groups: usize,
32    pub near_duplicate_units: usize,
33    pub exact_duplicate_lines: usize,
34    pub near_duplicate_lines: usize,
35    // Sub-function stats
36    pub sub_exact_groups: usize,
37    pub sub_exact_units: usize,
38    pub sub_near_groups: usize,
39    pub sub_near_units: usize,
40    /// Groups a baseline accounted for, or `None` when no baseline was in
41    /// effect. Zero and "there was nothing to suppress with" are different
42    /// facts, and a reader of the summary needs to tell them apart.
43    pub baseline_suppressed: Option<usize>,
44}
45
46impl DuplicationStats {
47    /// The same statistics, with what the baseline took out of them.
48    #[must_use]
49    pub const fn with_baseline_suppressed(self, suppressed: Option<usize>) -> Self {
50        Self {
51            baseline_suppressed: suppressed,
52            ..self
53        }
54    }
55
56    fn percent_of_total(&self, lines: usize) -> f64 {
57        if self.total_lines == 0 {
58            0.0
59        } else {
60            lines as f64 / self.total_lines as f64 * 100.0
61        }
62    }
63
64    /// Percentage of total lines that are exact duplicates.
65    #[must_use]
66    pub fn exact_duplicate_percent(&self) -> f64 {
67        self.percent_of_total(self.exact_duplicate_lines)
68    }
69
70    /// Percentage of total lines that are near duplicates.
71    #[must_use]
72    pub fn near_duplicate_percent(&self) -> f64 {
73        self.percent_of_total(self.near_duplicate_lines)
74    }
75}
76
77/// Group code units by exact fingerprint match.
78#[must_use]
79pub fn group_exact_duplicates(units: &[CodeUnit]) -> Vec<DuplicateGroup> {
80    let mut groups: HashMap<Fingerprint, Vec<CodeUnit>> = HashMap::new();
81
82    for unit in units {
83        groups
84            .entry(unit.fingerprint)
85            .or_default()
86            .push(unit.clone());
87    }
88
89    let mut result: Vec<DuplicateGroup> = groups
90        .into_iter()
91        .filter(|(_, members)| members.len() > 1)
92        .map(|(fp, members)| DuplicateGroup {
93            fingerprint: fp,
94            members,
95            similarity: 1.0,
96        })
97        .collect();
98
99    // Sort by group size (largest first), then by fingerprint for stability
100    result.sort_by(|a, b| {
101        b.members
102            .len()
103            .cmp(&a.members.len())
104            .then_with(|| a.fingerprint.cmp(&b.fingerprint))
105    });
106
107    result
108}
109
110/// Find near-duplicate groups above the similarity threshold.
111/// Pre-filters by `CodeUnitKind` and approximate size to reduce pairwise comparisons.
112#[must_use]
113pub fn find_near_duplicates(
114    units: &[CodeUnit],
115    threshold: f64,
116    exact_fingerprints: &[Fingerprint],
117) -> Vec<DuplicateGroup> {
118    NearDuplicateFinder::new(threshold).find(units, exact_fingerprints)
119}
120
121/// Compute the total number of source lines in a duplicate group.
122fn group_line_count(group: &DuplicateGroup) -> usize {
123    group
124        .members
125        .iter()
126        .map(|m| m.line_end.saturating_sub(m.line_start) + 1)
127        .sum()
128}
129
130/// Compute duplication statistics.
131pub fn compute_stats(
132    units: &[CodeUnit],
133    exact_groups: &[DuplicateGroup],
134    near_groups: &[DuplicateGroup],
135) -> DuplicationStats {
136    let total_lines: usize = units
137        .iter()
138        .map(|u| u.line_end.saturating_sub(u.line_start) + 1)
139        .sum();
140
141    DuplicationStats {
142        total_code_units: units.len(),
143        total_lines,
144        exact_duplicate_groups: exact_groups.len(),
145        exact_duplicate_units: exact_groups.iter().map(|g| g.members.len()).sum(),
146        near_duplicate_groups: near_groups.len(),
147        near_duplicate_units: near_groups.iter().map(|g| g.members.len()).sum(),
148        exact_duplicate_lines: exact_groups.iter().map(group_line_count).sum(),
149        near_duplicate_lines: near_groups.iter().map(group_line_count).sum(),
150        sub_exact_groups: 0,
151        sub_exact_units: 0,
152        sub_near_groups: 0,
153        sub_near_units: 0,
154        baseline_suppressed: None,
155    }
156}
157
158/// Compute duplication statistics including sub-function results.
159#[must_use]
160pub fn compute_stats_with_sub(
161    units: &[CodeUnit],
162    exact_groups: &[DuplicateGroup],
163    near_groups: &[DuplicateGroup],
164    sub_exact_groups: &[DuplicateGroup],
165    sub_near_groups: &[DuplicateGroup],
166) -> DuplicationStats {
167    let mut stats = compute_stats(units, exact_groups, near_groups);
168    stats.sub_exact_groups = sub_exact_groups.len();
169    stats.sub_exact_units = sub_exact_groups.iter().map(|g| g.members.len()).sum();
170    stats.sub_near_groups = sub_near_groups.len();
171    stats.sub_near_units = sub_near_groups.iter().map(|g| g.members.len()).sum();
172    stats
173}