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