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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
42 pub is_symlink: bool,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
48#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49pub struct CloneGroup {
50 pub instances: Vec<CloneInstance>,
52 pub token_count: usize,
54 pub line_count: usize,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 #[cfg_attr(feature = "schema", schemars(with = "f64"))]
60 pub similarity: Option<f64>,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq)]
68#[non_exhaustive]
69pub enum CloneGroupKind {
70 Exact,
72 Near {
74 similarity: f64,
76 },
77}
78
79impl CloneGroup {
80 #[must_use]
82 pub fn kind(&self) -> CloneGroupKind {
83 self.similarity
84 .map_or(CloneGroupKind::Exact, |similarity| CloneGroupKind::Near {
85 similarity,
86 })
87 }
88
89 #[must_use]
91 pub fn spread(&self) -> usize {
92 clone_group_spread(&self.instances)
93 }
94
95 pub fn strip_fragments(&mut self) {
97 for instance in &mut self.instances {
98 instance.fragment.clear();
99 }
100 }
101}
102
103const SAME_FILE_SPREAD_STEP: usize = 250;
104const MAX_RANKED_SPREAD: usize = 8;
105const SPREAD_RANK_WEIGHTS: [u64; MAX_RANKED_SPREAD + 1] = [
106 1_000_000_000,
107 1_047_319_732,
108 1_075_000_000,
109 1_094_639_463,
110 1_109_873_014,
111 1_122_319_732,
112 1_132_843_281,
113 1_141_959_195,
114 1_150_000_000,
115];
116
117#[must_use]
123pub fn clone_group_spread(instances: &[CloneInstance]) -> usize {
124 clone_location_spread(instances.iter().map(|instance| {
125 (
126 instance.file.as_path(),
127 instance.start_line,
128 instance.end_line,
129 )
130 }))
131}
132
133#[must_use]
138pub fn clone_location_spread<'a>(
139 locations: impl IntoIterator<Item = (&'a Path, usize, usize)>,
140) -> usize {
141 let mut locations = locations.into_iter();
142 let mut small = SmallFileSet::default();
143 let mut location_count = 0;
144 while let Some((file, start_line, end_line)) = locations.next() {
145 location_count += 1;
146 if small.include(file, start_line, end_line) {
147 continue;
148 }
149 let new_file = FileLines::new(file, start_line, end_line);
150 if !small.push(new_file) {
151 return large_location_spread(&small, new_file, locations);
152 }
153 }
154
155 if location_count < 2 {
156 return 0;
157 }
158 small.spread()
159}
160
161const SMALL_FILE_SET_LIMIT: usize = 8;
164
165#[derive(Clone, Copy)]
167struct FileLines<'a> {
168 file: &'a Path,
169 min_end: usize,
170 max_start: usize,
171 occurrences: usize,
172}
173
174impl<'a> FileLines<'a> {
175 const fn new(file: &'a Path, start_line: usize, end_line: usize) -> Self {
176 Self {
177 file,
178 min_end: end_line,
179 max_start: start_line,
180 occurrences: 1,
181 }
182 }
183
184 fn include(&mut self, start_line: usize, end_line: usize) {
185 self.min_end = self.min_end.min(end_line);
186 self.max_start = self.max_start.max(start_line);
187 self.occurrences += 1;
188 }
189
190 fn same_file_spread(&self) -> usize {
191 self.max_start
192 .saturating_sub(self.min_end)
193 .saturating_sub(1)
194 .div_ceil(SAME_FILE_SPREAD_STEP)
195 }
196}
197
198#[derive(Default)]
200struct SmallFileSet<'a> {
201 files: [Option<FileLines<'a>>; SMALL_FILE_SET_LIMIT],
202 len: usize,
203}
204
205impl<'a> SmallFileSet<'a> {
206 fn iter(&self) -> impl Iterator<Item = &FileLines<'a>> {
207 self.files[..self.len].iter().flatten()
208 }
209
210 fn include(&mut self, file: &Path, start_line: usize, end_line: usize) -> bool {
212 let Some(lines) = self.files[..self.len]
213 .iter_mut()
214 .flatten()
215 .find(|lines| lines.file == file)
216 else {
217 return false;
218 };
219 lines.include(start_line, end_line);
220 true
221 }
222
223 fn push(&mut self, lines: FileLines<'a>) -> bool {
225 let Some(slot) = self.files.get_mut(self.len) else {
226 return false;
227 };
228 *slot = Some(lines);
229 self.len += 1;
230 true
231 }
232
233 fn spread(&self) -> usize {
237 let same_file_max = self
238 .iter()
239 .filter(|lines| lines.occurrences >= 2)
240 .map(FileLines::same_file_spread)
241 .max()
242 .unwrap_or(0);
243 let mut diameter = 0;
244 for (index, left) in self.iter().enumerate() {
245 for right in self.iter().skip(index + 1) {
246 diameter = diameter.max(parent_distance(left.file, right.file));
247 }
248 }
249 same_file_max.max(diameter)
250 }
251}
252
253fn parent_distance(left: &Path, right: &Path) -> usize {
256 let mut left = left.parent().unwrap_or_else(|| Path::new("")).components();
257 let mut right = right.parent().unwrap_or_else(|| Path::new("")).components();
258 loop {
259 match (left.next(), right.next()) {
260 (Some(left_part), Some(right_part)) if left_part == right_part => {}
261 (Some(_), Some(_)) => return 2 + left.count() + right.count(),
262 (Some(_), None) => return 1 + left.count(),
263 (None, Some(_)) => return 1 + right.count(),
264 (None, None) => return 0,
265 }
266 }
267}
268
269fn large_location_spread<'a>(
271 small: &SmallFileSet<'a>,
272 pending: FileLines<'a>,
273 rest: impl Iterator<Item = (&'a Path, usize, usize)>,
274) -> usize {
275 let mut file_indices: FxHashMap<&'a Path, usize> = FxHashMap::default();
276 let mut by_file: Vec<FileSpread<'a>> = Vec::new();
277 for lines in small.iter().copied().chain(std::iter::once(pending)) {
278 file_indices.insert(lines.file, by_file.len());
279 by_file.push(FileSpread::from_lines(lines));
280 }
281
282 for (file, start_line, end_line) in rest {
283 let next_index = by_file.len();
284 match file_indices.entry(file) {
285 Entry::Occupied(entry) => by_file[*entry.get()].include(start_line, end_line),
286 Entry::Vacant(entry) => {
287 entry.insert(next_index);
288 by_file.push(FileSpread::new(file, start_line, end_line));
289 }
290 }
291 }
292
293 let same_file_max = by_file
294 .iter()
295 .filter(|file| file.occurrences >= 2)
296 .map(FileSpread::same_file_spread)
297 .max()
298 .unwrap_or(0);
299
300 same_file_max.max(directory_tree_diameter(&by_file))
301}
302
303struct FileSpread<'a> {
304 parent_components: Vec<Component<'a>>,
305 min_end: usize,
306 max_start: usize,
307 occurrences: usize,
308}
309
310impl<'a> FileSpread<'a> {
311 fn from_lines(lines: FileLines<'a>) -> Self {
312 Self {
313 parent_components: path_parent_components(lines.file),
314 min_end: lines.min_end,
315 max_start: lines.max_start,
316 occurrences: lines.occurrences,
317 }
318 }
319
320 fn new(file: &'a Path, start_line: usize, end_line: usize) -> Self {
321 Self {
322 parent_components: path_parent_components(file),
323 min_end: end_line,
324 max_start: start_line,
325 occurrences: 1,
326 }
327 }
328
329 fn include(&mut self, start_line: usize, end_line: usize) {
330 self.min_end = self.min_end.min(end_line);
331 self.max_start = self.max_start.max(start_line);
332 self.occurrences += 1;
333 }
334
335 fn same_file_spread(&self) -> usize {
336 self.max_start
337 .saturating_sub(self.min_end)
338 .saturating_sub(1)
339 .div_ceil(SAME_FILE_SPREAD_STEP)
340 }
341}
342
343#[must_use]
345pub fn compare_clone_groups(left: &CloneGroup, right: &CloneGroup) -> Ordering {
346 clone_group_rank_key(left).cmp(&clone_group_rank_key(right))
347}
348
349#[cfg(test)]
350fn instance_pair_spread(left: &CloneInstance, right: &CloneInstance) -> usize {
351 if left.file == right.file {
352 return same_file_spread(left, right);
353 }
354 directory_distance(&left.file, &right.file)
355}
356
357#[cfg(test)]
358fn same_file_spread(left: &CloneInstance, right: &CloneInstance) -> usize {
359 let gap = if left.end_line < right.start_line {
360 right
361 .start_line
362 .saturating_sub(left.end_line)
363 .saturating_sub(1)
364 } else if right.end_line < left.start_line {
365 left.start_line
366 .saturating_sub(right.end_line)
367 .saturating_sub(1)
368 } else {
369 0
370 };
371 gap.div_ceil(SAME_FILE_SPREAD_STEP)
372}
373
374fn path_parent_components(path: &Path) -> Vec<Component<'_>> {
375 path.parent()
376 .unwrap_or_else(|| Path::new(""))
377 .components()
378 .collect()
379}
380
381fn directory_tree_diameter(paths: &[FileSpread<'_>]) -> usize {
382 if paths.len() < 2 {
383 return 0;
384 }
385
386 let endpoint = farthest_path(paths, 0).0;
387 farthest_path(paths, endpoint).1
388}
389
390fn farthest_path(paths: &[FileSpread<'_>], origin: usize) -> (usize, usize) {
391 paths
392 .iter()
393 .enumerate()
394 .map(|(index, path)| {
395 (
396 index,
397 component_distance(&paths[origin].parent_components, &path.parent_components),
398 )
399 })
400 .max_by_key(|&(index, distance)| (distance, index))
401 .unwrap_or((origin, 0))
402}
403
404fn component_distance(left: &[Component<'_>], right: &[Component<'_>]) -> usize {
405 let shared = left
406 .iter()
407 .zip(right)
408 .take_while(|(left, right)| left == right)
409 .count();
410 left.len()
411 .saturating_sub(shared)
412 .saturating_add(right.len().saturating_sub(shared))
413}
414
415#[cfg(test)]
416fn directory_distance(left: &Path, right: &Path) -> usize {
417 component_distance(
418 &path_parent_components(left),
419 &path_parent_components(right),
420 )
421}
422
423type CloneGroupRankKey = (
424 Reverse<u128>,
425 Reverse<usize>,
426 Reverse<usize>,
427 Reverse<usize>,
428 Reverse<usize>,
429 bool,
430 PathBuf,
431 usize,
432);
433
434fn clone_group_rank_key(group: &CloneGroup) -> CloneGroupRankKey {
435 let spread = group.spread();
436 let weight = SPREAD_RANK_WEIGHTS[spread.min(MAX_RANKED_SPREAD)];
437 let token_count = u128::try_from(group.token_count).unwrap_or(u128::MAX);
438 let instance_count = u128::try_from(group.instances.len()).unwrap_or(u128::MAX);
439 let score = token_count
440 .saturating_mul(instance_count)
441 .saturating_mul(u128::from(weight));
442 let first = group.instances.iter().min_by(|left, right| {
443 left.file
444 .cmp(&right.file)
445 .then(left.start_line.cmp(&right.start_line))
446 });
447 (
448 Reverse(score),
449 Reverse(spread),
450 Reverse(group.token_count),
451 Reverse(group.instances.len()),
452 Reverse(group.line_count),
453 first.is_none(),
454 first.map_or_else(PathBuf::new, |instance| instance.file.clone()),
455 first.map_or(0, |instance| instance.start_line),
456 )
457}
458
459fn sort_clone_groups(groups: &mut [CloneGroup]) {
460 groups.sort_by_cached_key(clone_group_rank_key);
461}
462
463pub fn sort_clone_groups_in_report_order(groups: &mut [CloneGroup]) {
470 for group in groups.iter_mut() {
471 group
472 .instances
473 .sort_by(|a, b| a.file.cmp(&b.file).then(a.start_line.cmp(&b.start_line)));
474 }
475 sort_clone_groups(groups);
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
480#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
481pub enum RefactoringKind {
482 ExtractFunction,
484 ExtractModule,
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
490#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
491pub struct RefactoringSuggestion {
492 pub kind: RefactoringKind,
494 pub description: String,
496 pub estimated_savings: usize,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
506#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
507pub struct CloneFamily {
508 #[serde(serialize_with = "serde_path::serialize_vec")]
510 pub files: Vec<PathBuf>,
511 pub groups: Vec<CloneGroup>,
513 pub total_duplicated_lines: usize,
515 pub total_duplicated_tokens: usize,
517 pub suggestions: Vec<RefactoringSuggestion>,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
524#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
525pub struct MirroredDirectory {
526 pub dir_a: String,
528 pub dir_b: String,
530 pub shared_files: Vec<String>,
532 pub total_lines: usize,
534}
535
536#[derive(Debug, Clone, Default)]
538pub struct DefaultIgnoreSkipCount {
539 pub pattern: &'static str,
541 pub count: usize,
543}
544
545#[derive(Debug, Clone, Default)]
547pub struct DefaultIgnoreSkips {
548 pub total: usize,
550 pub by_pattern: Vec<DefaultIgnoreSkipCount>,
552}
553
554#[derive(Debug, Clone, Default, Serialize, Deserialize)]
556#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
557pub struct DuplicationReport {
558 pub clone_groups: Vec<CloneGroup>,
561 pub clone_families: Vec<CloneFamily>,
564 #[serde(default, skip_serializing_if = "Vec::is_empty")]
566 pub mirrored_directories: Vec<MirroredDirectory>,
567 pub stats: DuplicationStats,
569}
570
571impl DuplicationReport {
572 pub fn sort(&mut self) {
577 sort_clone_groups_in_report_order(&mut self.clone_groups);
578 for family in &mut self.clone_families {
579 sort_clone_groups_in_report_order(&mut family.groups);
580 }
581 self.clone_families.sort_by(|a, b| a.files.cmp(&b.files));
582 }
583
584 #[must_use]
586 pub fn clone_groups_shown(&self) -> usize {
587 self.clone_groups.len()
588 }
589
590 #[must_use]
596 pub fn clone_groups_omitted(&self) -> usize {
597 self.stats
598 .clone_groups
599 .saturating_sub(self.clone_groups.len())
600 }
601
602 #[must_use]
608 pub fn clone_groups_total(&self) -> usize {
609 self.clone_groups_shown() + self.clone_groups_omitted()
610 }
611
612 #[must_use]
614 pub fn clone_families_shown(&self) -> usize {
615 self.clone_families.len()
616 }
617
618 #[must_use]
626 pub fn clone_families_omitted(&self) -> usize {
627 self.stats
628 .clone_families
629 .saturating_sub(self.clone_families.len())
630 }
631
632 #[must_use]
634 pub fn clone_families_total(&self) -> usize {
635 self.clone_families_shown() + self.clone_families_omitted()
636 }
637
638 pub fn strip_fragments(&mut self) {
645 for group in &mut self.clone_groups {
646 group.strip_fragments();
647 }
648 for family in &mut self.clone_families {
649 for group in &mut family.groups {
650 group.strip_fragments();
651 }
652 }
653 }
654}
655
656#[derive(Debug, Clone, Default, Serialize, Deserialize)]
658#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
659pub struct DuplicationStats {
660 pub total_files: usize,
662 pub files_with_clones: usize,
664 pub total_lines: usize,
666 pub duplicated_lines: usize,
668 pub total_tokens: usize,
670 pub duplicated_tokens: usize,
672 pub clone_groups: usize,
676 pub clone_families: usize,
681 pub clone_instances: usize,
684 pub duplication_percentage: f64,
687 #[serde(default, skip_serializing_if = "is_zero_usize")]
691 pub clone_groups_below_min_occurrences: usize,
692 #[serde(default, skip_serializing_if = "is_zero_usize")]
694 pub clone_groups_ignored: usize,
695 #[serde(default, skip_serializing_if = "is_zero_usize")]
697 pub near_candidates_skipped: usize,
698}
699
700#[expect(
701 clippy::trivially_copy_pass_by_ref,
702 reason = "serde skip_serializing_if requires &T signature"
703)]
704const fn is_zero_usize(value: &usize) -> bool {
705 *value == 0
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use proptest::prelude::*;
712
713 fn pairwise_clone_group_spread(instances: &[CloneInstance]) -> usize {
714 let mut spread = 0;
715 for (index, left) in instances.iter().enumerate() {
716 for right in &instances[index + 1..] {
717 spread = spread.max(instance_pair_spread(left, right));
718 }
719 }
720 spread
721 }
722
723 fn instance(file: impl Into<PathBuf>, start_line: usize, end_line: usize) -> CloneInstance {
724 CloneInstance {
725 is_symlink: false,
726 file: file.into(),
727 start_line,
728 end_line,
729 start_col: 0,
730 end_col: 0,
731 fragment: String::new(),
732 }
733 }
734
735 fn group(instances: Vec<CloneInstance>, token_count: usize, line_count: usize) -> CloneGroup {
736 CloneGroup {
737 instances,
738 token_count,
739 line_count,
740 similarity: None,
741 }
742 }
743
744 #[test]
745 fn spread_counts_non_shared_parent_components() {
746 let clone = group(
747 vec![
748 instance("/repo/packages/a/src/a.ts", 1, 10),
749 instance("/repo/packages/b/src/b.ts", 1, 10),
750 ],
751 100,
752 10,
753 );
754 assert_eq!(clone.spread(), 4);
755 }
756
757 #[test]
758 fn spread_is_zero_for_different_files_in_the_same_directory() {
759 let clone = group(
760 vec![
761 instance("/repo/src/a.ts", 1, 10),
762 instance("/repo/src/b.ts", 1, 10),
763 ],
764 100,
765 10,
766 );
767 assert_eq!(clone.spread(), 0);
768 }
769
770 #[test]
771 fn adjacent_same_file_instances_have_zero_spread() {
772 let clone = group(
773 vec![
774 instance("/repo/src/a.ts", 1, 10),
775 instance("/repo/src/a.ts", 11, 20),
776 ],
777 100,
778 10,
779 );
780 assert_eq!(clone.spread(), 0);
781 }
782
783 #[test]
784 fn same_file_spread_uses_ceiling_rounded_intervening_lines() {
785 let clone = group(
786 vec![
787 instance("/repo/src/a.ts", 1, 10),
788 instance("/repo/src/a.ts", 260, 269),
789 instance("/repo/src/a.ts", 261, 270),
790 instance("/repo/src/a.ts", 262, 271),
791 ],
792 100,
793 10,
794 );
795 assert_eq!(clone_group_spread(&clone.instances[..2]), 1);
796 assert_eq!(clone_group_spread(&clone.instances[..3]), 1);
797 assert_eq!(clone.spread(), 2);
798 }
799
800 #[test]
801 fn overlapping_same_file_instances_have_zero_spread() {
802 let clone = group(
803 vec![
804 instance("/repo/src/a.ts", 1, 20),
805 instance("/repo/src/a.ts", 10, 30),
806 ],
807 100,
808 20,
809 );
810 assert_eq!(clone.spread(), 0);
811 }
812
813 #[test]
814 fn directory_diameter_handles_ties_and_mixed_roots() {
815 let instances = vec![
816 instance("src/a.ts", 1, 10),
817 instance("packages/a/b.ts", 1, 10),
818 instance("packages/c/d.ts", 1, 10),
819 instance("/repo/src/e.ts", 1, 10),
820 ];
821
822 assert_eq!(
823 clone_group_spread(&instances),
824 pairwise_clone_group_spread(&instances)
825 );
826 }
827
828 #[test]
829 fn spread_matches_reference_for_repeated_mixed_and_nested_files() {
830 let instances = vec![
831 instance("src/a.ts", 900, 920),
832 instance("packages/a/src/nested/b.ts", 40, 60),
833 instance("src/a.ts", 1, 20),
834 instance("/repo/apps/web/c.ts", 300, 325),
835 instance("packages/b/test/d.ts", 70, 90),
836 instance("/repo/apps/web/c.ts", 1, 25),
837 ];
838
839 assert_eq!(
840 clone_group_spread(&instances),
841 pairwise_clone_group_spread(&instances)
842 );
843 }
844
845 #[cfg(unix)]
846 #[test]
847 fn spread_matches_reference_for_non_utf8_paths() {
848 use std::ffi::OsString;
849 use std::os::unix::ffi::OsStringExt;
850
851 let first = PathBuf::from(OsString::from_vec(b"/repo/packages/\x80/src/a.ts".to_vec()));
852 let second = PathBuf::from(OsString::from_vec(b"/repo/packages/\x81/src/b.ts".to_vec()));
853 let instances = vec![
854 instance(first.clone(), 1, 20),
855 instance(second, 100, 120),
856 instance(first, 800, 820),
857 ];
858
859 assert_eq!(
860 clone_group_spread(&instances),
861 pairwise_clone_group_spread(&instances)
862 );
863 }
864
865 #[cfg(windows)]
866 #[test]
867 fn directory_diameter_handles_windows_prefixes() {
868 let instances = vec![
869 instance(r"C:\repo\src\a.ts", 1, 10),
870 instance(r"C:\repo\packages\b.ts", 1, 10),
871 instance(r"D:\other\c.ts", 1, 10),
872 ];
873
874 assert_eq!(
875 clone_group_spread(&instances),
876 pairwise_clone_group_spread(&instances)
877 );
878 }
879
880 #[test]
881 fn clone_group_kind_does_not_change_serialized_contract() {
882 let mut clone = group(vec![instance("src/a.ts", 1, 10)], 20, 10);
883 assert_eq!(clone.kind(), CloneGroupKind::Exact);
884 assert!(serde_json::to_value(&clone).unwrap()["similarity"].is_null());
885
886 clone.similarity = Some(0.85);
887 assert_eq!(clone.kind(), CloneGroupKind::Near { similarity: 0.85 });
888 assert_eq!(serde_json::to_value(&clone).unwrap()["similarity"], 0.85);
889 }
890
891 mod proptests {
892 use super::*;
893
894 proptest! {
895 #[test]
896 fn optimized_spread_matches_pairwise_reference(
897 entries in prop::collection::vec((0_u8..16, 1_usize..4_000, 1_usize..500), 0..80)
898 ) {
899 let paths = [
902 "src/a.ts",
903 "src/b.ts",
904 "packages/a/src/c.ts",
905 "packages/b/src/d.ts",
906 "packages/b/test/e.ts",
907 "/repo/apps/web/f.ts",
908 "/repo/crates/core/g.ts",
909 "h.ts",
910 "src//a.ts",
911 "./src/b.ts",
912 "packages/c/src/i.ts",
913 "packages/c/src/deep/j.ts",
914 "apps/web/k.ts",
915 "apps/api/src/l.ts",
916 "tools/m.ts",
917 "n.ts",
918 ];
919 let instances = entries
920 .into_iter()
921 .map(|(path, start, len)| instance(paths[usize::from(path)], start, start + len))
922 .collect::<Vec<_>>();
923
924 prop_assert_eq!(
925 clone_group_spread(&instances),
926 pairwise_clone_group_spread(&instances)
927 );
928 }
929 }
930 }
931
932 #[test]
933 fn ranking_uses_spread_without_overriding_a_larger_base_score() {
934 let distant = group(
935 vec![
936 instance("/repo/a/b/c/d/e/a.ts", 1, 10),
937 instance("/repo/f/g/h/i/j/b.ts", 1, 10),
938 ],
939 100,
940 10,
941 );
942 let slightly_larger_local = group(
943 vec![
944 instance("/repo/src/a.ts", 1, 10),
945 instance("/repo/src/b.ts", 1, 10),
946 ],
947 116,
948 10,
949 );
950 assert_eq!(distant.spread(), 10);
951 assert_eq!(
952 compare_clone_groups(&distant, &slightly_larger_local),
953 Ordering::Greater
954 );
955
956 let slightly_smaller_local = group(
957 vec![
958 instance("/repo/src/c.ts", 1, 10),
959 instance("/repo/src/d.ts", 1, 10),
960 ],
961 114,
962 10,
963 );
964 assert_eq!(
965 compare_clone_groups(&distant, &slightly_smaller_local),
966 Ordering::Less
967 );
968 }
969
970 #[test]
971 fn report_sort_uses_canonical_location_as_final_tiebreaker() {
972 let later = group(
973 vec![
974 instance("/repo/src/z.ts", 1, 10),
975 instance("/repo/src/y.ts", 1, 10),
976 ],
977 100,
978 10,
979 );
980 let earlier = group(
981 vec![
982 instance("/repo/src/a.ts", 20, 29),
983 instance("/repo/src/b.ts", 20, 29),
984 ],
985 100,
986 10,
987 );
988 let mut report = DuplicationReport {
989 clone_groups: vec![later, earlier],
990 ..DuplicationReport::default()
991 };
992 report.sort();
993 assert_eq!(
994 report.clone_groups[0].instances[0].file,
995 Path::new("/repo/src/a.ts")
996 );
997 }
998
999 #[test]
1000 fn exact_clone_similarity_is_omitted() {
1001 let clone = group(Vec::new(), 100, 10);
1002 let value = serde_json::to_value(clone).unwrap();
1003 assert!(value.get("similarity").is_none());
1004 }
1005
1006 #[test]
1007 fn near_clone_similarity_is_serialized() {
1008 let mut clone = group(Vec::new(), 100, 10);
1009 clone.similarity = Some(0.85);
1010 let value = serde_json::to_value(clone).unwrap();
1011 assert_eq!(value["similarity"], 0.85);
1012 }
1013
1014 #[test]
1015 fn optional_duplication_stats_are_omitted_at_zero() {
1016 let empty = serde_json::to_value(DuplicationStats::default()).unwrap();
1017 assert!(empty.get("clone_groups_ignored").is_none());
1018 assert!(empty.get("near_candidates_skipped").is_none());
1019
1020 let populated = serde_json::to_value(DuplicationStats {
1021 clone_groups_ignored: 2,
1022 near_candidates_skipped: 3,
1023 ..DuplicationStats::default()
1024 })
1025 .unwrap();
1026 assert_eq!(populated["clone_groups_ignored"], 2);
1027 assert_eq!(populated["near_candidates_skipped"], 3);
1028 }
1029}