Skip to main content

fallow_types/
duplicates.rs

1//! Shared duplicate-code output contracts.
2
3use 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/// A single instance of duplicated code at a specific location.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15pub struct CloneInstance {
16    /// Path to the file containing this clone instance.
17    #[serde(serialize_with = "serde_path::serialize")]
18    pub file: PathBuf,
19    /// 1-based start line of the clone.
20    pub start_line: usize,
21    /// 1-based end line of the clone.
22    pub end_line: usize,
23    /// 0-based start column.
24    pub start_col: usize,
25    /// 0-based end column.
26    pub end_col: usize,
27    /// The actual source code fragment.
28    ///
29    /// Omitted from JSON when the caller asked for a location-only payload
30    /// (`fallow dupes --no-fragments`, and the MCP `find_dupes` default). The
31    /// five location fields above address the same text, so a consumer that
32    /// wants the source reads it from the file.
33    #[serde(default, skip_serializing_if = "String::is_empty")]
34    pub fragment: String,
35}
36
37/// A group of code clones -- the same (or normalized-equivalent) code appearing
38/// in multiple places.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub struct CloneGroup {
42    /// All instances where this duplicated code appears.
43    pub instances: Vec<CloneInstance>,
44    /// Number of tokens in the duplicated block.
45    pub token_count: usize,
46    /// Number of lines in the duplicated block.
47    pub line_count: usize,
48    /// Lowest all-pairs similarity for a near-miss clone group. Exact clone
49    /// groups omit this field.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    #[cfg_attr(feature = "schema", schemars(with = "f64"))]
52    pub similarity: Option<f64>,
53}
54
55/// Whether a clone group is exact or a near-miss match.
56///
57/// This is derived from the serialized clone-group fields and does not add a
58/// separate discriminator to the output contract.
59#[derive(Debug, Clone, Copy, PartialEq)]
60#[non_exhaustive]
61pub enum CloneGroupKind {
62    /// An exact or normalization-equivalent clone group.
63    Exact,
64    /// A near-miss group and its lowest all-pairs similarity.
65    Near {
66        /// Lowest all-pairs similarity for the group.
67        similarity: f64,
68    },
69}
70
71impl CloneGroup {
72    /// Return the semantic kind represented by this clone group.
73    #[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    /// Maximum directory-tree or same-file line distance between instances.
82    #[must_use]
83    pub fn spread(&self) -> usize {
84        clone_group_spread(&self.instances)
85    }
86
87    /// Drop the verbatim source text from every instance in this group.
88    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/// Compute the maximum distance between clone instances.
110///
111/// Instances in different files use lexical parent-directory distance.
112/// Instances in the same file use the non-overlapping line gap, rounded up in
113/// 250-line steps. The returned value is not capped; only ranking caps spread.
114#[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/// Compute clone spread from borrowed file and line locations.
126///
127/// This is equivalent to [`clone_group_spread`] without requiring callers
128/// that wrap clone instances to clone their source fragments.
129#[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/// Compare clone groups in shared spread-aware priority order.
195#[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/// The kind of refactoring suggested for a clone family.
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
316#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
317pub enum RefactoringKind {
318    /// Extract a shared function/utility.
319    ExtractFunction,
320    /// Extract a shared module.
321    ExtractModule,
322}
323
324/// A refactoring suggestion for a clone family.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
327pub struct RefactoringSuggestion {
328    /// What kind of refactoring is suggested.
329    pub kind: RefactoringKind,
330    /// Human-readable description of the suggestion.
331    pub description: String,
332    /// Estimated lines that could be eliminated.
333    pub estimated_savings: usize,
334}
335
336/// A clone family: a set of clone groups that share the same file set.
337///
338/// When multiple clone groups are all duplicated between the same set of files,
339/// they form a family, indicating a deeper structural relationship that should
340/// be refactored together rather than group-by-group.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
343pub struct CloneFamily {
344    /// The files involved in this family (sorted for stable output).
345    #[serde(serialize_with = "serde_path::serialize_vec")]
346    pub files: Vec<PathBuf>,
347    /// Clone groups belonging to this family.
348    pub groups: Vec<CloneGroup>,
349    /// Total number of duplicated lines across all groups.
350    pub total_duplicated_lines: usize,
351    /// Total number of duplicated tokens across all groups.
352    pub total_duplicated_tokens: usize,
353    /// Refactoring suggestions for this family.
354    pub suggestions: Vec<RefactoringSuggestion>,
355}
356
357/// A detected mirrored directory pattern: two directory prefixes that contain
358/// identical files (e.g., `src/` and `deno/lib/`).
359#[derive(Debug, Clone, Serialize, Deserialize)]
360#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
361pub struct MirroredDirectory {
362    /// First directory path (lexically smaller).
363    pub dir_a: String,
364    /// Second directory path.
365    pub dir_b: String,
366    /// Filenames shared between the two directories.
367    pub shared_files: Vec<String>,
368    /// Total duplicated lines across all shared files.
369    pub total_lines: usize,
370}
371
372/// Number of files skipped by one built-in duplicates ignore pattern.
373#[derive(Debug, Clone, Default)]
374pub struct DefaultIgnoreSkipCount {
375    /// Glob pattern that matched skipped files.
376    pub pattern: &'static str,
377    /// Number of files skipped by this pattern.
378    pub count: usize,
379}
380
381/// Human-format-only skipped-file stats for built-in duplicates ignores.
382#[derive(Debug, Clone, Default)]
383pub struct DefaultIgnoreSkips {
384    /// Total number of files skipped by built-in duplicates ignores.
385    pub total: usize,
386    /// Per-pattern skip counts, in default pattern order.
387    pub by_pattern: Vec<DefaultIgnoreSkipCount>,
388}
389
390/// Overall duplication analysis report.
391#[derive(Debug, Clone, Default, Serialize, Deserialize)]
392#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
393pub struct DuplicationReport {
394    /// All detected clone groups. Each group contains 2+ instances of identical
395    /// or near-identical code.
396    pub clone_groups: Vec<CloneGroup>,
397    /// Clone families: groups of clone groups sharing the same file set,
398    /// indicating systematic duplication patterns.
399    pub clone_families: Vec<CloneFamily>,
400    /// Detected mirrored directory trees (directories with many identical files).
401    #[serde(default, skip_serializing_if = "Vec::is_empty")]
402    pub mirrored_directories: Vec<MirroredDirectory>,
403    /// Aggregate statistics.
404    pub stats: DuplicationStats,
405}
406
407impl DuplicationReport {
408    /// Sort all result arrays for deterministic output ordering.
409    ///
410    /// Clone groups use spread-aware priority order, instances use file path and
411    /// line order, and clone families use their file set.
412    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    /// Number of clone groups actually carried in `clone_groups[]`.
432    #[must_use]
433    pub fn clone_groups_shown(&self) -> usize {
434        self.clone_groups.len()
435    }
436
437    /// Number of scoped-corpus clone groups withheld from `clone_groups[]`.
438    ///
439    /// Non-zero only when a presentation cap such as `--top` truncated the
440    /// array; scope filters (diff, workspace, baseline) recompute `stats`, so
441    /// they leave nothing omitted.
442    #[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    /// Number of clone groups the measured corpus holds, shown plus omitted.
450    ///
451    /// Reads `stats.clone_groups` except on a report whose stats were never
452    /// populated, where it falls back to the array length so a caller never
453    /// renders a total smaller than the rows beneath it.
454    #[must_use]
455    pub fn clone_groups_total(&self) -> usize {
456        self.clone_groups_shown() + self.clone_groups_omitted()
457    }
458
459    /// Number of clone families actually carried in `clone_families[]`.
460    #[must_use]
461    pub fn clone_families_shown(&self) -> usize {
462        self.clone_families.len()
463    }
464
465    /// Number of scoped-corpus clone families withheld from
466    /// `clone_families[]`.
467    ///
468    /// `--top` truncates `clone_groups[]` and rebuilds the families from what
469    /// survives, so the family array narrows with the group array. Scope
470    /// filters (diff, workspace, baseline) rebuild the families and then
471    /// recompute `stats`, so they leave nothing omitted.
472    #[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    /// Number of clone families the measured corpus holds, shown plus omitted.
480    #[must_use]
481    pub fn clone_families_total(&self) -> usize {
482        self.clone_families_shown() + self.clone_families_omitted()
483    }
484
485    /// Drop the verbatim source text from every clone instance, including the
486    /// copies nested in `clone_families[].groups[]`.
487    ///
488    /// Fingerprints, suggested names, refactoring suggestions and actions are
489    /// all derived before serialization, so removing the text only shrinks the
490    /// payload.
491    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/// Aggregate duplication statistics.
504#[derive(Debug, Clone, Default, Serialize, Deserialize)]
505#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
506pub struct DuplicationStats {
507    /// Total files analyzed.
508    pub total_files: usize,
509    /// Files containing at least one clone instance.
510    pub files_with_clones: usize,
511    /// Total lines across all analyzed files.
512    pub total_lines: usize,
513    /// Lines that are part of at least one clone.
514    pub duplicated_lines: usize,
515    /// Total tokens across all analyzed files.
516    pub total_tokens: usize,
517    /// Tokens in redundant clone copies, excluding one retained copy per group.
518    pub duplicated_tokens: usize,
519    /// Number of clone groups the scoped corpus contains after filtering.
520    /// `--top` does not change it; compare it with `clone_groups_shown` on the
521    /// envelope to see how much of the corpus the array carries.
522    pub clone_groups: usize,
523    /// Number of clone families the scoped corpus contains after filtering.
524    /// `--top` truncates `clone_families[]` along with `clone_groups[]` but
525    /// does not change this counter; compare it with `clone_families_shown` on
526    /// the envelope to see how much of the corpus the array carries.
527    pub clone_families: usize,
528    /// Total clone instances across the scoped corpus after filtering.
529    /// `--top` does not change it.
530    pub clone_instances: usize,
531    /// Percentage of duplicated lines (0.0 to 100.0). `--top` does not change
532    /// this scoped corpus metric.
533    pub duplication_percentage: f64,
534    /// Number of clone groups hidden by `duplicates.minOccurrences`. Absent (or
535    /// `0`) when the filter is at its default of `2` and nothing was hidden.
536    /// This counter covers only the minimum-occurrence filter.
537    #[serde(default, skip_serializing_if = "is_zero_usize")]
538    pub clone_groups_below_min_occurrences: usize,
539    /// Number of clone groups hidden by `duplicates.ignoredClones`.
540    #[serde(default, skip_serializing_if = "is_zero_usize")]
541    pub clone_groups_ignored: usize,
542    /// Near-miss candidate comparisons skipped by bounded-work limits.
543    #[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}