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            similarity: None,
74        }
75    }
76
77    fn report(groups: Vec<CloneGroup>) -> DuplicationReport {
78        DuplicationReport {
79            clone_groups: groups,
80            clone_families: vec![],
81            mirrored_directories: vec![],
82            stats: DuplicationStats {
83                total_files: 10,
84                total_lines: 1000,
85                ..Default::default()
86            },
87        }
88    }
89
90    #[test]
91    fn largest_owner_majority_wins() {
92        let r = group(vec![
93            instance("/root/src/a.ts", 1, 10),
94            instance("/root/src/b.ts", 1, 10),
95            instance("/root/lib/c.ts", 1, 10),
96        ]);
97        let key = largest_owner(&r, Path::new("/root"), &OwnershipResolver::Directory);
98        assert_eq!(key, "src", "src has 2 instances vs lib's 1");
99    }
100
101    #[test]
102    fn largest_owner_alphabetical_tiebreak() {
103        let r = group(vec![
104            instance("/root/src/a.ts", 1, 10),
105            instance("/root/lib/b.ts", 1, 10),
106        ]);
107        let key = largest_owner(&r, Path::new("/root"), &OwnershipResolver::Directory);
108        assert_eq!(key, "lib");
109    }
110
111    #[test]
112    fn largest_owner_three_way_tie_alphabetical() {
113        let r = group(vec![
114            instance("/root/zeta/a.ts", 1, 10),
115            instance("/root/alpha/b.ts", 1, 10),
116            instance("/root/beta/c.ts", 1, 10),
117        ]);
118        let key = largest_owner(&r, Path::new("/root"), &OwnershipResolver::Directory);
119        assert_eq!(key, "alpha");
120    }
121
122    #[test]
123    fn build_grouping_partitions_clone_groups() {
124        let g1 = group(vec![
125            instance("/root/src/a.ts", 1, 10),
126            instance("/root/src/b.ts", 1, 10),
127        ]);
128        let g2 = group(vec![
129            instance("/root/lib/x.ts", 1, 10),
130            instance("/root/lib/y.ts", 1, 10),
131        ]);
132        let r = report(vec![g1, g2]);
133        let grouping =
134            build_duplication_grouping(&r, Path::new("/root"), &OwnershipResolver::Directory);
135        assert_eq!(grouping.groups.len(), 2);
136        let lib = grouping.groups.iter().find(|g| g.key == "lib").unwrap();
137        let src = grouping.groups.iter().find(|g| g.key == "src").unwrap();
138        assert_eq!(lib.clone_groups.len(), 1);
139        assert_eq!(src.clone_groups.len(), 1);
140    }
141
142    #[test]
143    fn build_grouping_unowned_pinned_last() {
144        let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
145        let resolver = OwnershipResolver::Owner(co);
146        let g_src = group(vec![
147            instance("/root/src/a.ts", 1, 10),
148            instance("/root/src/b.ts", 1, 10),
149        ]);
150        let g_docs = group(vec![
151            instance("/root/docs/a.md", 1, 10),
152            instance("/root/docs/b.md", 1, 10),
153        ]);
154        let r = report(vec![g_src, g_docs]);
155        let grouping = build_duplication_grouping(&r, Path::new("/root"), &resolver);
156        assert_eq!(grouping.groups.len(), 2);
157        assert_eq!(grouping.groups.last().unwrap().key, UNOWNED_LABEL);
158    }
159
160    #[test]
161    fn build_grouping_per_instance_owner_inline() {
162        let g = group(vec![
163            instance("/root/src/a.ts", 1, 10),
164            instance("/root/src/b.ts", 1, 10),
165            instance("/root/lib/c.ts", 1, 10),
166        ]);
167        let r = report(vec![g]);
168        let grouping =
169            build_duplication_grouping(&r, Path::new("/root"), &OwnershipResolver::Directory);
170        assert_eq!(grouping.groups.len(), 1);
171        let bucket = &grouping.groups[0];
172        assert_eq!(bucket.key, "src");
173        assert_eq!(bucket.clone_groups.len(), 1);
174        let finding = &bucket.clone_groups[0];
175        let cg = &finding.group;
176        assert_eq!(cg.primary_owner, "src");
177        assert_eq!(cg.instances.len(), 3);
178        let owners: Vec<&str> = cg.instances.iter().map(|i| i.owner.as_str()).collect();
179        assert!(owners.contains(&"src"));
180        assert!(owners.contains(&"lib"));
181        assert_eq!(finding.actions.len(), 2);
182    }
183
184    #[test]
185    fn empty_report_produces_empty_grouping() {
186        let r = DuplicationReport::default();
187        let grouping =
188            build_duplication_grouping(&r, Path::new("/root"), &OwnershipResolver::Directory);
189        assert!(grouping.groups.is_empty());
190    }
191}