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