Skip to main content

fallow_cli/report/
dupes_grouping.rs

1//! Per-group attribution for `fallow dupes --group-by`.
2//!
3//! For each `CloneGroup`, every instance is attributed to a group key (owner,
4//! directory, package, or section) via the same [`OwnershipResolver`] used by
5//! `check` and `health`. The group itself is then attributed to its
6//! **largest owner**: the key with the most instances in that clone group.
7//! Ties are broken alphabetically (lexicographic ascending).
8//!
9//! This mirrors jscpd's majority-owner attribution and avoids the
10//! positional non-determinism that a "first-instance-wins" rule would
11//! introduce, since `DuplicationReport::sort()` already orders instances
12//! deterministically by file path then line.
13
14use std::path::Path;
15
16use fallow_api::DuplicationGrouping;
17use fallow_types::duplicates::{CloneGroup, DuplicationReport};
18
19use super::grouping::OwnershipResolver;
20use super::relative_path;
21
22/// Pick the largest owner for a clone group: most instances wins, ties broken
23/// alphabetically (smallest key wins).
24///
25/// Iterates a `BTreeMap` so iteration order is alphabetical. The first key
26/// to reach the running maximum wins, which means equal counts resolve to the
27/// alphabetically-smallest key.
28pub fn largest_owner(group: &CloneGroup, root: &Path, resolver: &OwnershipResolver) -> String {
29    fallow_api::largest_clone_group_owner_with(group, |path| {
30        resolver.resolve(relative_path(path, root))
31    })
32}
33
34/// Build the grouped duplication payload from a project-level report.
35///
36/// Aggregation is performed BEFORE any `--top` truncation so per-group stats
37/// reflect the full group, not just the rendered top-N.
38pub fn build_duplication_grouping(
39    report: &DuplicationReport,
40    root: &Path,
41    resolver: &OwnershipResolver,
42) -> DuplicationGrouping {
43    fallow_api::build_duplication_grouping_with(report, resolver.mode_label(), |path| {
44        resolver.resolve(relative_path(path, root))
45    })
46}
47
48#[cfg(test)]
49mod tests {
50    use std::path::PathBuf;
51
52    use fallow_types::duplicates::{CloneInstance, DuplicationStats};
53
54    use super::*;
55    use crate::codeowners::{CodeOwners, UNOWNED_LABEL};
56
57    fn instance(path: &str, start: usize, end: usize) -> CloneInstance {
58        CloneInstance {
59            file: PathBuf::from(path),
60            start_line: start,
61            end_line: end,
62            start_col: 0,
63            end_col: 0,
64            fragment: String::new(),
65        }
66    }
67
68    fn group(instances: Vec<CloneInstance>) -> CloneGroup {
69        CloneGroup {
70            instances,
71            token_count: 50,
72            line_count: 10,
73        }
74    }
75
76    fn report(groups: Vec<CloneGroup>) -> DuplicationReport {
77        DuplicationReport {
78            clone_groups: groups,
79            clone_families: vec![],
80            mirrored_directories: vec![],
81            stats: DuplicationStats {
82                total_files: 10,
83                total_lines: 1000,
84                ..Default::default()
85            },
86        }
87    }
88
89    #[test]
90    fn largest_owner_majority_wins() {
91        let r = group(vec![
92            instance("/root/src/a.ts", 1, 10),
93            instance("/root/src/b.ts", 1, 10),
94            instance("/root/lib/c.ts", 1, 10),
95        ]);
96        let key = largest_owner(&r, Path::new("/root"), &OwnershipResolver::Directory);
97        assert_eq!(key, "src", "src has 2 instances vs lib's 1");
98    }
99
100    #[test]
101    fn largest_owner_alphabetical_tiebreak() {
102        let r = group(vec![
103            instance("/root/src/a.ts", 1, 10),
104            instance("/root/lib/b.ts", 1, 10),
105        ]);
106        let key = largest_owner(&r, Path::new("/root"), &OwnershipResolver::Directory);
107        assert_eq!(key, "lib");
108    }
109
110    #[test]
111    fn largest_owner_three_way_tie_alphabetical() {
112        let r = group(vec![
113            instance("/root/zeta/a.ts", 1, 10),
114            instance("/root/alpha/b.ts", 1, 10),
115            instance("/root/beta/c.ts", 1, 10),
116        ]);
117        let key = largest_owner(&r, Path::new("/root"), &OwnershipResolver::Directory);
118        assert_eq!(key, "alpha");
119    }
120
121    #[test]
122    fn build_grouping_partitions_clone_groups() {
123        let g1 = group(vec![
124            instance("/root/src/a.ts", 1, 10),
125            instance("/root/src/b.ts", 1, 10),
126        ]);
127        let g2 = group(vec![
128            instance("/root/lib/x.ts", 1, 10),
129            instance("/root/lib/y.ts", 1, 10),
130        ]);
131        let r = report(vec![g1, g2]);
132        let grouping =
133            build_duplication_grouping(&r, Path::new("/root"), &OwnershipResolver::Directory);
134        assert_eq!(grouping.groups.len(), 2);
135        let lib = grouping.groups.iter().find(|g| g.key == "lib").unwrap();
136        let src = grouping.groups.iter().find(|g| g.key == "src").unwrap();
137        assert_eq!(lib.clone_groups.len(), 1);
138        assert_eq!(src.clone_groups.len(), 1);
139    }
140
141    #[test]
142    fn build_grouping_unowned_pinned_last() {
143        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
144        let resolver = OwnershipResolver::Owner(co);
145        let g_src = group(vec![
146            instance("/root/src/a.ts", 1, 10),
147            instance("/root/src/b.ts", 1, 10),
148        ]);
149        let g_docs = group(vec![
150            instance("/root/docs/a.md", 1, 10),
151            instance("/root/docs/b.md", 1, 10),
152        ]);
153        let r = report(vec![g_src, g_docs]);
154        let grouping = build_duplication_grouping(&r, Path::new("/root"), &resolver);
155        assert_eq!(grouping.groups.len(), 2);
156        assert_eq!(grouping.groups.last().unwrap().key, UNOWNED_LABEL);
157    }
158
159    #[test]
160    fn build_grouping_per_instance_owner_inline() {
161        let g = group(vec![
162            instance("/root/src/a.ts", 1, 10),
163            instance("/root/src/b.ts", 1, 10),
164            instance("/root/lib/c.ts", 1, 10),
165        ]);
166        let r = report(vec![g]);
167        let grouping =
168            build_duplication_grouping(&r, Path::new("/root"), &OwnershipResolver::Directory);
169        assert_eq!(grouping.groups.len(), 1);
170        let bucket = &grouping.groups[0];
171        assert_eq!(bucket.key, "src");
172        assert_eq!(bucket.clone_groups.len(), 1);
173        let finding = &bucket.clone_groups[0];
174        let cg = &finding.group;
175        assert_eq!(cg.primary_owner, "src");
176        assert_eq!(cg.instances.len(), 3);
177        let owners: Vec<&str> = cg.instances.iter().map(|i| i.owner.as_str()).collect();
178        assert!(owners.contains(&"src"));
179        assert!(owners.contains(&"lib"));
180        assert_eq!(finding.actions.len(), 2);
181    }
182
183    #[test]
184    fn empty_report_produces_empty_grouping() {
185        let r = DuplicationReport::default();
186        let grouping =
187            build_duplication_grouping(&r, Path::new("/root"), &OwnershipResolver::Directory);
188        assert!(grouping.groups.is_empty());
189    }
190}