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    pub fragment: String,
29}
30
31/// A group of code clones -- the same (or normalized-equivalent) code appearing
32/// in multiple places.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub struct CloneGroup {
36    /// All instances where this duplicated code appears.
37    pub instances: Vec<CloneInstance>,
38    /// Number of tokens in the duplicated block.
39    pub token_count: usize,
40    /// Number of lines in the duplicated block.
41    pub line_count: usize,
42    /// Lowest all-pairs similarity for a near-miss clone group. Exact clone
43    /// groups omit this field.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    #[cfg_attr(feature = "schema", schemars(with = "f64"))]
46    pub similarity: Option<f64>,
47}
48
49/// Whether a clone group is exact or a near-miss match.
50///
51/// This is derived from the serialized clone-group fields and does not add a
52/// separate discriminator to the output contract.
53#[derive(Debug, Clone, Copy, PartialEq)]
54#[non_exhaustive]
55pub enum CloneGroupKind {
56    /// An exact or normalization-equivalent clone group.
57    Exact,
58    /// A near-miss group and its lowest all-pairs similarity.
59    Near {
60        /// Lowest all-pairs similarity for the group.
61        similarity: f64,
62    },
63}
64
65impl CloneGroup {
66    /// Return the semantic kind represented by this clone group.
67    #[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    /// Maximum directory-tree or same-file line distance between instances.
76    #[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/// Compute the maximum distance between clone instances.
97///
98/// Instances in different files use lexical parent-directory distance.
99/// Instances in the same file use the non-overlapping line gap, rounded up in
100/// 250-line steps. The returned value is not capped; only ranking caps spread.
101#[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/// Compute clone spread from borrowed file and line locations.
113///
114/// This is equivalent to [`clone_group_spread`] without requiring callers
115/// that wrap clone instances to clone their source fragments.
116#[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/// Compare clone groups in shared spread-aware priority order.
182#[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/// The kind of refactoring suggested for a clone family.
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
303#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
304pub enum RefactoringKind {
305    /// Extract a shared function/utility.
306    ExtractFunction,
307    /// Extract a shared module.
308    ExtractModule,
309}
310
311/// A refactoring suggestion for a clone family.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
314pub struct RefactoringSuggestion {
315    /// What kind of refactoring is suggested.
316    pub kind: RefactoringKind,
317    /// Human-readable description of the suggestion.
318    pub description: String,
319    /// Estimated lines that could be eliminated.
320    pub estimated_savings: usize,
321}
322
323/// A clone family: a set of clone groups that share the same file set.
324///
325/// When multiple clone groups are all duplicated between the same set of files,
326/// they form a family, indicating a deeper structural relationship that should
327/// be refactored together rather than group-by-group.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
330pub struct CloneFamily {
331    /// The files involved in this family (sorted for stable output).
332    #[serde(serialize_with = "serde_path::serialize_vec")]
333    pub files: Vec<PathBuf>,
334    /// Clone groups belonging to this family.
335    pub groups: Vec<CloneGroup>,
336    /// Total number of duplicated lines across all groups.
337    pub total_duplicated_lines: usize,
338    /// Total number of duplicated tokens across all groups.
339    pub total_duplicated_tokens: usize,
340    /// Refactoring suggestions for this family.
341    pub suggestions: Vec<RefactoringSuggestion>,
342}
343
344/// A detected mirrored directory pattern: two directory prefixes that contain
345/// identical files (e.g., `src/` and `deno/lib/`).
346#[derive(Debug, Clone, Serialize, Deserialize)]
347#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
348pub struct MirroredDirectory {
349    /// First directory path (lexically smaller).
350    pub dir_a: String,
351    /// Second directory path.
352    pub dir_b: String,
353    /// Filenames shared between the two directories.
354    pub shared_files: Vec<String>,
355    /// Total duplicated lines across all shared files.
356    pub total_lines: usize,
357}
358
359/// Number of files skipped by one built-in duplicates ignore pattern.
360#[derive(Debug, Clone, Default)]
361pub struct DefaultIgnoreSkipCount {
362    /// Glob pattern that matched skipped files.
363    pub pattern: &'static str,
364    /// Number of files skipped by this pattern.
365    pub count: usize,
366}
367
368/// Human-format-only skipped-file stats for built-in duplicates ignores.
369#[derive(Debug, Clone, Default)]
370pub struct DefaultIgnoreSkips {
371    /// Total number of files skipped by built-in duplicates ignores.
372    pub total: usize,
373    /// Per-pattern skip counts, in default pattern order.
374    pub by_pattern: Vec<DefaultIgnoreSkipCount>,
375}
376
377/// Overall duplication analysis report.
378#[derive(Debug, Clone, Default, Serialize, Deserialize)]
379#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
380pub struct DuplicationReport {
381    /// All detected clone groups. Each group contains 2+ instances of identical
382    /// or near-identical code.
383    pub clone_groups: Vec<CloneGroup>,
384    /// Clone families: groups of clone groups sharing the same file set,
385    /// indicating systematic duplication patterns.
386    pub clone_families: Vec<CloneFamily>,
387    /// Detected mirrored directory trees (directories with many identical files).
388    #[serde(default, skip_serializing_if = "Vec::is_empty")]
389    pub mirrored_directories: Vec<MirroredDirectory>,
390    /// Aggregate statistics.
391    pub stats: DuplicationStats,
392}
393
394impl DuplicationReport {
395    /// Sort all result arrays for deterministic output ordering.
396    ///
397    /// Clone groups use spread-aware priority order, instances use file path and
398    /// line order, and clone families use their file set.
399    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/// Aggregate duplication statistics.
420#[derive(Debug, Clone, Default, Serialize, Deserialize)]
421#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
422pub struct DuplicationStats {
423    /// Total files analyzed.
424    pub total_files: usize,
425    /// Files containing at least one clone instance.
426    pub files_with_clones: usize,
427    /// Total lines across all analyzed files.
428    pub total_lines: usize,
429    /// Lines that are part of at least one clone.
430    pub duplicated_lines: usize,
431    /// Total tokens across all analyzed files.
432    pub total_tokens: usize,
433    /// Tokens in redundant clone copies, excluding one retained copy per group.
434    pub duplicated_tokens: usize,
435    /// Number of clone groups in the reported `clone_groups[]` array after
436    /// filtering and optional `--top` truncation.
437    pub clone_groups: usize,
438    /// Total clone instances across all reported groups after filtering and
439    /// optional `--top` truncation.
440    pub clone_instances: usize,
441    /// Percentage of duplicated lines (0.0 to 100.0). `--top` does not change
442    /// this scoped corpus metric.
443    pub duplication_percentage: f64,
444    /// Number of clone groups hidden by `duplicates.minOccurrences`. Absent (or
445    /// `0`) when the filter is at its default of `2` and nothing was hidden.
446    /// This counter covers only the minimum-occurrence filter.
447    #[serde(default, skip_serializing_if = "is_zero_usize")]
448    pub clone_groups_below_min_occurrences: usize,
449    /// Number of clone groups hidden by `duplicates.ignoredClones`.
450    #[serde(default, skip_serializing_if = "is_zero_usize")]
451    pub clone_groups_ignored: usize,
452    /// Near-miss candidate comparisons skipped by bounded-work limits.
453    #[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}