1mod cache;
9pub mod deepdive;
10pub mod detect;
11pub mod families;
12pub mod normalize;
13mod shingle_filter;
14pub mod token_types;
15mod token_visitor;
16pub mod tokenize;
17pub(crate) mod types;
18
19use rustc_hash::FxHashMap;
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicUsize, Ordering};
22
23use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder};
24use rayon::prelude::*;
25use rustc_hash::FxHashSet;
26
27use cache::{TokenCache, TokenCacheEntry, TokenCacheMode};
28pub use deepdive::{
29 CloneFingerprintKey, CloneFingerprintSet, FINGERPRINT_PREFIX, clone_fingerprint,
30 dominant_identifier, fingerprint_for_fragment, group_refactoring_suggestion,
31};
32use detect::CloneDetector;
33use normalize::normalize_and_hash_resolved;
34use tokenize::{tokenize_file, tokenize_file_cross_language};
35pub use types::{
36 CloneFamily, CloneGroup, CloneInstance, DefaultIgnoreSkipCount, DefaultIgnoreSkips,
37 DetectionMode, DuplicatesConfig, DuplicationReport, DuplicationStats, MirroredDirectory,
38 RefactoringKind, RefactoringSuggestion,
39};
40
41use crate::discover::{self, DiscoveredFile};
42use crate::suppress::{self, IssueKind, Suppression};
43
44pub const DUPES_DEFAULT_IGNORES: &[&str] = &[
50 "**/.next/**",
51 "**/.nuxt/**",
52 "**/.svelte-kit/**",
53 "**/.turbo/**",
54 "**/.parcel-cache/**",
55 "**/.vite/**",
56 "**/.cache/**",
57 "**/out/**",
58 "**/storybook-static/**",
59];
60
61#[derive(Clone)]
62pub(super) struct TokenizedFile {
63 path: PathBuf,
64 hashed_tokens: Vec<normalize::HashedToken>,
65 file_tokens: tokenize::FileTokens,
66 metadata: Option<std::fs::Metadata>,
67 cache_hit: bool,
68 suppressions: Vec<Suppression>,
69}
70
71struct IgnoreSet {
72 all: GlobSet,
73 defaults: Vec<(&'static str, GlobMatcher)>,
74}
75
76impl IgnoreSet {
77 fn is_match(&self, path: &Path) -> bool {
78 self.all.is_match(path)
79 }
80
81 fn default_match_index(&self, path: &Path) -> Option<usize> {
82 self.defaults
83 .iter()
84 .position(|(_, matcher)| matcher.is_match(path))
85 }
86}
87
88struct DuplicationRun {
89 report: DuplicationReport,
90 default_ignore_skips: DefaultIgnoreSkips,
91}
92
93struct DuplicationTokenizeContext<'a> {
94 root: &'a Path,
95 config: &'a DuplicatesConfig,
96 extra_ignores: Option<&'a IgnoreSet>,
97 default_skip_counts: &'a [AtomicUsize],
98 token_cache: Option<&'a TokenCache>,
99 token_cache_mode: TokenCacheMode,
100 normalization: fallow_config::ResolvedNormalization,
101 strip_types: bool,
102 skip_imports: bool,
103}
104
105pub fn find_duplicates(
114 root: &Path,
115 files: &[DiscoveredFile],
116 config: &DuplicatesConfig,
117) -> DuplicationReport {
118 find_duplicates_inner(root, files, config, None, None).report
119}
120
121pub fn find_duplicates_with_default_ignore_skips(
124 root: &Path,
125 files: &[DiscoveredFile],
126 config: &DuplicatesConfig,
127) -> (DuplicationReport, DefaultIgnoreSkips) {
128 let run = find_duplicates_inner(root, files, config, None, None);
129 (run.report, run.default_ignore_skips)
130}
131
132pub fn find_duplicates_cached(
134 root: &Path,
135 files: &[DiscoveredFile],
136 config: &DuplicatesConfig,
137 cache_root: &Path,
138) -> DuplicationReport {
139 find_duplicates_inner(root, files, config, None, Some(cache_root)).report
140}
141
142pub fn find_duplicates_cached_with_default_ignore_skips(
145 root: &Path,
146 files: &[DiscoveredFile],
147 config: &DuplicatesConfig,
148 cache_root: &Path,
149) -> (DuplicationReport, DefaultIgnoreSkips) {
150 let run = find_duplicates_inner(root, files, config, None, Some(cache_root));
151 (run.report, run.default_ignore_skips)
152}
153
154#[expect(
160 clippy::implicit_hasher,
161 reason = "fallow uses FxHashSet for changed-file sets throughout analysis"
162)]
163pub fn find_duplicates_touching_files(
164 root: &Path,
165 files: &[DiscoveredFile],
166 config: &DuplicatesConfig,
167 focus_files: &FxHashSet<PathBuf>,
168) -> DuplicationReport {
169 find_duplicates_inner(root, files, config, Some(focus_files), None).report
170}
171
172#[expect(
175 clippy::implicit_hasher,
176 reason = "fallow uses FxHashSet for changed-file sets throughout analysis"
177)]
178pub fn find_duplicates_touching_files_with_default_ignore_skips(
179 root: &Path,
180 files: &[DiscoveredFile],
181 config: &DuplicatesConfig,
182 focus_files: &FxHashSet<PathBuf>,
183) -> (DuplicationReport, DefaultIgnoreSkips) {
184 let run = find_duplicates_inner(root, files, config, Some(focus_files), None);
185 (run.report, run.default_ignore_skips)
186}
187
188#[expect(
190 clippy::implicit_hasher,
191 reason = "fallow uses FxHashSet for changed-file sets throughout analysis"
192)]
193pub fn find_duplicates_touching_files_cached(
194 root: &Path,
195 files: &[DiscoveredFile],
196 config: &DuplicatesConfig,
197 focus_files: &FxHashSet<PathBuf>,
198 cache_root: &Path,
199) -> DuplicationReport {
200 find_duplicates_inner(root, files, config, Some(focus_files), Some(cache_root)).report
201}
202
203#[expect(
206 clippy::implicit_hasher,
207 reason = "fallow uses FxHashSet for changed-file sets throughout analysis"
208)]
209pub fn find_duplicates_touching_files_cached_with_default_ignore_skips(
210 root: &Path,
211 files: &[DiscoveredFile],
212 config: &DuplicatesConfig,
213 focus_files: &FxHashSet<PathBuf>,
214 cache_root: &Path,
215) -> (DuplicationReport, DefaultIgnoreSkips) {
216 let run = find_duplicates_inner(root, files, config, Some(focus_files), Some(cache_root));
217 (run.report, run.default_ignore_skips)
218}
219
220fn tokenize_corpus_for_duplicates(
224 root: &Path,
225 files: &[DiscoveredFile],
226 config: &DuplicatesConfig,
227 extra_ignores: Option<&IgnoreSet>,
228 default_skip_counts: &[AtomicUsize],
229 cache_root: Option<&Path>,
230) -> (Vec<TokenizedFile>, detect::CorpusTotals) {
231 let normalization =
232 fallow_config::ResolvedNormalization::resolve(config.mode, &config.normalization);
233
234 let strip_types = config.cross_language;
235 let skip_imports = config.ignore_imports;
236
237 tracing::debug!(
238 ignore_imports = skip_imports,
239 "duplication tokenization config"
240 );
241
242 let token_cache_mode = TokenCacheMode::new(normalization, strip_types, skip_imports);
243 let cache_root = cache_root.filter(|_| files.len() >= config.min_corpus_size_for_token_cache);
244 let token_cache = cache_root.map(TokenCache::load);
245
246 let file_data = tokenize_duplication_files(
247 files,
248 &DuplicationTokenizeContext {
249 root,
250 config,
251 extra_ignores,
252 default_skip_counts,
253 token_cache: token_cache.as_ref(),
254 token_cache_mode,
255 normalization,
256 strip_types,
257 skip_imports,
258 },
259 );
260
261 if let (Some(cache_root), Some(cache)) = (cache_root, token_cache) {
262 save_duplication_token_cache(cache_root, cache, files, &file_data, token_cache_mode);
263 }
264
265 tracing::info!(
266 files = file_data.len(),
267 "tokenized files for duplication analysis"
268 );
269
270 let corpus_totals = detect::CorpusTotals {
271 files: file_data.len(),
272 lines: file_data
273 .iter()
274 .map(|file| file.file_tokens.line_count)
275 .sum(),
276 tokens: file_data.iter().map(|file| file.hashed_tokens.len()).sum(),
277 };
278 (file_data, corpus_totals)
279}
280
281fn detect_and_postprocess(
285 root: &Path,
286 config: &DuplicatesConfig,
287 mut file_data: Vec<TokenizedFile>,
288 corpus_totals: detect::CorpusTotals,
289 focus_files: Option<&FxHashSet<PathBuf>>,
290) -> DuplicationReport {
291 if file_data.len() >= config.min_corpus_size_for_shingle_filter {
292 if let Some(focus_files) = focus_files {
293 shingle_filter::filter_to_focus_candidates(
294 &mut file_data,
295 focus_files,
296 config.min_tokens,
297 );
298 } else {
299 shingle_filter::filter_to_duplicate_candidates(&mut file_data, config.min_tokens);
300 }
301 }
302
303 let suppressions_by_file: FxHashMap<PathBuf, Vec<Suppression>> = file_data
304 .iter()
305 .filter(|file| !file.suppressions.is_empty())
306 .map(|file| (file.path.clone(), file.suppressions.clone()))
307 .collect();
308
309 let detector_data: Vec<(PathBuf, Vec<normalize::HashedToken>, tokenize::FileTokens)> =
310 file_data
311 .into_iter()
312 .map(|file| (file.path, file.hashed_tokens, file.file_tokens))
313 .collect();
314
315 let detector = CloneDetector::new(config.min_tokens, config.min_lines, config.skip_local);
316 let mut report = if let Some(focus_files) = focus_files {
317 detector.detect_touching_files(detector_data, focus_files)
318 } else {
319 detector.detect_with_totals(detector_data, corpus_totals)
320 };
321
322 if !suppressions_by_file.is_empty() {
323 apply_line_suppressions(&mut report, &suppressions_by_file);
324 }
325
326 apply_min_occurrences_filter(&mut report, config.min_occurrences);
327
328 report.clone_families = families::group_into_families(&report.clone_groups, root);
329 report.mirrored_directories =
330 families::detect_mirrored_directories(&report.clone_families, root);
331 report.sort();
332 report
333}
334
335fn find_duplicates_inner(
336 root: &Path,
337 files: &[DiscoveredFile],
338 config: &DuplicatesConfig,
339 focus_files: Option<&FxHashSet<PathBuf>>,
340 cache_root: Option<&Path>,
341) -> DuplicationRun {
342 let _span = tracing::info_span!("find_duplicates").entered();
343
344 let extra_ignores = build_ignore_set(config);
345 let default_skip_counts = extra_ignores
346 .as_ref()
347 .map(|ignores| {
348 std::iter::repeat_with(|| AtomicUsize::new(0))
349 .take(ignores.defaults.len())
350 .collect::<Vec<_>>()
351 })
352 .unwrap_or_default();
353
354 let (file_data, corpus_totals) = tokenize_corpus_for_duplicates(
355 root,
356 files,
357 config,
358 extra_ignores.as_ref(),
359 &default_skip_counts,
360 cache_root,
361 );
362
363 let report = detect_and_postprocess(root, config, file_data, corpus_totals, focus_files);
364
365 let default_ignore_skips =
366 build_default_ignore_skips(extra_ignores.as_ref(), &default_skip_counts);
367
368 DuplicationRun {
369 report,
370 default_ignore_skips,
371 }
372}
373
374fn tokenize_duplication_files(
375 files: &[DiscoveredFile],
376 ctx: &DuplicationTokenizeContext<'_>,
377) -> Vec<TokenizedFile> {
378 files
379 .par_iter()
380 .filter_map(|file| tokenize_duplication_file(file, ctx))
381 .collect()
382}
383
384fn tokenize_duplication_file(
385 file: &DiscoveredFile,
386 ctx: &DuplicationTokenizeContext<'_>,
387) -> Option<TokenizedFile> {
388 if should_skip_duplicate_file(file, ctx) {
389 return None;
390 }
391
392 let metadata = std::fs::metadata(&file.path).ok()?;
393 let cached_entry = ctx
394 .token_cache
395 .and_then(|cache| cache.get(&file.path, &metadata, ctx.token_cache_mode));
396 let cache_hit = cached_entry.is_some();
397 let (mut entry, suppressions) = duplication_token_cache_entry(file, ctx, cached_entry)?;
398 if entry.file_tokens.tokens.is_empty() || entry.hashed_tokens.len() < ctx.config.min_tokens {
399 return None;
400 }
401
402 Some(TokenizedFile {
403 path: file.path.clone(),
404 hashed_tokens: std::mem::take(&mut entry.hashed_tokens),
405 file_tokens: entry.file_tokens,
406 metadata: Some(metadata),
407 cache_hit,
408 suppressions,
409 })
410}
411
412fn should_skip_duplicate_file(file: &DiscoveredFile, ctx: &DuplicationTokenizeContext<'_>) -> bool {
413 let relative = file.path.strip_prefix(ctx.root).unwrap_or(&file.path);
414 let Some(ignores) = ctx.extra_ignores else {
415 return false;
416 };
417 if let Some(index) = ignores.default_match_index(relative) {
418 ctx.default_skip_counts[index].fetch_add(1, Ordering::Relaxed);
419 return true;
420 }
421 ignores.is_match(relative)
422}
423
424fn duplication_token_cache_entry(
425 file: &DiscoveredFile,
426 ctx: &DuplicationTokenizeContext<'_>,
427 cached_entry: Option<TokenCacheEntry>,
428) -> Option<(TokenCacheEntry, Vec<Suppression>)> {
429 if let Some(entry) = cached_entry {
430 let suppressions = entry.suppressions.clone();
431 if suppress::is_file_suppressed(&suppressions, IssueKind::CodeDuplication) {
432 return None;
433 }
434 return Some((entry, suppressions));
435 }
436
437 let source = std::fs::read_to_string(&file.path).ok()?;
438 let suppressions = suppress::parse_suppressions_from_source(&source).suppressions;
439 if suppress::is_file_suppressed(&suppressions, IssueKind::CodeDuplication) {
440 return None;
441 }
442 let file_tokens = tokenize_duplication_source(file, ctx, &source);
443 if file_tokens.tokens.is_empty() {
444 return None;
445 }
446 let hashed_tokens = normalize_and_hash_resolved(&file_tokens.tokens, ctx.normalization);
447 Some((
448 TokenCacheEntry {
449 hashed_tokens,
450 file_tokens,
451 suppressions: suppressions.clone(),
452 },
453 suppressions,
454 ))
455}
456
457fn tokenize_duplication_source(
458 file: &DiscoveredFile,
459 ctx: &DuplicationTokenizeContext<'_>,
460 source: &str,
461) -> tokenize::FileTokens {
462 if ctx.strip_types {
463 tokenize_file_cross_language(&file.path, source, true, ctx.skip_imports)
464 } else {
465 tokenize_file(&file.path, source, ctx.skip_imports)
466 }
467}
468
469fn save_duplication_token_cache(
470 cache_root: &Path,
471 mut cache: TokenCache,
472 files: &[DiscoveredFile],
473 file_data: &[TokenizedFile],
474 mode: TokenCacheMode,
475) {
476 for file in file_data {
477 if !file.cache_hit
478 && let Some(metadata) = &file.metadata
479 {
480 cache.insert(
481 &file.path,
482 metadata,
483 mode,
484 &cache::TokenPayload {
485 hashed_tokens: &file.hashed_tokens,
486 file_tokens: &file.file_tokens,
487 suppressions: &file.suppressions,
488 },
489 );
490 }
491 }
492 cache.retain_paths(files);
493 match cache.save_if_dirty() {
494 Ok(true) => {
495 tracing::debug!(cache_root = %cache_root.display(), "saved duplication token cache");
496 }
497 Ok(false) => {
498 tracing::debug!(cache_root = %cache_root.display(), "duplication token cache unchanged");
499 }
500 Err(err) => tracing::warn!("Failed to save duplication token cache: {err}"),
501 }
502}
503
504fn apply_min_occurrences_filter(report: &mut DuplicationReport, min: usize) {
519 if min <= 2 {
520 return;
521 }
522 let before = report.clone_groups.len();
523 report
524 .clone_groups
525 .retain(|group| group.instances.len() >= min);
526 let hidden = before - report.clone_groups.len();
527 if hidden == 0 {
528 return;
529 }
530 report.stats.clone_groups_below_min_occurrences = hidden;
531 report.stats.clone_groups = report.clone_groups.len();
532 report.stats.clone_instances = report.clone_groups.iter().map(|g| g.instances.len()).sum();
533}
534
535#[expect(
537 clippy::cast_possible_truncation,
538 reason = "line numbers are bounded by source size"
539)]
540fn apply_line_suppressions(
541 report: &mut DuplicationReport,
542 suppressions_by_file: &FxHashMap<PathBuf, Vec<Suppression>>,
543) {
544 report.clone_groups.retain_mut(|group| {
545 group.instances.retain(|instance| {
546 if let Some(supps) = suppressions_by_file.get(&instance.file) {
547 for line in instance.start_line..=instance.end_line {
548 if suppress::is_suppressed(supps, line as u32, IssueKind::CodeDuplication) {
549 return false;
550 }
551 }
552 }
553 true
554 });
555 group.instances.len() >= 2
556 });
557}
558
559#[must_use]
563pub fn find_duplicates_in_project(root: &Path, config: &DuplicatesConfig) -> DuplicationReport {
564 let resolved = crate::default_config(root);
565 let files = discover::discover_files_with_plugin_scopes(&resolved);
566 find_duplicates(root, &files, config)
567}
568
569#[expect(
571 clippy::expect_used,
572 reason = "duplicate ignore globs are validated before clone detection"
573)]
574fn build_ignore_set(config: &DuplicatesConfig) -> Option<IgnoreSet> {
575 if !config.ignore_defaults && config.ignore.is_empty() {
576 return None;
577 }
578
579 let mut builder = GlobSetBuilder::new();
580 let mut defaults = Vec::new();
581
582 if config.ignore_defaults {
583 for pattern in DUPES_DEFAULT_IGNORES {
584 let glob = Glob::new(pattern).expect("default duplication ignore pattern is valid");
585 defaults.push((*pattern, glob.compile_matcher()));
586 builder.add(glob);
587 }
588 }
589
590 for pattern in &config.ignore {
591 builder.add(
592 Glob::new(pattern)
593 .expect("duplicates.ignore pattern was validated at config load time"),
594 );
595 }
596
597 builder.build().ok().map(|all| IgnoreSet { all, defaults })
598}
599
600fn build_default_ignore_skips(
601 ignores: Option<&IgnoreSet>,
602 counts: &[AtomicUsize],
603) -> DefaultIgnoreSkips {
604 let Some(ignores) = ignores else {
605 return DefaultIgnoreSkips::default();
606 };
607
608 let by_pattern = ignores
609 .defaults
610 .iter()
611 .zip(counts)
612 .filter_map(|((pattern, _), count)| {
613 let count = count.load(Ordering::Relaxed);
614 (count > 0).then_some(DefaultIgnoreSkipCount { pattern, count })
615 })
616 .collect::<Vec<_>>();
617 let total = by_pattern.iter().map(|entry| entry.count).sum();
618
619 DefaultIgnoreSkips { total, by_pattern }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use crate::discover::FileId;
626
627 #[test]
628 fn find_duplicates_empty_files() {
629 let config = DuplicatesConfig::default();
630 let report = find_duplicates(Path::new("/tmp"), &[], &config);
631 assert!(report.clone_groups.is_empty());
632 assert!(report.clone_families.is_empty());
633 assert_eq!(report.stats.total_files, 0);
634 }
635
636 #[test]
637 fn build_ignore_set_empty() {
638 let config = DuplicatesConfig {
639 ignore_defaults: false,
640 ..DuplicatesConfig::default()
641 };
642 assert!(build_ignore_set(&config).is_none());
643 }
644
645 #[test]
646 fn build_ignore_set_valid_patterns() {
647 let config = DuplicatesConfig {
648 ignore_defaults: false,
649 ignore: vec!["**/*.test.ts".to_string(), "**/*.spec.ts".to_string()],
650 ..DuplicatesConfig::default()
651 };
652 let set = build_ignore_set(&config);
653 assert!(set.is_some());
654 let set = set.unwrap();
655 assert!(set.is_match(Path::new("src/foo.test.ts")));
656 assert!(set.is_match(Path::new("src/bar.spec.ts")));
657 assert!(!set.is_match(Path::new("src/baz.ts")));
658 }
659
660 #[test]
661 fn build_ignore_set_merges_defaults_with_user_patterns() {
662 let config = DuplicatesConfig {
663 ignore: vec!["**/foo/**".to_string()],
664 ..DuplicatesConfig::default()
665 };
666 let set = build_ignore_set(&config).expect("ignore set");
667 assert!(set.is_match(Path::new(".next/static/chunks/app.js")));
668 assert!(set.is_match(Path::new("src/foo/generated.js")));
669 }
670
671 #[test]
672 fn build_ignore_set_ignore_defaults_false_uses_only_user_patterns() {
673 let config = DuplicatesConfig {
674 ignore_defaults: false,
675 ignore: vec!["**/foo/**".to_string()],
676 ..DuplicatesConfig::default()
677 };
678 let set = build_ignore_set(&config).expect("ignore set");
679 assert!(!set.is_match(Path::new(".next/static/chunks/app.js")));
680 assert!(set.is_match(Path::new("src/foo/generated.js")));
681 }
682
683 #[test]
684 fn find_duplicates_with_real_files() {
685 let dir = tempfile::tempdir().expect("create temp dir");
686 let src_dir = dir.path().join("src");
687 std::fs::create_dir_all(&src_dir).expect("create src dir");
688
689 let code = r#"
690export function processData(input: string): string {
691 const trimmed = input.trim();
692 if (trimmed.length === 0) {
693 return "";
694 }
695 const parts = trimmed.split(",");
696 const filtered = parts.filter(p => p.length > 0);
697 const mapped = filtered.map(p => p.toUpperCase());
698 return mapped.join(", ");
699}
700
701export function validateInput(data: string): boolean {
702 if (data === null || data === undefined) {
703 return false;
704 }
705 const cleaned = data.trim();
706 if (cleaned.length < 3) {
707 return false;
708 }
709 return true;
710}
711"#;
712
713 std::fs::write(src_dir.join("original.ts"), code).expect("write original");
714 std::fs::write(src_dir.join("copy.ts"), code).expect("write copy");
715 std::fs::write(dir.path().join("package.json"), r#"{"name": "test"}"#)
716 .expect("write package.json");
717
718 let files = vec![
719 DiscoveredFile {
720 id: FileId(0),
721 path: src_dir.join("original.ts"),
722 size_bytes: code.len() as u64,
723 },
724 DiscoveredFile {
725 id: FileId(1),
726 path: src_dir.join("copy.ts"),
727 size_bytes: code.len() as u64,
728 },
729 ];
730
731 let config = DuplicatesConfig {
732 min_tokens: 10,
733 min_lines: 2,
734 ..DuplicatesConfig::default()
735 };
736
737 let report = find_duplicates(dir.path(), &files, &config);
738 assert!(
739 !report.clone_groups.is_empty(),
740 "Should detect clones in identical files"
741 );
742 assert!(report.stats.files_with_clones >= 2);
743
744 assert!(
745 !report.clone_families.is_empty(),
746 "Should group clones into families"
747 );
748 }
749
750 #[test]
751 fn global_shingle_prefilter_preserves_corpus_totals() {
752 let dir = tempfile::tempdir().expect("create temp dir");
753 let src_dir = dir.path().join("src");
754 std::fs::create_dir_all(&src_dir).expect("create src dir");
755
756 let duplicated = r#"
757export function normalizeUser(input: string): string {
758 const trimmed = input.trim();
759 const lowered = trimmed.toLowerCase();
760 const compact = lowered.replaceAll(" ", "-");
761 return compact;
762}
763"#;
764 let unique = r#"
765export function renderInvoice(id: string): string {
766 const prefix = "invoice";
767 const suffix = id.padStart(6, "0");
768 return `${prefix}:${suffix}`;
769}
770"#;
771
772 let original_path = src_dir.join("original.ts");
773 let copy_path = src_dir.join("copy.ts");
774 let unique_path = src_dir.join("unique.ts");
775 std::fs::write(&original_path, duplicated).expect("write original");
776 std::fs::write(©_path, duplicated).expect("write copy");
777 std::fs::write(&unique_path, unique).expect("write unique");
778
779 let files = vec![
780 DiscoveredFile {
781 id: FileId(0),
782 path: original_path,
783 size_bytes: duplicated.len() as u64,
784 },
785 DiscoveredFile {
786 id: FileId(1),
787 path: copy_path,
788 size_bytes: duplicated.len() as u64,
789 },
790 DiscoveredFile {
791 id: FileId(2),
792 path: unique_path,
793 size_bytes: unique.len() as u64,
794 },
795 ];
796 let config = DuplicatesConfig {
797 min_tokens: 5,
798 min_lines: 2,
799 min_corpus_size_for_shingle_filter: 1,
800 ..DuplicatesConfig::default()
801 };
802
803 let report = find_duplicates(dir.path(), &files, &config);
804
805 assert!(!report.clone_groups.is_empty());
806 assert_eq!(report.stats.total_files, 3);
807 assert!(report.stats.total_tokens > report.stats.duplicated_tokens);
808 }
809
810 #[test]
811 fn find_duplicates_cached_skips_token_cache_for_small_corpus() {
812 let dir = tempfile::tempdir().expect("create temp dir");
813 let src_dir = dir.path().join("src");
814 std::fs::create_dir_all(&src_dir).expect("create src dir");
815
816 let code = "export function same(input: number): number {\n const doubled = input * 2;\n return doubled + 1;\n}\n";
817 let first = src_dir.join("first.ts");
818 let second = src_dir.join("second.ts");
819 std::fs::write(&first, code).expect("write first");
820 std::fs::write(&second, code).expect("write second");
821
822 let files = vec![
823 DiscoveredFile {
824 id: FileId(0),
825 path: first,
826 size_bytes: code.len() as u64,
827 },
828 DiscoveredFile {
829 id: FileId(1),
830 path: second,
831 size_bytes: code.len() as u64,
832 },
833 ];
834 let config = DuplicatesConfig {
835 min_tokens: 5,
836 min_lines: 2,
837 ..DuplicatesConfig::default()
838 };
839 let cache_root = dir.path().join(".fallow");
840
841 let report = find_duplicates_cached(dir.path(), &files, &config, &cache_root);
842
843 assert!(!report.clone_groups.is_empty());
844 assert!(
845 !cache_root.exists(),
846 "small projects should avoid token-cache IO overhead"
847 );
848 }
849
850 #[test]
851 fn find_duplicates_touching_files_keeps_cross_corpus_matches_only_for_focus() {
852 let dir = tempfile::tempdir().expect("create temp dir");
853 let src_dir = dir.path().join("src");
854 std::fs::create_dir_all(&src_dir).expect("create src dir");
855
856 let focused_code = r"
857export function focused(input: number): number {
858 const doubled = input * 2;
859 const shifted = doubled + 10;
860 return shifted / 2;
861}
862";
863 let untouched_code = r#"
864export function untouched(input: string): string {
865 const lowered = input.toLowerCase();
866 const padded = lowered.padStart(10, "x");
867 return padded.slice(0, 8);
868}
869"#;
870
871 let changed_path = src_dir.join("changed.ts");
872 let focused_copy_path = src_dir.join("focused-copy.ts");
873 let untouched_a_path = src_dir.join("untouched-a.ts");
874 let untouched_b_path = src_dir.join("untouched-b.ts");
875 std::fs::write(&changed_path, focused_code).expect("write changed");
876 std::fs::write(&focused_copy_path, focused_code).expect("write focused copy");
877 std::fs::write(&untouched_a_path, untouched_code).expect("write untouched a");
878 std::fs::write(&untouched_b_path, untouched_code).expect("write untouched b");
879
880 let files = vec![
881 DiscoveredFile {
882 id: FileId(0),
883 path: changed_path.clone(),
884 size_bytes: focused_code.len() as u64,
885 },
886 DiscoveredFile {
887 id: FileId(1),
888 path: focused_copy_path,
889 size_bytes: focused_code.len() as u64,
890 },
891 DiscoveredFile {
892 id: FileId(2),
893 path: untouched_a_path,
894 size_bytes: untouched_code.len() as u64,
895 },
896 DiscoveredFile {
897 id: FileId(3),
898 path: untouched_b_path,
899 size_bytes: untouched_code.len() as u64,
900 },
901 ];
902
903 let config = DuplicatesConfig {
904 mode: DetectionMode::Strict,
905 min_tokens: 5,
906 min_lines: 2,
907 min_corpus_size_for_shingle_filter: 1,
908 ..DuplicatesConfig::default()
909 };
910 let mut focus = FxHashSet::default();
911 focus.insert(changed_path.clone());
912
913 let full_report = find_duplicates(dir.path(), &files, &config);
914 let report = find_duplicates_touching_files(dir.path(), &files, &config, &focus);
915 let expected_touching = full_report
916 .clone_groups
917 .iter()
918 .filter(|group| {
919 group
920 .instances
921 .iter()
922 .any(|instance| instance.file == changed_path)
923 })
924 .count();
925
926 assert!(
927 !report.clone_groups.is_empty(),
928 "focused file should still match an unchanged duplicate"
929 );
930 assert_eq!(
931 report.clone_groups.len(),
932 expected_touching,
933 "focused shingle filtering must not drop clone groups touching the focused file"
934 );
935 assert!(report.clone_groups.iter().all(|group| {
936 group
937 .instances
938 .iter()
939 .any(|instance| instance.file == changed_path)
940 }));
941 }
942
943 #[test]
944 fn file_wide_suppression_excludes_file() {
945 let dir = tempfile::tempdir().expect("create temp dir");
946 let src_dir = dir.path().join("src");
947 std::fs::create_dir_all(&src_dir).expect("create src dir");
948
949 let code = r#"
950export function processData(input: string): string {
951 const trimmed = input.trim();
952 if (trimmed.length === 0) {
953 return "";
954 }
955 const parts = trimmed.split(",");
956 const filtered = parts.filter(p => p.length > 0);
957 const mapped = filtered.map(p => p.toUpperCase());
958 return mapped.join(", ");
959}
960"#;
961 let suppressed_code = format!("// fallow-ignore-file code-duplication\n{code}");
962
963 std::fs::write(src_dir.join("original.ts"), code).expect("write original");
964 std::fs::write(src_dir.join("suppressed.ts"), &suppressed_code).expect("write suppressed");
965 std::fs::write(dir.path().join("package.json"), r#"{"name": "test"}"#)
966 .expect("write package.json");
967
968 let files = vec![
969 DiscoveredFile {
970 id: FileId(0),
971 path: src_dir.join("original.ts"),
972 size_bytes: code.len() as u64,
973 },
974 DiscoveredFile {
975 id: FileId(1),
976 path: src_dir.join("suppressed.ts"),
977 size_bytes: suppressed_code.len() as u64,
978 },
979 ];
980
981 let config = DuplicatesConfig {
982 min_tokens: 10,
983 min_lines: 2,
984 ..DuplicatesConfig::default()
985 };
986
987 let report = find_duplicates(dir.path(), &files, &config);
988 assert!(
989 report.clone_groups.is_empty(),
990 "File-wide suppression should exclude file from duplication analysis"
991 );
992 }
993
994 #[test]
995 #[expect(
996 clippy::too_many_lines,
997 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
998 )]
999 fn min_occurrences_hides_pairs_and_records_count() {
1000 let dir = tempfile::tempdir().expect("create temp dir");
1001 let src_dir = dir.path().join("src");
1002 std::fs::create_dir_all(&src_dir).expect("create src dir");
1003
1004 let block_a = r#"
1005export function blockA(input: string): string {
1006 const trimmed = input.trim();
1007 if (trimmed.length === 0) {
1008 return "";
1009 }
1010 const parts = trimmed.split(",");
1011 const filtered = parts.filter(p => p.length > 0);
1012 const mapped = filtered.map(p => p.toUpperCase());
1013 return mapped.join(", ");
1014}
1015"#;
1016 let block_b = r"
1017export function blockB(value: number): number {
1018 if (value <= 0) {
1019 return 0;
1020 }
1021 let total = 0;
1022 for (let i = 1; i <= value; i += 1) {
1023 total += i * 2;
1024 total -= 1;
1025 }
1026 return total + 7;
1027}
1028";
1029
1030 let pair_a1 = src_dir.join("pair-a1.ts");
1031 let pair_a2 = src_dir.join("pair-a2.ts");
1032 let triple_b1 = src_dir.join("triple-b1.ts");
1033 let triple_b2 = src_dir.join("triple-b2.ts");
1034 let triple_b3 = src_dir.join("triple-b3.ts");
1035 std::fs::write(&pair_a1, block_a).expect("write");
1036 std::fs::write(&pair_a2, block_a).expect("write");
1037 std::fs::write(&triple_b1, block_b).expect("write");
1038 std::fs::write(&triple_b2, block_b).expect("write");
1039 std::fs::write(&triple_b3, block_b).expect("write");
1040
1041 let files = vec![
1042 DiscoveredFile {
1043 id: FileId(0),
1044 path: pair_a1,
1045 size_bytes: block_a.len() as u64,
1046 },
1047 DiscoveredFile {
1048 id: FileId(1),
1049 path: pair_a2,
1050 size_bytes: block_a.len() as u64,
1051 },
1052 DiscoveredFile {
1053 id: FileId(2),
1054 path: triple_b1,
1055 size_bytes: block_b.len() as u64,
1056 },
1057 DiscoveredFile {
1058 id: FileId(3),
1059 path: triple_b2,
1060 size_bytes: block_b.len() as u64,
1061 },
1062 DiscoveredFile {
1063 id: FileId(4),
1064 path: triple_b3,
1065 size_bytes: block_b.len() as u64,
1066 },
1067 ];
1068
1069 let default_config = DuplicatesConfig {
1070 min_tokens: 10,
1071 min_lines: 2,
1072 ..DuplicatesConfig::default()
1073 };
1074 let baseline = find_duplicates(dir.path(), &files, &default_config);
1075 assert_eq!(
1076 baseline.clone_groups.len(),
1077 2,
1078 "default minOccurrences should report both the pair and the triple"
1079 );
1080 assert_eq!(
1081 baseline.stats.clone_groups_below_min_occurrences, 0,
1082 "default minOccurrences hides nothing"
1083 );
1084 let baseline_pct = baseline.stats.duplication_percentage;
1085
1086 let raised_config = DuplicatesConfig {
1087 min_tokens: 10,
1088 min_lines: 2,
1089 min_occurrences: 3,
1090 ..DuplicatesConfig::default()
1091 };
1092 let report = find_duplicates(dir.path(), &files, &raised_config);
1093 assert_eq!(
1094 report.clone_groups.len(),
1095 1,
1096 "minOccurrences=3 should hide the 2-instance group"
1097 );
1098 assert_eq!(
1099 report.clone_groups[0].instances.len(),
1100 3,
1101 "surviving group must be the 3-instance group"
1102 );
1103 assert_eq!(
1104 report.stats.clone_groups_below_min_occurrences, 1,
1105 "the hidden 2-instance group must be counted"
1106 );
1107 assert_eq!(
1108 report.stats.clone_groups, 1,
1109 "stats.clone_groups must match the post-filter array length"
1110 );
1111 assert_eq!(
1112 report.stats.clone_instances, 3,
1113 "stats.clone_instances must match the surviving instance total"
1114 );
1115 assert!(
1116 (report.stats.duplication_percentage - baseline_pct).abs() < f64::EPSILON,
1117 "duplication_percentage should not shift when minOccurrences changes"
1118 );
1119 }
1120
1121 #[test]
1122 fn min_occurrences_evaluates_after_line_suppressions() {
1123 let dir = tempfile::tempdir().expect("create temp dir");
1124 let src_dir = dir.path().join("src");
1125 std::fs::create_dir_all(&src_dir).expect("create src dir");
1126
1127 let block = r#"
1128export function shared(input: string): string {
1129 const trimmed = input.trim();
1130 if (trimmed.length === 0) {
1131 return "";
1132 }
1133 const parts = trimmed.split(",");
1134 const filtered = parts.filter(p => p.length > 0);
1135 const mapped = filtered.map(p => p.toUpperCase());
1136 return mapped.join(", ");
1137}
1138"#;
1139 let suppressed = format!("// fallow-ignore-file code-duplication\n{block}");
1140
1141 let a = src_dir.join("a.ts");
1142 let b = src_dir.join("b.ts");
1143 let c = src_dir.join("c.ts");
1144 std::fs::write(&a, block).expect("write a");
1145 std::fs::write(&b, block).expect("write b");
1146 std::fs::write(&c, &suppressed).expect("write c");
1147
1148 let files = vec![
1149 DiscoveredFile {
1150 id: FileId(0),
1151 path: a,
1152 size_bytes: block.len() as u64,
1153 },
1154 DiscoveredFile {
1155 id: FileId(1),
1156 path: b,
1157 size_bytes: block.len() as u64,
1158 },
1159 DiscoveredFile {
1160 id: FileId(2),
1161 path: c,
1162 size_bytes: suppressed.len() as u64,
1163 },
1164 ];
1165
1166 let config = DuplicatesConfig {
1167 min_tokens: 10,
1168 min_lines: 2,
1169 min_occurrences: 3,
1170 ..DuplicatesConfig::default()
1171 };
1172 let report = find_duplicates(dir.path(), &files, &config);
1173 assert!(
1174 report.clone_groups.is_empty(),
1175 "post-suppression 2-instance group must be hidden by minOccurrences=3, \
1176 got groups: {:?}",
1177 report
1178 .clone_groups
1179 .iter()
1180 .map(|g| g.instances.len())
1181 .collect::<Vec<_>>()
1182 );
1183 assert_eq!(
1184 report.stats.clone_groups, 0,
1185 "stats.clone_groups must match the empty post-filter array"
1186 );
1187 assert_eq!(
1188 report.stats.clone_instances, 0,
1189 "stats.clone_instances must match the empty post-filter array"
1190 );
1191 }
1192}