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 pub fragment: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub struct CloneGroup {
36 pub instances: Vec<CloneInstance>,
38 pub token_count: usize,
40 pub line_count: usize,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 #[cfg_attr(feature = "schema", schemars(with = "f64"))]
46 pub similarity: Option<f64>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq)]
54#[non_exhaustive]
55pub enum CloneGroupKind {
56 Exact,
58 Near {
60 similarity: f64,
62 },
63}
64
65impl CloneGroup {
66 #[must_use]
68 pub fn kind(&self) -> CloneGroupKind {
69 self.similarity
70 .map_or(CloneGroupKind::Exact, |similarity| CloneGroupKind::Near {
71 similarity,
72 })
73 }
74
75 #[must_use]
77 pub fn spread(&self) -> usize {
78 clone_group_spread(&self.instances)
79 }
80}
81
82const SAME_FILE_SPREAD_STEP: usize = 250;
83const MAX_RANKED_SPREAD: usize = 8;
84const SPREAD_RANK_WEIGHTS: [u64; MAX_RANKED_SPREAD + 1] = [
85 1_000_000_000,
86 1_047_319_732,
87 1_075_000_000,
88 1_094_639_463,
89 1_109_873_014,
90 1_122_319_732,
91 1_132_843_281,
92 1_141_959_195,
93 1_150_000_000,
94];
95
96#[must_use]
102pub fn clone_group_spread(instances: &[CloneInstance]) -> usize {
103 clone_location_spread(instances.iter().map(|instance| {
104 (
105 instance.file.as_path(),
106 instance.start_line,
107 instance.end_line,
108 )
109 }))
110}
111
112#[must_use]
117pub fn clone_location_spread<'a>(
118 locations: impl IntoIterator<Item = (&'a Path, usize, usize)>,
119) -> usize {
120 let mut location_count = 0;
121 let mut file_indices: FxHashMap<&'a Path, usize> = FxHashMap::default();
122 let mut by_file: Vec<FileSpread<'a>> = Vec::new();
123
124 for (file, start_line, end_line) in locations {
125 location_count += 1;
126 let next_index = by_file.len();
127 match file_indices.entry(file) {
128 Entry::Occupied(entry) => by_file[*entry.get()].include(start_line, end_line),
129 Entry::Vacant(entry) => {
130 entry.insert(next_index);
131 by_file.push(FileSpread::new(file, start_line, end_line));
132 }
133 }
134 }
135
136 if location_count < 2 {
137 return 0;
138 }
139
140 let same_file_max = by_file
141 .iter()
142 .filter(|file| file.occurrences >= 2)
143 .map(FileSpread::same_file_spread)
144 .max()
145 .unwrap_or(0);
146
147 same_file_max.max(directory_tree_diameter(&by_file))
148}
149
150struct FileSpread<'a> {
151 parent_components: Vec<Component<'a>>,
152 min_end: usize,
153 max_start: usize,
154 occurrences: usize,
155}
156
157impl<'a> FileSpread<'a> {
158 fn new(file: &'a Path, start_line: usize, end_line: usize) -> Self {
159 Self {
160 parent_components: path_parent_components(file),
161 min_end: end_line,
162 max_start: start_line,
163 occurrences: 1,
164 }
165 }
166
167 fn include(&mut self, start_line: usize, end_line: usize) {
168 self.min_end = self.min_end.min(end_line);
169 self.max_start = self.max_start.max(start_line);
170 self.occurrences += 1;
171 }
172
173 fn same_file_spread(&self) -> usize {
174 self.max_start
175 .saturating_sub(self.min_end)
176 .saturating_sub(1)
177 .div_ceil(SAME_FILE_SPREAD_STEP)
178 }
179}
180
181#[must_use]
183pub fn compare_clone_groups(left: &CloneGroup, right: &CloneGroup) -> Ordering {
184 clone_group_rank_key(left).cmp(&clone_group_rank_key(right))
185}
186
187#[cfg(test)]
188fn instance_pair_spread(left: &CloneInstance, right: &CloneInstance) -> usize {
189 if left.file == right.file {
190 return same_file_spread(left, right);
191 }
192 directory_distance(&left.file, &right.file)
193}
194
195#[cfg(test)]
196fn same_file_spread(left: &CloneInstance, right: &CloneInstance) -> usize {
197 let gap = if left.end_line < right.start_line {
198 right
199 .start_line
200 .saturating_sub(left.end_line)
201 .saturating_sub(1)
202 } else if right.end_line < left.start_line {
203 left.start_line
204 .saturating_sub(right.end_line)
205 .saturating_sub(1)
206 } else {
207 0
208 };
209 gap.div_ceil(SAME_FILE_SPREAD_STEP)
210}
211
212fn path_parent_components(path: &Path) -> Vec<Component<'_>> {
213 path.parent()
214 .unwrap_or_else(|| Path::new(""))
215 .components()
216 .collect()
217}
218
219fn directory_tree_diameter(paths: &[FileSpread<'_>]) -> usize {
220 if paths.len() < 2 {
221 return 0;
222 }
223
224 let endpoint = farthest_path(paths, 0).0;
225 farthest_path(paths, endpoint).1
226}
227
228fn farthest_path(paths: &[FileSpread<'_>], origin: usize) -> (usize, usize) {
229 paths
230 .iter()
231 .enumerate()
232 .map(|(index, path)| {
233 (
234 index,
235 component_distance(&paths[origin].parent_components, &path.parent_components),
236 )
237 })
238 .max_by_key(|&(index, distance)| (distance, index))
239 .unwrap_or((origin, 0))
240}
241
242fn component_distance(left: &[Component<'_>], right: &[Component<'_>]) -> usize {
243 let shared = left
244 .iter()
245 .zip(right)
246 .take_while(|(left, right)| left == right)
247 .count();
248 left.len()
249 .saturating_sub(shared)
250 .saturating_add(right.len().saturating_sub(shared))
251}
252
253#[cfg(test)]
254fn directory_distance(left: &Path, right: &Path) -> usize {
255 component_distance(
256 &path_parent_components(left),
257 &path_parent_components(right),
258 )
259}
260
261type CloneGroupRankKey = (
262 Reverse<u128>,
263 Reverse<usize>,
264 Reverse<usize>,
265 Reverse<usize>,
266 Reverse<usize>,
267 bool,
268 PathBuf,
269 usize,
270);
271
272fn clone_group_rank_key(group: &CloneGroup) -> CloneGroupRankKey {
273 let spread = group.spread();
274 let weight = SPREAD_RANK_WEIGHTS[spread.min(MAX_RANKED_SPREAD)];
275 let token_count = u128::try_from(group.token_count).unwrap_or(u128::MAX);
276 let instance_count = u128::try_from(group.instances.len()).unwrap_or(u128::MAX);
277 let score = token_count
278 .saturating_mul(instance_count)
279 .saturating_mul(u128::from(weight));
280 let first = group.instances.iter().min_by(|left, right| {
281 left.file
282 .cmp(&right.file)
283 .then(left.start_line.cmp(&right.start_line))
284 });
285 (
286 Reverse(score),
287 Reverse(spread),
288 Reverse(group.token_count),
289 Reverse(group.instances.len()),
290 Reverse(group.line_count),
291 first.is_none(),
292 first.map_or_else(PathBuf::new, |instance| instance.file.clone()),
293 first.map_or(0, |instance| instance.start_line),
294 )
295}
296
297fn sort_clone_groups(groups: &mut [CloneGroup]) {
298 groups.sort_by_cached_key(clone_group_rank_key);
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
303#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
304pub enum RefactoringKind {
305 ExtractFunction,
307 ExtractModule,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
314pub struct RefactoringSuggestion {
315 pub kind: RefactoringKind,
317 pub description: String,
319 pub estimated_savings: usize,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
329#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
330pub struct CloneFamily {
331 #[serde(serialize_with = "serde_path::serialize_vec")]
333 pub files: Vec<PathBuf>,
334 pub groups: Vec<CloneGroup>,
336 pub total_duplicated_lines: usize,
338 pub total_duplicated_tokens: usize,
340 pub suggestions: Vec<RefactoringSuggestion>,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
347#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
348pub struct MirroredDirectory {
349 pub dir_a: String,
351 pub dir_b: String,
353 pub shared_files: Vec<String>,
355 pub total_lines: usize,
357}
358
359#[derive(Debug, Clone, Default)]
361pub struct DefaultIgnoreSkipCount {
362 pub pattern: &'static str,
364 pub count: usize,
366}
367
368#[derive(Debug, Clone, Default)]
370pub struct DefaultIgnoreSkips {
371 pub total: usize,
373 pub by_pattern: Vec<DefaultIgnoreSkipCount>,
375}
376
377#[derive(Debug, Clone, Default, Serialize, Deserialize)]
379#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
380pub struct DuplicationReport {
381 pub clone_groups: Vec<CloneGroup>,
384 pub clone_families: Vec<CloneFamily>,
387 #[serde(default, skip_serializing_if = "Vec::is_empty")]
389 pub mirrored_directories: Vec<MirroredDirectory>,
390 pub stats: DuplicationStats,
392}
393
394impl DuplicationReport {
395 pub fn sort(&mut self) {
400 for group in &mut self.clone_groups {
401 group
402 .instances
403 .sort_by(|a, b| a.file.cmp(&b.file).then(a.start_line.cmp(&b.start_line)));
404 }
405 sort_clone_groups(&mut self.clone_groups);
406
407 for family in &mut self.clone_families {
408 for group in &mut family.groups {
409 group
410 .instances
411 .sort_by(|a, b| a.file.cmp(&b.file).then(a.start_line.cmp(&b.start_line)));
412 }
413 sort_clone_groups(&mut family.groups);
414 }
415 self.clone_families.sort_by(|a, b| a.files.cmp(&b.files));
416 }
417}
418
419#[derive(Debug, Clone, Default, Serialize, Deserialize)]
421#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
422pub struct DuplicationStats {
423 pub total_files: usize,
425 pub files_with_clones: usize,
427 pub total_lines: usize,
429 pub duplicated_lines: usize,
431 pub total_tokens: usize,
433 pub duplicated_tokens: usize,
435 pub clone_groups: usize,
438 pub clone_instances: usize,
441 pub duplication_percentage: f64,
444 #[serde(default, skip_serializing_if = "is_zero_usize")]
448 pub clone_groups_below_min_occurrences: usize,
449 #[serde(default, skip_serializing_if = "is_zero_usize")]
451 pub clone_groups_ignored: usize,
452 #[serde(default, skip_serializing_if = "is_zero_usize")]
454 pub near_candidates_skipped: usize,
455}
456
457#[expect(
458 clippy::trivially_copy_pass_by_ref,
459 reason = "serde skip_serializing_if requires &T signature"
460)]
461const fn is_zero_usize(value: &usize) -> bool {
462 *value == 0
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use proptest::prelude::*;
469
470 fn pairwise_clone_group_spread(instances: &[CloneInstance]) -> usize {
471 let mut spread = 0;
472 for (index, left) in instances.iter().enumerate() {
473 for right in &instances[index + 1..] {
474 spread = spread.max(instance_pair_spread(left, right));
475 }
476 }
477 spread
478 }
479
480 fn instance(file: impl Into<PathBuf>, start_line: usize, end_line: usize) -> CloneInstance {
481 CloneInstance {
482 file: file.into(),
483 start_line,
484 end_line,
485 start_col: 0,
486 end_col: 0,
487 fragment: String::new(),
488 }
489 }
490
491 fn group(instances: Vec<CloneInstance>, token_count: usize, line_count: usize) -> CloneGroup {
492 CloneGroup {
493 instances,
494 token_count,
495 line_count,
496 similarity: None,
497 }
498 }
499
500 #[test]
501 fn spread_counts_non_shared_parent_components() {
502 let clone = group(
503 vec![
504 instance("/repo/packages/a/src/a.ts", 1, 10),
505 instance("/repo/packages/b/src/b.ts", 1, 10),
506 ],
507 100,
508 10,
509 );
510 assert_eq!(clone.spread(), 4);
511 }
512
513 #[test]
514 fn spread_is_zero_for_different_files_in_the_same_directory() {
515 let clone = group(
516 vec![
517 instance("/repo/src/a.ts", 1, 10),
518 instance("/repo/src/b.ts", 1, 10),
519 ],
520 100,
521 10,
522 );
523 assert_eq!(clone.spread(), 0);
524 }
525
526 #[test]
527 fn adjacent_same_file_instances_have_zero_spread() {
528 let clone = group(
529 vec![
530 instance("/repo/src/a.ts", 1, 10),
531 instance("/repo/src/a.ts", 11, 20),
532 ],
533 100,
534 10,
535 );
536 assert_eq!(clone.spread(), 0);
537 }
538
539 #[test]
540 fn same_file_spread_uses_ceiling_rounded_intervening_lines() {
541 let clone = group(
542 vec![
543 instance("/repo/src/a.ts", 1, 10),
544 instance("/repo/src/a.ts", 260, 269),
545 instance("/repo/src/a.ts", 261, 270),
546 instance("/repo/src/a.ts", 262, 271),
547 ],
548 100,
549 10,
550 );
551 assert_eq!(clone_group_spread(&clone.instances[..2]), 1);
552 assert_eq!(clone_group_spread(&clone.instances[..3]), 1);
553 assert_eq!(clone.spread(), 2);
554 }
555
556 #[test]
557 fn overlapping_same_file_instances_have_zero_spread() {
558 let clone = group(
559 vec![
560 instance("/repo/src/a.ts", 1, 20),
561 instance("/repo/src/a.ts", 10, 30),
562 ],
563 100,
564 20,
565 );
566 assert_eq!(clone.spread(), 0);
567 }
568
569 #[test]
570 fn directory_diameter_handles_ties_and_mixed_roots() {
571 let instances = vec![
572 instance("src/a.ts", 1, 10),
573 instance("packages/a/b.ts", 1, 10),
574 instance("packages/c/d.ts", 1, 10),
575 instance("/repo/src/e.ts", 1, 10),
576 ];
577
578 assert_eq!(
579 clone_group_spread(&instances),
580 pairwise_clone_group_spread(&instances)
581 );
582 }
583
584 #[test]
585 fn spread_matches_reference_for_repeated_mixed_and_nested_files() {
586 let instances = vec![
587 instance("src/a.ts", 900, 920),
588 instance("packages/a/src/nested/b.ts", 40, 60),
589 instance("src/a.ts", 1, 20),
590 instance("/repo/apps/web/c.ts", 300, 325),
591 instance("packages/b/test/d.ts", 70, 90),
592 instance("/repo/apps/web/c.ts", 1, 25),
593 ];
594
595 assert_eq!(
596 clone_group_spread(&instances),
597 pairwise_clone_group_spread(&instances)
598 );
599 }
600
601 #[cfg(unix)]
602 #[test]
603 fn spread_matches_reference_for_non_utf8_paths() {
604 use std::ffi::OsString;
605 use std::os::unix::ffi::OsStringExt;
606
607 let first = PathBuf::from(OsString::from_vec(b"/repo/packages/\x80/src/a.ts".to_vec()));
608 let second = PathBuf::from(OsString::from_vec(b"/repo/packages/\x81/src/b.ts".to_vec()));
609 let instances = vec![
610 instance(first.clone(), 1, 20),
611 instance(second, 100, 120),
612 instance(first, 800, 820),
613 ];
614
615 assert_eq!(
616 clone_group_spread(&instances),
617 pairwise_clone_group_spread(&instances)
618 );
619 }
620
621 #[cfg(windows)]
622 #[test]
623 fn directory_diameter_handles_windows_prefixes() {
624 let instances = vec![
625 instance(r"C:\repo\src\a.ts", 1, 10),
626 instance(r"C:\repo\packages\b.ts", 1, 10),
627 instance(r"D:\other\c.ts", 1, 10),
628 ];
629
630 assert_eq!(
631 clone_group_spread(&instances),
632 pairwise_clone_group_spread(&instances)
633 );
634 }
635
636 #[test]
637 fn clone_group_kind_does_not_change_serialized_contract() {
638 let mut clone = group(vec![instance("src/a.ts", 1, 10)], 20, 10);
639 assert_eq!(clone.kind(), CloneGroupKind::Exact);
640 assert!(serde_json::to_value(&clone).unwrap()["similarity"].is_null());
641
642 clone.similarity = Some(0.85);
643 assert_eq!(clone.kind(), CloneGroupKind::Near { similarity: 0.85 });
644 assert_eq!(serde_json::to_value(&clone).unwrap()["similarity"], 0.85);
645 }
646
647 mod proptests {
648 use super::*;
649
650 proptest! {
651 #[test]
652 fn optimized_spread_matches_pairwise_reference(
653 entries in prop::collection::vec((0_u8..8, 1_usize..4_000, 1_usize..500), 0..80)
654 ) {
655 let paths = [
656 "src/a.ts",
657 "src/b.ts",
658 "packages/a/src/c.ts",
659 "packages/b/src/d.ts",
660 "packages/b/test/e.ts",
661 "/repo/apps/web/f.ts",
662 "/repo/crates/core/g.ts",
663 "h.ts",
664 ];
665 let instances = entries
666 .into_iter()
667 .map(|(path, start, len)| instance(paths[usize::from(path)], start, start + len))
668 .collect::<Vec<_>>();
669
670 prop_assert_eq!(
671 clone_group_spread(&instances),
672 pairwise_clone_group_spread(&instances)
673 );
674 }
675 }
676 }
677
678 #[test]
679 fn ranking_uses_spread_without_overriding_a_larger_base_score() {
680 let distant = group(
681 vec![
682 instance("/repo/a/b/c/d/e/a.ts", 1, 10),
683 instance("/repo/f/g/h/i/j/b.ts", 1, 10),
684 ],
685 100,
686 10,
687 );
688 let slightly_larger_local = group(
689 vec![
690 instance("/repo/src/a.ts", 1, 10),
691 instance("/repo/src/b.ts", 1, 10),
692 ],
693 116,
694 10,
695 );
696 assert_eq!(distant.spread(), 10);
697 assert_eq!(
698 compare_clone_groups(&distant, &slightly_larger_local),
699 Ordering::Greater
700 );
701
702 let slightly_smaller_local = group(
703 vec![
704 instance("/repo/src/c.ts", 1, 10),
705 instance("/repo/src/d.ts", 1, 10),
706 ],
707 114,
708 10,
709 );
710 assert_eq!(
711 compare_clone_groups(&distant, &slightly_smaller_local),
712 Ordering::Less
713 );
714 }
715
716 #[test]
717 fn report_sort_uses_canonical_location_as_final_tiebreaker() {
718 let later = group(
719 vec![
720 instance("/repo/src/z.ts", 1, 10),
721 instance("/repo/src/y.ts", 1, 10),
722 ],
723 100,
724 10,
725 );
726 let earlier = group(
727 vec![
728 instance("/repo/src/a.ts", 20, 29),
729 instance("/repo/src/b.ts", 20, 29),
730 ],
731 100,
732 10,
733 );
734 let mut report = DuplicationReport {
735 clone_groups: vec![later, earlier],
736 ..DuplicationReport::default()
737 };
738 report.sort();
739 assert_eq!(
740 report.clone_groups[0].instances[0].file,
741 Path::new("/repo/src/a.ts")
742 );
743 }
744
745 #[test]
746 fn exact_clone_similarity_is_omitted() {
747 let clone = group(Vec::new(), 100, 10);
748 let value = serde_json::to_value(clone).unwrap();
749 assert!(value.get("similarity").is_none());
750 }
751
752 #[test]
753 fn near_clone_similarity_is_serialized() {
754 let mut clone = group(Vec::new(), 100, 10);
755 clone.similarity = Some(0.85);
756 let value = serde_json::to_value(clone).unwrap();
757 assert_eq!(value["similarity"], 0.85);
758 }
759
760 #[test]
761 fn optional_duplication_stats_are_omitted_at_zero() {
762 let empty = serde_json::to_value(DuplicationStats::default()).unwrap();
763 assert!(empty.get("clone_groups_ignored").is_none());
764 assert!(empty.get("near_candidates_skipped").is_none());
765
766 let populated = serde_json::to_value(DuplicationStats {
767 clone_groups_ignored: 2,
768 near_candidates_skipped: 3,
769 ..DuplicationStats::default()
770 })
771 .unwrap();
772 assert_eq!(populated["clone_groups_ignored"], 2);
773 assert_eq!(populated["near_candidates_skipped"], 3);
774 }
775}