Skip to main content

fallow_core/duplicates/
mod.rs

1//! Code duplication / clone detection module.
2//!
3//! This module implements suffix array + LCP based clone detection
4//! for TypeScript/JavaScript source files. It supports multiple detection
5//! modes from strict (exact matches only) to semantic (structure-aware
6//! matching that ignores identifier names and literal values).
7
8mod 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
44/// Built-in duplicates ignores for generated framework and tool output.
45///
46/// These are engine policy defaults, not config-file defaults: `duplicates.ignore`
47/// stays empty in round-tripped configs, while the analyzer merges these patterns
48/// unless `duplicates.ignoreDefaults` is set to `false`.
49pub 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
105/// Run duplication detection on the given files.
106///
107/// This is the main entry point for the duplication analysis. It:
108/// 1. Reads and tokenizes all source files in parallel
109/// 2. Normalizes tokens according to the detection mode
110/// 3. Runs suffix array + LCP clone detection
111/// 4. Groups clone instances into families with refactoring suggestions
112/// 5. Applies inline suppression filters
113pub 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
121/// Run duplication detection and return human-format sidecar metadata for
122/// files skipped by built-in duplicates ignores.
123pub 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
132/// Run duplication detection with the persistent token cache enabled.
133pub 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
142/// Run cached duplication detection and return human-format sidecar metadata for
143/// files skipped by built-in duplicates ignores.
144pub 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/// Run duplication detection and only return clone groups touching `focus_files`.
155///
156/// This keeps all files in the matching corpus, which preserves changed-file
157/// versus unchanged-file detection for diff-scoped audit runs, but avoids
158/// materializing duplicate groups that cannot appear in the scoped report.
159#[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/// Run focused duplication detection and return human-format sidecar metadata
173/// for files skipped by built-in duplicates ignores.
174#[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/// Run focused duplication detection with the persistent token cache enabled.
189#[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/// Run cached focused duplication detection and return human-format sidecar
204/// metadata for files skipped by built-in duplicates ignores.
205#[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
220/// Tokenize the corpus for duplication detection: resolves normalization and
221/// cache config, tokenizes files (writing the token cache when enabled), and
222/// returns the per-file token data alongside the corpus totals.
223fn 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
281/// Run clone detection over tokenized files, then apply suppression and
282/// min-occurrence filters, family grouping, mirrored-directory detection, and
283/// final sorting.
284fn 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
504/// Drop clone groups with fewer than `min` instances and record the count on
505/// the stats block. The detector already guarantees `>= 2`, so this is a
506/// no-op when `min <= 2`.
507///
508/// Stats split: `clone_groups` and `clone_instances` are recomputed
509/// post-filter so they match the serialized array length (a CI consumer
510/// reading `stats.clone_groups` and iterating `clone_groups[]` sees the same
511/// count). `duplication_percentage`, `duplicated_lines`, `duplicated_tokens`,
512/// and `files_with_clones` stay pre-filter so the percentage math (lines /
513/// total) stays consistent and `threshold` gates / trend lines don't shift
514/// when the filter changes. The hidden count is disclosed in
515/// `clone_groups_below_min_occurrences`. The surviving groups feed every
516/// downstream step (families, mirrored dirs, --top, baseline, changed-since,
517/// workspace scoping) so there's a single source of truth.
518fn 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/// Filter out clone instances that are suppressed by line-level comments.
536#[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/// Run duplication detection on a project directory using auto-discovered files.
560///
561/// This is a convenience function that handles file discovery internally.
562#[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/// Build a merged ignore set from built-in and user-provided duplicates ignores.
570#[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(&copy_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}