1use std::cmp::{Ordering, Reverse};
4use std::collections::hash_map::Entry;
5use std::path::{Component, Path, PathBuf};
6
7use rustc_hash::FxHashMap;
8use serde::{Deserialize, Serialize};
9
10use crate::serde_path;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15pub struct CloneInstance {
16 #[serde(serialize_with = "serde_path::serialize")]
18 pub file: PathBuf,
19 pub start_line: usize,
21 pub end_line: usize,
23 pub start_col: usize,
25 pub end_col: usize,
27 #[serde(default, skip_serializing_if = "String::is_empty")]
34 pub fragment: String,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub struct CloneGroup {
42 pub instances: Vec<CloneInstance>,
44 pub token_count: usize,
46 pub line_count: usize,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 #[cfg_attr(feature = "schema", schemars(with = "f64"))]
52 pub similarity: Option<f64>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq)]
60#[non_exhaustive]
61pub enum CloneGroupKind {
62 Exact,
64 Near {
66 similarity: f64,
68 },
69}
70
71impl CloneGroup {
72 #[must_use]
74 pub fn kind(&self) -> CloneGroupKind {
75 self.similarity
76 .map_or(CloneGroupKind::Exact, |similarity| CloneGroupKind::Near {
77 similarity,
78 })
79 }
80
81 #[must_use]
83 pub fn spread(&self) -> usize {
84 clone_group_spread(&self.instances)
85 }
86
87 pub fn strip_fragments(&mut self) {
89 for instance in &mut self.instances {
90 instance.fragment.clear();
91 }
92 }
93}
94
95const SAME_FILE_SPREAD_STEP: usize = 250;
96const MAX_RANKED_SPREAD: usize = 8;
97const SPREAD_RANK_WEIGHTS: [u64; MAX_RANKED_SPREAD + 1] = [
98 1_000_000_000,
99 1_047_319_732,
100 1_075_000_000,
101 1_094_639_463,
102 1_109_873_014,
103 1_122_319_732,
104 1_132_843_281,
105 1_141_959_195,
106 1_150_000_000,
107];
108
109#[must_use]
115pub fn clone_group_spread(instances: &[CloneInstance]) -> usize {
116 clone_location_spread(instances.iter().map(|instance| {
117 (
118 instance.file.as_path(),
119 instance.start_line,
120 instance.end_line,
121 )
122 }))
123}
124
125#[must_use]
130pub fn clone_location_spread<'a>(
131 locations: impl IntoIterator<Item = (&'a Path, usize, usize)>,
132) -> usize {
133 let mut location_count = 0;
134 let mut file_indices: FxHashMap<&'a Path, usize> = FxHashMap::default();
135 let mut by_file: Vec<FileSpread<'a>> = Vec::new();
136
137 for (file, start_line, end_line) in locations {
138 location_count += 1;
139 let next_index = by_file.len();
140 match file_indices.entry(file) {
141 Entry::Occupied(entry) => by_file[*entry.get()].include(start_line, end_line),
142 Entry::Vacant(entry) => {
143 entry.insert(next_index);
144 by_file.push(FileSpread::new(file, start_line, end_line));
145 }
146 }
147 }
148
149 if location_count < 2 {
150 return 0;
151 }
152
153 let same_file_max = by_file
154 .iter()
155 .filter(|file| file.occurrences >= 2)
156 .map(FileSpread::same_file_spread)
157 .max()
158 .unwrap_or(0);
159
160 same_file_max.max(directory_tree_diameter(&by_file))
161}
162
163struct FileSpread<'a> {
164 parent_components: Vec<Component<'a>>,
165 min_end: usize,
166 max_start: usize,
167 occurrences: usize,
168}
169
170impl<'a> FileSpread<'a> {
171 fn new(file: &'a Path, start_line: usize, end_line: usize) -> Self {
172 Self {
173 parent_components: path_parent_components(file),
174 min_end: end_line,
175 max_start: start_line,
176 occurrences: 1,
177 }
178 }
179
180 fn include(&mut self, start_line: usize, end_line: usize) {
181 self.min_end = self.min_end.min(end_line);
182 self.max_start = self.max_start.max(start_line);
183 self.occurrences += 1;
184 }
185
186 fn same_file_spread(&self) -> usize {
187 self.max_start
188 .saturating_sub(self.min_end)
189 .saturating_sub(1)
190 .div_ceil(SAME_FILE_SPREAD_STEP)
191 }
192}
193
194#[must_use]
196pub fn compare_clone_groups(left: &CloneGroup, right: &CloneGroup) -> Ordering {
197 clone_group_rank_key(left).cmp(&clone_group_rank_key(right))
198}
199
200#[cfg(test)]
201fn instance_pair_spread(left: &CloneInstance, right: &CloneInstance) -> usize {
202 if left.file == right.file {
203 return same_file_spread(left, right);
204 }
205 directory_distance(&left.file, &right.file)
206}
207
208#[cfg(test)]
209fn same_file_spread(left: &CloneInstance, right: &CloneInstance) -> usize {
210 let gap = if left.end_line < right.start_line {
211 right
212 .start_line
213 .saturating_sub(left.end_line)
214 .saturating_sub(1)
215 } else if right.end_line < left.start_line {
216 left.start_line
217 .saturating_sub(right.end_line)
218 .saturating_sub(1)
219 } else {
220 0
221 };
222 gap.div_ceil(SAME_FILE_SPREAD_STEP)
223}
224
225fn path_parent_components(path: &Path) -> Vec<Component<'_>> {
226 path.parent()
227 .unwrap_or_else(|| Path::new(""))
228 .components()
229 .collect()
230}
231
232fn directory_tree_diameter(paths: &[FileSpread<'_>]) -> usize {
233 if paths.len() < 2 {
234 return 0;
235 }
236
237 let endpoint = farthest_path(paths, 0).0;
238 farthest_path(paths, endpoint).1
239}
240
241fn farthest_path(paths: &[FileSpread<'_>], origin: usize) -> (usize, usize) {
242 paths
243 .iter()
244 .enumerate()
245 .map(|(index, path)| {
246 (
247 index,
248 component_distance(&paths[origin].parent_components, &path.parent_components),
249 )
250 })
251 .max_by_key(|&(index, distance)| (distance, index))
252 .unwrap_or((origin, 0))
253}
254
255fn component_distance(left: &[Component<'_>], right: &[Component<'_>]) -> usize {
256 let shared = left
257 .iter()
258 .zip(right)
259 .take_while(|(left, right)| left == right)
260 .count();
261 left.len()
262 .saturating_sub(shared)
263 .saturating_add(right.len().saturating_sub(shared))
264}
265
266#[cfg(test)]
267fn directory_distance(left: &Path, right: &Path) -> usize {
268 component_distance(
269 &path_parent_components(left),
270 &path_parent_components(right),
271 )
272}
273
274type CloneGroupRankKey = (
275 Reverse<u128>,
276 Reverse<usize>,
277 Reverse<usize>,
278 Reverse<usize>,
279 Reverse<usize>,
280 bool,
281 PathBuf,
282 usize,
283);
284
285fn clone_group_rank_key(group: &CloneGroup) -> CloneGroupRankKey {
286 let spread = group.spread();
287 let weight = SPREAD_RANK_WEIGHTS[spread.min(MAX_RANKED_SPREAD)];
288 let token_count = u128::try_from(group.token_count).unwrap_or(u128::MAX);
289 let instance_count = u128::try_from(group.instances.len()).unwrap_or(u128::MAX);
290 let score = token_count
291 .saturating_mul(instance_count)
292 .saturating_mul(u128::from(weight));
293 let first = group.instances.iter().min_by(|left, right| {
294 left.file
295 .cmp(&right.file)
296 .then(left.start_line.cmp(&right.start_line))
297 });
298 (
299 Reverse(score),
300 Reverse(spread),
301 Reverse(group.token_count),
302 Reverse(group.instances.len()),
303 Reverse(group.line_count),
304 first.is_none(),
305 first.map_or_else(PathBuf::new, |instance| instance.file.clone()),
306 first.map_or(0, |instance| instance.start_line),
307 )
308}
309
310fn sort_clone_groups(groups: &mut [CloneGroup]) {
311 groups.sort_by_cached_key(clone_group_rank_key);
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
316#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
317pub enum RefactoringKind {
318 ExtractFunction,
320 ExtractModule,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
326#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
327pub struct RefactoringSuggestion {
328 pub kind: RefactoringKind,
330 pub description: String,
332 pub estimated_savings: usize,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
342#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
343pub struct CloneFamily {
344 #[serde(serialize_with = "serde_path::serialize_vec")]
346 pub files: Vec<PathBuf>,
347 pub groups: Vec<CloneGroup>,
349 pub total_duplicated_lines: usize,
351 pub total_duplicated_tokens: usize,
353 pub suggestions: Vec<RefactoringSuggestion>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
360#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
361pub struct MirroredDirectory {
362 pub dir_a: String,
364 pub dir_b: String,
366 pub shared_files: Vec<String>,
368 pub total_lines: usize,
370}
371
372#[derive(Debug, Clone, Default)]
374pub struct DefaultIgnoreSkipCount {
375 pub pattern: &'static str,
377 pub count: usize,
379}
380
381#[derive(Debug, Clone, Default)]
383pub struct DefaultIgnoreSkips {
384 pub total: usize,
386 pub by_pattern: Vec<DefaultIgnoreSkipCount>,
388}
389
390#[derive(Debug, Clone, Default, Serialize, Deserialize)]
392#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
393pub struct DuplicationReport {
394 pub clone_groups: Vec<CloneGroup>,
397 pub clone_families: Vec<CloneFamily>,
400 #[serde(default, skip_serializing_if = "Vec::is_empty")]
402 pub mirrored_directories: Vec<MirroredDirectory>,
403 pub stats: DuplicationStats,
405}
406
407impl DuplicationReport {
408 pub fn sort(&mut self) {
413 for group in &mut self.clone_groups {
414 group
415 .instances
416 .sort_by(|a, b| a.file.cmp(&b.file).then(a.start_line.cmp(&b.start_line)));
417 }
418 sort_clone_groups(&mut self.clone_groups);
419
420 for family in &mut self.clone_families {
421 for group in &mut family.groups {
422 group
423 .instances
424 .sort_by(|a, b| a.file.cmp(&b.file).then(a.start_line.cmp(&b.start_line)));
425 }
426 sort_clone_groups(&mut family.groups);
427 }
428 self.clone_families.sort_by(|a, b| a.files.cmp(&b.files));
429 }
430
431 #[must_use]
433 pub fn clone_groups_shown(&self) -> usize {
434 self.clone_groups.len()
435 }
436
437 #[must_use]
443 pub fn clone_groups_omitted(&self) -> usize {
444 self.stats
445 .clone_groups
446 .saturating_sub(self.clone_groups.len())
447 }
448
449 #[must_use]
455 pub fn clone_groups_total(&self) -> usize {
456 self.clone_groups_shown() + self.clone_groups_omitted()
457 }
458
459 #[must_use]
461 pub fn clone_families_shown(&self) -> usize {
462 self.clone_families.len()
463 }
464
465 #[must_use]
473 pub fn clone_families_omitted(&self) -> usize {
474 self.stats
475 .clone_families
476 .saturating_sub(self.clone_families.len())
477 }
478
479 #[must_use]
481 pub fn clone_families_total(&self) -> usize {
482 self.clone_families_shown() + self.clone_families_omitted()
483 }
484
485 pub fn strip_fragments(&mut self) {
492 for group in &mut self.clone_groups {
493 group.strip_fragments();
494 }
495 for family in &mut self.clone_families {
496 for group in &mut family.groups {
497 group.strip_fragments();
498 }
499 }
500 }
501}
502
503#[derive(Debug, Clone, Default, Serialize, Deserialize)]
505#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
506pub struct DuplicationStats {
507 pub total_files: usize,
509 pub files_with_clones: usize,
511 pub total_lines: usize,
513 pub duplicated_lines: usize,
515 pub total_tokens: usize,
517 pub duplicated_tokens: usize,
519 pub clone_groups: usize,
523 pub clone_families: usize,
528 pub clone_instances: usize,
531 pub duplication_percentage: f64,
534 #[serde(default, skip_serializing_if = "is_zero_usize")]
538 pub clone_groups_below_min_occurrences: usize,
539 #[serde(default, skip_serializing_if = "is_zero_usize")]
541 pub clone_groups_ignored: usize,
542 #[serde(default, skip_serializing_if = "is_zero_usize")]
544 pub near_candidates_skipped: usize,
545}
546
547#[expect(
548 clippy::trivially_copy_pass_by_ref,
549 reason = "serde skip_serializing_if requires &T signature"
550)]
551const fn is_zero_usize(value: &usize) -> bool {
552 *value == 0
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use proptest::prelude::*;
559
560 fn pairwise_clone_group_spread(instances: &[CloneInstance]) -> usize {
561 let mut spread = 0;
562 for (index, left) in instances.iter().enumerate() {
563 for right in &instances[index + 1..] {
564 spread = spread.max(instance_pair_spread(left, right));
565 }
566 }
567 spread
568 }
569
570 fn instance(file: impl Into<PathBuf>, start_line: usize, end_line: usize) -> CloneInstance {
571 CloneInstance {
572 file: file.into(),
573 start_line,
574 end_line,
575 start_col: 0,
576 end_col: 0,
577 fragment: String::new(),
578 }
579 }
580
581 fn group(instances: Vec<CloneInstance>, token_count: usize, line_count: usize) -> CloneGroup {
582 CloneGroup {
583 instances,
584 token_count,
585 line_count,
586 similarity: None,
587 }
588 }
589
590 #[test]
591 fn spread_counts_non_shared_parent_components() {
592 let clone = group(
593 vec![
594 instance("/repo/packages/a/src/a.ts", 1, 10),
595 instance("/repo/packages/b/src/b.ts", 1, 10),
596 ],
597 100,
598 10,
599 );
600 assert_eq!(clone.spread(), 4);
601 }
602
603 #[test]
604 fn spread_is_zero_for_different_files_in_the_same_directory() {
605 let clone = group(
606 vec![
607 instance("/repo/src/a.ts", 1, 10),
608 instance("/repo/src/b.ts", 1, 10),
609 ],
610 100,
611 10,
612 );
613 assert_eq!(clone.spread(), 0);
614 }
615
616 #[test]
617 fn adjacent_same_file_instances_have_zero_spread() {
618 let clone = group(
619 vec![
620 instance("/repo/src/a.ts", 1, 10),
621 instance("/repo/src/a.ts", 11, 20),
622 ],
623 100,
624 10,
625 );
626 assert_eq!(clone.spread(), 0);
627 }
628
629 #[test]
630 fn same_file_spread_uses_ceiling_rounded_intervening_lines() {
631 let clone = group(
632 vec![
633 instance("/repo/src/a.ts", 1, 10),
634 instance("/repo/src/a.ts", 260, 269),
635 instance("/repo/src/a.ts", 261, 270),
636 instance("/repo/src/a.ts", 262, 271),
637 ],
638 100,
639 10,
640 );
641 assert_eq!(clone_group_spread(&clone.instances[..2]), 1);
642 assert_eq!(clone_group_spread(&clone.instances[..3]), 1);
643 assert_eq!(clone.spread(), 2);
644 }
645
646 #[test]
647 fn overlapping_same_file_instances_have_zero_spread() {
648 let clone = group(
649 vec![
650 instance("/repo/src/a.ts", 1, 20),
651 instance("/repo/src/a.ts", 10, 30),
652 ],
653 100,
654 20,
655 );
656 assert_eq!(clone.spread(), 0);
657 }
658
659 #[test]
660 fn directory_diameter_handles_ties_and_mixed_roots() {
661 let instances = vec![
662 instance("src/a.ts", 1, 10),
663 instance("packages/a/b.ts", 1, 10),
664 instance("packages/c/d.ts", 1, 10),
665 instance("/repo/src/e.ts", 1, 10),
666 ];
667
668 assert_eq!(
669 clone_group_spread(&instances),
670 pairwise_clone_group_spread(&instances)
671 );
672 }
673
674 #[test]
675 fn spread_matches_reference_for_repeated_mixed_and_nested_files() {
676 let instances = vec![
677 instance("src/a.ts", 900, 920),
678 instance("packages/a/src/nested/b.ts", 40, 60),
679 instance("src/a.ts", 1, 20),
680 instance("/repo/apps/web/c.ts", 300, 325),
681 instance("packages/b/test/d.ts", 70, 90),
682 instance("/repo/apps/web/c.ts", 1, 25),
683 ];
684
685 assert_eq!(
686 clone_group_spread(&instances),
687 pairwise_clone_group_spread(&instances)
688 );
689 }
690
691 #[cfg(unix)]
692 #[test]
693 fn spread_matches_reference_for_non_utf8_paths() {
694 use std::ffi::OsString;
695 use std::os::unix::ffi::OsStringExt;
696
697 let first = PathBuf::from(OsString::from_vec(b"/repo/packages/\x80/src/a.ts".to_vec()));
698 let second = PathBuf::from(OsString::from_vec(b"/repo/packages/\x81/src/b.ts".to_vec()));
699 let instances = vec![
700 instance(first.clone(), 1, 20),
701 instance(second, 100, 120),
702 instance(first, 800, 820),
703 ];
704
705 assert_eq!(
706 clone_group_spread(&instances),
707 pairwise_clone_group_spread(&instances)
708 );
709 }
710
711 #[cfg(windows)]
712 #[test]
713 fn directory_diameter_handles_windows_prefixes() {
714 let instances = vec![
715 instance(r"C:\repo\src\a.ts", 1, 10),
716 instance(r"C:\repo\packages\b.ts", 1, 10),
717 instance(r"D:\other\c.ts", 1, 10),
718 ];
719
720 assert_eq!(
721 clone_group_spread(&instances),
722 pairwise_clone_group_spread(&instances)
723 );
724 }
725
726 #[test]
727 fn clone_group_kind_does_not_change_serialized_contract() {
728 let mut clone = group(vec![instance("src/a.ts", 1, 10)], 20, 10);
729 assert_eq!(clone.kind(), CloneGroupKind::Exact);
730 assert!(serde_json::to_value(&clone).unwrap()["similarity"].is_null());
731
732 clone.similarity = Some(0.85);
733 assert_eq!(clone.kind(), CloneGroupKind::Near { similarity: 0.85 });
734 assert_eq!(serde_json::to_value(&clone).unwrap()["similarity"], 0.85);
735 }
736
737 mod proptests {
738 use super::*;
739
740 proptest! {
741 #[test]
742 fn optimized_spread_matches_pairwise_reference(
743 entries in prop::collection::vec((0_u8..8, 1_usize..4_000, 1_usize..500), 0..80)
744 ) {
745 let paths = [
746 "src/a.ts",
747 "src/b.ts",
748 "packages/a/src/c.ts",
749 "packages/b/src/d.ts",
750 "packages/b/test/e.ts",
751 "/repo/apps/web/f.ts",
752 "/repo/crates/core/g.ts",
753 "h.ts",
754 ];
755 let instances = entries
756 .into_iter()
757 .map(|(path, start, len)| instance(paths[usize::from(path)], start, start + len))
758 .collect::<Vec<_>>();
759
760 prop_assert_eq!(
761 clone_group_spread(&instances),
762 pairwise_clone_group_spread(&instances)
763 );
764 }
765 }
766 }
767
768 #[test]
769 fn ranking_uses_spread_without_overriding_a_larger_base_score() {
770 let distant = group(
771 vec![
772 instance("/repo/a/b/c/d/e/a.ts", 1, 10),
773 instance("/repo/f/g/h/i/j/b.ts", 1, 10),
774 ],
775 100,
776 10,
777 );
778 let slightly_larger_local = group(
779 vec![
780 instance("/repo/src/a.ts", 1, 10),
781 instance("/repo/src/b.ts", 1, 10),
782 ],
783 116,
784 10,
785 );
786 assert_eq!(distant.spread(), 10);
787 assert_eq!(
788 compare_clone_groups(&distant, &slightly_larger_local),
789 Ordering::Greater
790 );
791
792 let slightly_smaller_local = group(
793 vec![
794 instance("/repo/src/c.ts", 1, 10),
795 instance("/repo/src/d.ts", 1, 10),
796 ],
797 114,
798 10,
799 );
800 assert_eq!(
801 compare_clone_groups(&distant, &slightly_smaller_local),
802 Ordering::Less
803 );
804 }
805
806 #[test]
807 fn report_sort_uses_canonical_location_as_final_tiebreaker() {
808 let later = group(
809 vec![
810 instance("/repo/src/z.ts", 1, 10),
811 instance("/repo/src/y.ts", 1, 10),
812 ],
813 100,
814 10,
815 );
816 let earlier = group(
817 vec![
818 instance("/repo/src/a.ts", 20, 29),
819 instance("/repo/src/b.ts", 20, 29),
820 ],
821 100,
822 10,
823 );
824 let mut report = DuplicationReport {
825 clone_groups: vec![later, earlier],
826 ..DuplicationReport::default()
827 };
828 report.sort();
829 assert_eq!(
830 report.clone_groups[0].instances[0].file,
831 Path::new("/repo/src/a.ts")
832 );
833 }
834
835 #[test]
836 fn exact_clone_similarity_is_omitted() {
837 let clone = group(Vec::new(), 100, 10);
838 let value = serde_json::to_value(clone).unwrap();
839 assert!(value.get("similarity").is_none());
840 }
841
842 #[test]
843 fn near_clone_similarity_is_serialized() {
844 let mut clone = group(Vec::new(), 100, 10);
845 clone.similarity = Some(0.85);
846 let value = serde_json::to_value(clone).unwrap();
847 assert_eq!(value["similarity"], 0.85);
848 }
849
850 #[test]
851 fn optional_duplication_stats_are_omitted_at_zero() {
852 let empty = serde_json::to_value(DuplicationStats::default()).unwrap();
853 assert!(empty.get("clone_groups_ignored").is_none());
854 assert!(empty.get("near_candidates_skipped").is_none());
855
856 let populated = serde_json::to_value(DuplicationStats {
857 clone_groups_ignored: 2,
858 near_candidates_skipped: 3,
859 ..DuplicationStats::default()
860 })
861 .unwrap();
862 assert_eq!(populated["clone_groups_ignored"], 2);
863 assert_eq!(populated["near_candidates_skipped"], 3);
864 }
865}