Skip to main content

fallow_config/config/
duplicates_config.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4const fn default_true() -> bool {
5    true
6}
7
8const fn default_min_tokens() -> usize {
9    50
10}
11
12const fn default_min_lines() -> usize {
13    5
14}
15
16const fn default_min_occurrences() -> usize {
17    2
18}
19
20/// Reject `< 2` at deserialize time. A single occurrence isn't a duplicate;
21/// silently clamping would poison reproducibility across config / env / CLI
22/// override sources.
23fn deserialize_min_occurrences<'de, D>(deserializer: D) -> Result<usize, D::Error>
24where
25    D: Deserializer<'de>,
26{
27    let value = usize::deserialize(deserializer)?;
28    if value < 2 {
29        return Err(serde::de::Error::custom(format!(
30            "minOccurrences must be at least 2 (got {value}); a single occurrence isn't a duplicate"
31        )));
32    }
33    Ok(value)
34}
35
36fn deserialize_ignored_clones<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
37where
38    D: Deserializer<'de>,
39{
40    let values = Vec::<String>::deserialize(deserializer)?;
41    for (index, value) in values.iter().enumerate() {
42        if !is_valid_ignored_clone_key(value) {
43            return Err(serde::de::Error::custom(format!(
44                "ignoredClones[{index}] must use <fingerprint>:<instance_count> (for example dup:6f12ab34:2); got {value:?}"
45            )));
46        }
47    }
48    Ok(values)
49}
50
51fn is_valid_ignored_clone_key(value: &str) -> bool {
52    let Some((fingerprint, count)) = value.rsplit_once(':') else {
53        return false;
54    };
55    if count.is_empty() || count.starts_with('0') {
56        return false;
57    }
58    let Ok(count) = count.parse::<usize>() else {
59        return false;
60    };
61    if count < 2 {
62        return false;
63    }
64
65    let Some(identifier) = fingerprint.strip_prefix("dup:") else {
66        return false;
67    };
68    let (hex, suffix) = identifier
69        .split_once('-')
70        .map_or((identifier, None), |(hex, suffix)| (hex, Some(suffix)));
71    if !matches!(hex.len(), 8 | 16)
72        || !hex
73            .as_bytes()
74            .iter()
75            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
76    {
77        return false;
78    }
79
80    match suffix {
81        None => true,
82        Some(suffix) => {
83            let ordinal = suffix.strip_prefix('r').unwrap_or(suffix);
84            hex.len() == 16
85                && !ordinal.starts_with('0')
86                && ordinal.bytes().all(|byte| byte.is_ascii_digit())
87                && ordinal.parse::<usize>().is_ok_and(|ordinal| ordinal > 0)
88        }
89    }
90}
91
92const fn default_min_corpus_size_for_shingle_filter() -> usize {
93    1024
94}
95
96const fn default_min_corpus_size_for_token_cache() -> usize {
97    5_000
98}
99
100/// Configuration for code duplication detection.
101#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
102#[serde(rename_all = "camelCase")]
103pub struct DuplicatesConfig {
104    /// Whether duplication detection is enabled.
105    #[serde(default = "default_true")]
106    pub enabled: bool,
107
108    /// Detection mode: strict, mild, weak, or semantic.
109    #[serde(default)]
110    pub mode: DetectionMode,
111
112    /// Detect structurally similar function bodies with small edits.
113    #[serde(default)]
114    pub near: bool,
115
116    /// Minimum number of tokens for a clone.
117    #[serde(default = "default_min_tokens")]
118    pub min_tokens: usize,
119
120    /// Minimum number of lines for a clone.
121    #[serde(default = "default_min_lines")]
122    pub min_lines: usize,
123
124    /// Minimum number of occurrences (instances of the same clone) before a
125    /// group is reported. Defaults to 2 (every duplicated pair is reported).
126    /// Raise this to focus on widespread copy-paste worth refactoring and skip
127    /// context-sensitive pairs.
128    #[serde(
129        default = "default_min_occurrences",
130        deserialize_with = "deserialize_min_occurrences"
131    )]
132    #[schemars(range(min = 2))]
133    pub min_occurrences: usize,
134
135    /// Maximum allowed duplication percentage (0 = no limit).
136    #[serde(default)]
137    pub threshold: f64,
138
139    /// Additional ignore patterns for duplication analysis.
140    /// A single `*` stays within one path segment; use `**` to match across directories.
141    #[serde(default)]
142    pub ignore: Vec<String>,
143
144    /// Reviewed clone groups to omit from duplication results.
145    ///
146    /// Each entry is `<fingerprint>:<instance_count>`, for example
147    /// `dup:6f12ab34:2`. A content or occurrence-count change produces a new key
148    /// and makes the group reportable again.
149    #[serde(default, deserialize_with = "deserialize_ignored_clones")]
150    #[schemars(inner(regex(
151        pattern = r"^dup:(?:[0-9a-f]{8}|[0-9a-f]{16}(?:-r?[1-9][0-9]*)?):(?:[2-9]|[1-9][0-9]+)$"
152    )))]
153    pub ignored_clones: Vec<String>,
154
155    /// Merge built-in generated-framework ignore patterns with `ignore`.
156    ///
157    /// Set to `false` to use only the user-provided `ignore` list.
158    #[serde(default = "default_true")]
159    pub ignore_defaults: bool,
160
161    /// Only report cross-directory duplicates.
162    #[serde(default)]
163    pub skip_local: bool,
164
165    /// Omit clone instances whose file path is a symlink, or lies under a
166    /// symlinked directory, inside the project root.
167    ///
168    /// Defaults to `false`: symlinked instances stay in the report and carry
169    /// `is_symlink: true` in JSON output. Set to `true` to report only
170    /// duplication between real files. A clone group with fewer than two
171    /// remaining instances is not reported. Symlinked files also leave the
172    /// corpus, so `stats` (files, lines, tokens and the duplication
173    /// percentage that `threshold` checks) count only real files.
174    #[serde(default)]
175    pub ignore_symlinks: bool,
176
177    /// Enable cross-language clone detection by stripping type annotations.
178    ///
179    /// When enabled, TypeScript type annotations (parameter types, return types,
180    /// generics, interfaces, type aliases) are stripped from the token stream,
181    /// allowing detection of clones between `.ts` and `.js` files.
182    #[serde(default)]
183    pub cross_language: bool,
184
185    /// Exclude module-wiring declarations from clone detection.
186    ///
187    /// Defaults to `true`: token-identical module wiring is a structural
188    /// property of well-formatted code, not copy-paste, so it should not
189    /// surface as clone groups. Set to `false` to count module wiring again.
190    /// When enabled, ES imports, re-export declarations, and top-level static
191    /// CommonJS `require("...")` binding declarations are stripped from the
192    /// token stream before clone detection. Dynamic imports, side-effect
193    /// `require()` calls, nested `require()` calls, dynamic require arguments,
194    /// and mixed declarations are still counted.
195    #[serde(default = "default_true")]
196    pub ignore_imports: bool,
197
198    /// Fine-grained normalization overrides on top of the detection mode.
199    #[serde(default)]
200    pub normalization: NormalizationConfig,
201
202    /// Minimum tokenized file count before focused duplicate analysis prefilters
203    /// unchanged files with k-token shingles.
204    #[serde(default = "default_min_corpus_size_for_shingle_filter")]
205    pub min_corpus_size_for_shingle_filter: usize,
206
207    /// Minimum source file count before the persistent duplication token cache
208    /// activates. Below this threshold the cache load/save overhead exceeds the
209    /// tokenize savings, so the cache stays disabled even when not running with
210    /// `--no-cache`.
211    #[serde(default = "default_min_corpus_size_for_token_cache")]
212    pub min_corpus_size_for_token_cache: usize,
213}
214
215impl Default for DuplicatesConfig {
216    fn default() -> Self {
217        Self {
218            enabled: true,
219            mode: DetectionMode::default(),
220            near: false,
221            min_tokens: default_min_tokens(),
222            min_lines: default_min_lines(),
223            min_occurrences: default_min_occurrences(),
224            threshold: 0.0,
225            ignore: vec![],
226            ignored_clones: vec![],
227            ignore_defaults: true,
228            skip_local: false,
229            ignore_symlinks: false,
230            cross_language: false,
231            ignore_imports: true,
232            normalization: NormalizationConfig::default(),
233            min_corpus_size_for_shingle_filter: default_min_corpus_size_for_shingle_filter(),
234            min_corpus_size_for_token_cache: default_min_corpus_size_for_token_cache(),
235        }
236    }
237}
238
239/// Fine-grained normalization overrides.
240///
241/// Each option, when set to `Some(true)`, forces that normalization regardless of
242/// the detection mode. When set to `Some(false)`, it forces preservation. When
243/// `None`, the detection mode's default behavior applies.
244#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
245#[serde(rename_all = "camelCase")]
246pub struct NormalizationConfig {
247    /// Blind all identifiers (variable names, function names, etc.) to the same hash.
248    /// Default in `semantic` mode.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub ignore_identifiers: Option<bool>,
251
252    /// Blind string literal values to the same hash.
253    /// Default in `weak` and `semantic` modes.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub ignore_string_values: Option<bool>,
256
257    /// Blind numeric literal values to the same hash.
258    /// Default in `semantic` mode.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub ignore_numeric_values: Option<bool>,
261}
262
263/// Resolved normalization flags: mode defaults merged with user overrides.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub struct ResolvedNormalization {
266    /// Blind all identifiers (variable, function, member names) to one hash.
267    pub ignore_identifiers: bool,
268    /// Blind string literal values to one hash.
269    pub ignore_string_values: bool,
270    /// Blind numeric literal values to one hash.
271    pub ignore_numeric_values: bool,
272}
273
274impl ResolvedNormalization {
275    /// Resolve normalization from a detection mode and optional overrides.
276    #[must_use]
277    pub fn resolve(mode: DetectionMode, overrides: &NormalizationConfig) -> Self {
278        let (default_ids, default_strings, default_numbers) = match mode {
279            DetectionMode::Strict | DetectionMode::Mild => (false, false, false),
280            DetectionMode::Weak => (false, true, false),
281            DetectionMode::Semantic => (true, true, true),
282        };
283
284        Self {
285            ignore_identifiers: overrides.ignore_identifiers.unwrap_or(default_ids),
286            ignore_string_values: overrides.ignore_string_values.unwrap_or(default_strings),
287            ignore_numeric_values: overrides.ignore_numeric_values.unwrap_or(default_numbers),
288        }
289    }
290}
291
292/// Detection mode controlling how aggressively tokens are normalized.
293///
294/// Since fallow uses AST-based tokenization (not lexer-based), whitespace and
295/// comments are inherently absent from the token stream. The `Strict` and `Mild`
296/// modes are currently equivalent. `Weak` mode additionally blinds string
297/// literals. `Semantic` mode blinds all identifiers and literal values for
298/// Type-2 (renamed variable) clone detection.
299#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
300#[serde(rename_all = "lowercase")]
301pub enum DetectionMode {
302    /// All tokens preserved including identifier names and literal values (Type-1 only).
303    Strict,
304    /// Default mode -- equivalent to strict for AST-based tokenization.
305    #[default]
306    Mild,
307    /// Blind string literal values (structure-preserving).
308    Weak,
309    /// Blind all identifiers and literal values for structural (Type-2) detection.
310    Semantic,
311}
312
313impl std::fmt::Display for DetectionMode {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        match self {
316            Self::Strict => write!(f, "strict"),
317            Self::Mild => write!(f, "mild"),
318            Self::Weak => write!(f, "weak"),
319            Self::Semantic => write!(f, "semantic"),
320        }
321    }
322}
323
324impl std::str::FromStr for DetectionMode {
325    type Err = String;
326
327    fn from_str(s: &str) -> Result<Self, Self::Err> {
328        match s.to_lowercase().as_str() {
329            "strict" => Ok(Self::Strict),
330            "mild" => Ok(Self::Mild),
331            "weak" => Ok(Self::Weak),
332            "semantic" => Ok(Self::Semantic),
333            other => Err(format!("unknown detection mode: '{other}'")),
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn duplicates_config_defaults() {
344        let config = DuplicatesConfig::default();
345        assert!(config.enabled);
346        assert_eq!(config.mode, DetectionMode::Mild);
347        assert_eq!(config.min_tokens, 50);
348        assert_eq!(config.min_lines, 5);
349        assert_eq!(config.min_occurrences, 2);
350        assert!((config.threshold - 0.0).abs() < f64::EPSILON);
351        assert!(config.ignore.is_empty());
352        assert!(config.ignore_defaults);
353        assert!(!config.skip_local);
354        assert!(!config.ignore_symlinks);
355        assert!(!config.cross_language);
356        assert!(config.ignore_imports);
357        assert_eq!(config.min_corpus_size_for_shingle_filter, 1024);
358        assert_eq!(config.min_corpus_size_for_token_cache, 5_000);
359    }
360
361    #[test]
362    fn detection_mode_from_str_all_variants() {
363        assert_eq!(
364            "strict".parse::<DetectionMode>().unwrap(),
365            DetectionMode::Strict
366        );
367        assert_eq!(
368            "mild".parse::<DetectionMode>().unwrap(),
369            DetectionMode::Mild
370        );
371        assert_eq!(
372            "weak".parse::<DetectionMode>().unwrap(),
373            DetectionMode::Weak
374        );
375        assert_eq!(
376            "semantic".parse::<DetectionMode>().unwrap(),
377            DetectionMode::Semantic
378        );
379    }
380
381    #[test]
382    fn detection_mode_from_str_case_insensitive() {
383        assert_eq!(
384            "STRICT".parse::<DetectionMode>().unwrap(),
385            DetectionMode::Strict
386        );
387        assert_eq!(
388            "Weak".parse::<DetectionMode>().unwrap(),
389            DetectionMode::Weak
390        );
391        assert_eq!(
392            "SEMANTIC".parse::<DetectionMode>().unwrap(),
393            DetectionMode::Semantic
394        );
395    }
396
397    #[test]
398    fn detection_mode_from_str_unknown() {
399        let err = "foobar".parse::<DetectionMode>().unwrap_err();
400        assert!(err.contains("unknown detection mode"));
401        assert!(err.contains("foobar"));
402    }
403
404    #[test]
405    fn detection_mode_display() {
406        assert_eq!(DetectionMode::Strict.to_string(), "strict");
407        assert_eq!(DetectionMode::Mild.to_string(), "mild");
408        assert_eq!(DetectionMode::Weak.to_string(), "weak");
409        assert_eq!(DetectionMode::Semantic.to_string(), "semantic");
410    }
411
412    #[test]
413    fn resolve_strict_mode_all_false() {
414        let resolved =
415            ResolvedNormalization::resolve(DetectionMode::Strict, &NormalizationConfig::default());
416        assert!(!resolved.ignore_identifiers);
417        assert!(!resolved.ignore_string_values);
418        assert!(!resolved.ignore_numeric_values);
419    }
420
421    #[test]
422    fn resolve_mild_mode_all_false() {
423        let resolved =
424            ResolvedNormalization::resolve(DetectionMode::Mild, &NormalizationConfig::default());
425        assert!(!resolved.ignore_identifiers);
426        assert!(!resolved.ignore_string_values);
427        assert!(!resolved.ignore_numeric_values);
428    }
429
430    #[test]
431    fn resolve_weak_mode_only_strings_true() {
432        let resolved =
433            ResolvedNormalization::resolve(DetectionMode::Weak, &NormalizationConfig::default());
434        assert!(!resolved.ignore_identifiers);
435        assert!(resolved.ignore_string_values);
436        assert!(!resolved.ignore_numeric_values);
437    }
438
439    #[test]
440    fn resolve_semantic_mode_all_true() {
441        let resolved = ResolvedNormalization::resolve(
442            DetectionMode::Semantic,
443            &NormalizationConfig::default(),
444        );
445        assert!(resolved.ignore_identifiers);
446        assert!(resolved.ignore_string_values);
447        assert!(resolved.ignore_numeric_values);
448    }
449
450    #[test]
451    fn resolve_override_forces_true() {
452        let overrides = NormalizationConfig {
453            ignore_identifiers: Some(true),
454            ignore_string_values: None,
455            ignore_numeric_values: None,
456        };
457        let resolved = ResolvedNormalization::resolve(DetectionMode::Strict, &overrides);
458        assert!(resolved.ignore_identifiers);
459        assert!(!resolved.ignore_string_values);
460        assert!(!resolved.ignore_numeric_values);
461    }
462
463    #[test]
464    fn resolve_override_forces_false() {
465        let overrides = NormalizationConfig {
466            ignore_identifiers: Some(false),
467            ignore_string_values: Some(false),
468            ignore_numeric_values: None,
469        };
470        let resolved = ResolvedNormalization::resolve(DetectionMode::Semantic, &overrides);
471        assert!(!resolved.ignore_identifiers);
472        assert!(!resolved.ignore_string_values);
473        assert!(resolved.ignore_numeric_values); // not overridden
474    }
475
476    #[test]
477    fn resolve_all_overrides_on_weak() {
478        let overrides = NormalizationConfig {
479            ignore_identifiers: Some(true),
480            ignore_string_values: Some(false), // override weak default (true -> false)
481            ignore_numeric_values: Some(true),
482        };
483        let resolved = ResolvedNormalization::resolve(DetectionMode::Weak, &overrides);
484        assert!(resolved.ignore_identifiers);
485        assert!(!resolved.ignore_string_values); // overridden from true to false
486        assert!(resolved.ignore_numeric_values);
487    }
488
489    #[test]
490    fn duplicates_config_json_all_fields() {
491        let json = r#"{
492            "enabled": false,
493            "mode": "semantic",
494            "near": true,
495            "minTokens": 100,
496            "minLines": 10,
497            "minOccurrences": 3,
498            "threshold": 5.0,
499            "ignore": ["**/vendor/**"],
500            "ignoredClones": ["dup:6f12ab34:2", "dup:0123456789abcdef-1:3"],
501            "ignoreDefaults": false,
502            "skipLocal": true,
503            "crossLanguage": true,
504            "ignoreImports": true
505        }"#;
506        let config: DuplicatesConfig = serde_json::from_str(json).unwrap();
507        assert!(!config.enabled);
508        assert_eq!(config.mode, DetectionMode::Semantic);
509        assert!(config.near);
510        assert_eq!(config.min_tokens, 100);
511        assert_eq!(config.min_lines, 10);
512        assert_eq!(config.min_occurrences, 3);
513        assert!((config.threshold - 5.0).abs() < f64::EPSILON);
514        assert_eq!(config.ignore, vec!["**/vendor/**"]);
515        assert_eq!(
516            config.ignored_clones,
517            vec!["dup:6f12ab34:2", "dup:0123456789abcdef-1:3"]
518        );
519        assert!(!config.ignore_defaults);
520        assert!(config.skip_local);
521        assert!(config.cross_language);
522        assert!(config.ignore_imports);
523    }
524
525    #[test]
526    fn duplicates_config_json_partial_uses_defaults() {
527        let json = r#"{"mode": "weak"}"#;
528        let config: DuplicatesConfig = serde_json::from_str(json).unwrap();
529        assert!(config.enabled); // default
530        assert_eq!(config.mode, DetectionMode::Weak);
531        assert_eq!(config.min_tokens, 50); // default
532        assert_eq!(config.min_lines, 5); // default
533        assert!(config.ignored_clones.is_empty());
534        assert!(config.ignore_defaults);
535    }
536
537    #[test]
538    fn duplicates_config_json_ignore_defaults_merges_by_default() {
539        let json = r#"{"ignore": ["**/foo/**"]}"#;
540        let config: DuplicatesConfig = serde_json::from_str(json).unwrap();
541        assert_eq!(config.ignore, vec!["**/foo/**"]);
542        assert!(config.ignore_defaults);
543    }
544
545    #[test]
546    fn ignored_clones_accepts_corrected_and_legacy_collision_handles() {
547        let keys = ["dup:0123456789abcdef-r1:2", "dup:0123456789abcdef-1:2"];
548        let config: DuplicatesConfig = serde_json::from_value(serde_json::json!({
549            "ignoredClones": keys
550        }))
551        .expect("both collision generations remain valid config input");
552        assert_eq!(config.ignored_clones, keys);
553    }
554
555    #[test]
556    fn ignored_clones_rejects_malformed_keys() {
557        for key in [
558            "6f12ab34:2",
559            "dup:6f12ab3:2",
560            "dup:6F12AB34:2",
561            "dup:6f12ab34:1",
562            "dup:6f12ab34:02",
563            "dup:0123456789abcdef-0:2",
564            "dup:0123456789abcdef-01:2",
565            "dup:0123456789abcdef-extra:2",
566            "dup:0123456789abcdef-r:2",
567            "dup:0123456789abcdef-r0:2",
568            "dup:0123456789abcdef-r01:2",
569            "dup:0123456789abcdef-r+1:2",
570            "dup:0123456789abcdef-+1:2",
571            "dup:0123456789abcdef-rr1:2",
572            "dup:6f12ab34-r1:2",
573        ] {
574            let json = serde_json::json!({ "ignoredClones": [key] });
575            let error = serde_json::from_value::<DuplicatesConfig>(json).unwrap_err();
576            assert!(
577                error.to_string().contains("ignoredClones[0]"),
578                "unexpected error for {key}: {error}"
579            );
580        }
581    }
582
583    #[test]
584    fn ignore_imports_defaults_true_when_field_omitted() {
585        // The field-level serde default is `default_true`, NOT `bool::default()`
586        // (which would be `false`). An empty duplicates object and a config that
587        // sets only an unrelated field must both leave `ignoreImports` at `true`.
588        let empty: DuplicatesConfig = serde_json::from_str("{}").unwrap();
589        assert!(empty.ignore_imports);
590        let partial: DuplicatesConfig = serde_json::from_str(r#"{"minLines": 8}"#).unwrap();
591        assert!(partial.ignore_imports);
592    }
593
594    #[test]
595    fn ignore_imports_false_opts_out() {
596        let json: DuplicatesConfig = serde_json::from_str(r#"{"ignoreImports": false}"#).unwrap();
597        assert!(!json.ignore_imports);
598        let toml_cfg: DuplicatesConfig = toml::from_str("ignoreImports = false").unwrap();
599        assert!(!toml_cfg.ignore_imports);
600    }
601
602    #[test]
603    fn normalization_config_json_overrides() {
604        let json = r#"{
605            "ignoreIdentifiers": true,
606            "ignoreStringValues": false
607        }"#;
608        let config: NormalizationConfig = serde_json::from_str(json).unwrap();
609        assert_eq!(config.ignore_identifiers, Some(true));
610        assert_eq!(config.ignore_string_values, Some(false));
611        assert_eq!(config.ignore_numeric_values, None);
612    }
613
614    #[test]
615    fn duplicates_config_toml_all_fields() {
616        let toml_str = r#"
617enabled = false
618mode = "weak"
619near = true
620minTokens = 75
621minLines = 8
622minOccurrences = 3
623threshold = 3.0
624ignore = ["vendor/**"]
625ignoredClones = ["dup:6f12ab34:2"]
626skipLocal = true
627crossLanguage = true
628ignoreImports = true
629
630[normalization]
631ignoreIdentifiers = true
632ignoreStringValues = true
633ignoreNumericValues = false
634"#;
635        let config: DuplicatesConfig = toml::from_str(toml_str).unwrap();
636        assert!(!config.enabled);
637        assert_eq!(config.mode, DetectionMode::Weak);
638        assert!(config.near);
639        assert_eq!(config.min_tokens, 75);
640        assert_eq!(config.min_lines, 8);
641        assert_eq!(config.min_occurrences, 3);
642        assert!((config.threshold - 3.0).abs() < f64::EPSILON);
643        assert_eq!(config.ignore, vec!["vendor/**"]);
644        assert_eq!(config.ignored_clones, vec!["dup:6f12ab34:2"]);
645        assert!(config.skip_local);
646        assert!(config.cross_language);
647        assert!(config.ignore_imports);
648        assert_eq!(config.normalization.ignore_identifiers, Some(true));
649        assert_eq!(config.normalization.ignore_string_values, Some(true));
650        assert_eq!(config.normalization.ignore_numeric_values, Some(false));
651    }
652
653    #[test]
654    fn duplicates_config_toml_defaults() {
655        let toml_str = "";
656        let config: DuplicatesConfig = toml::from_str(toml_str).unwrap();
657        assert!(config.enabled);
658        assert_eq!(config.mode, DetectionMode::Mild);
659        assert_eq!(config.min_tokens, 50);
660        assert_eq!(config.min_lines, 5);
661    }
662
663    #[test]
664    fn normalization_config_default_all_none() {
665        let config = NormalizationConfig::default();
666        assert!(config.ignore_identifiers.is_none());
667        assert!(config.ignore_string_values.is_none());
668        assert!(config.ignore_numeric_values.is_none());
669    }
670
671    #[test]
672    fn normalization_config_empty_json_object() {
673        let config: NormalizationConfig = serde_json::from_str("{}").unwrap();
674        assert!(config.ignore_identifiers.is_none());
675        assert!(config.ignore_string_values.is_none());
676        assert!(config.ignore_numeric_values.is_none());
677    }
678
679    #[test]
680    fn detection_mode_default_is_mild() {
681        assert_eq!(DetectionMode::default(), DetectionMode::Mild);
682    }
683
684    #[test]
685    fn resolved_normalization_equality() {
686        let a = ResolvedNormalization {
687            ignore_identifiers: true,
688            ignore_string_values: false,
689            ignore_numeric_values: true,
690        };
691        let b = ResolvedNormalization {
692            ignore_identifiers: true,
693            ignore_string_values: false,
694            ignore_numeric_values: true,
695        };
696        assert_eq!(a, b);
697
698        let c = ResolvedNormalization {
699            ignore_identifiers: false,
700            ignore_string_values: false,
701            ignore_numeric_values: true,
702        };
703        assert_ne!(a, c);
704    }
705
706    #[test]
707    fn detection_mode_json_deserialization() {
708        let strict: DetectionMode = serde_json::from_str(r#""strict""#).unwrap();
709        assert_eq!(strict, DetectionMode::Strict);
710
711        let mild: DetectionMode = serde_json::from_str(r#""mild""#).unwrap();
712        assert_eq!(mild, DetectionMode::Mild);
713
714        let weak: DetectionMode = serde_json::from_str(r#""weak""#).unwrap();
715        assert_eq!(weak, DetectionMode::Weak);
716
717        let semantic: DetectionMode = serde_json::from_str(r#""semantic""#).unwrap();
718        assert_eq!(semantic, DetectionMode::Semantic);
719    }
720
721    #[test]
722    fn detection_mode_invalid_json() {
723        let result: Result<DetectionMode, _> = serde_json::from_str(r#""aggressive""#);
724        assert!(result.is_err());
725    }
726
727    #[test]
728    fn duplicates_config_json_roundtrip() {
729        let config = DuplicatesConfig {
730            enabled: false,
731            mode: DetectionMode::Semantic,
732            near: true,
733            min_tokens: 100,
734            min_lines: 10,
735            min_occurrences: 4,
736            threshold: 5.5,
737            ignore: vec!["test/**".to_string()],
738            ignored_clones: vec!["dup:6f12ab34:2".to_string()],
739            ignore_defaults: false,
740            skip_local: true,
741            ignore_symlinks: true,
742            cross_language: true,
743            ignore_imports: true,
744            normalization: NormalizationConfig {
745                ignore_identifiers: Some(true),
746                ignore_string_values: None,
747                ignore_numeric_values: Some(false),
748            },
749            min_corpus_size_for_shingle_filter: 2048,
750            min_corpus_size_for_token_cache: 8_000,
751        };
752        let json = serde_json::to_string(&config).unwrap();
753        let restored: DuplicatesConfig = serde_json::from_str(&json).unwrap();
754        assert!(!restored.enabled);
755        assert_eq!(restored.mode, DetectionMode::Semantic);
756        assert!(restored.near);
757        assert_eq!(restored.min_tokens, 100);
758        assert_eq!(restored.min_lines, 10);
759        assert_eq!(restored.min_occurrences, 4);
760        assert!((restored.threshold - 5.5).abs() < f64::EPSILON);
761        assert!(!restored.ignore_defaults);
762        assert_eq!(restored.ignored_clones, vec!["dup:6f12ab34:2"]);
763        assert!(restored.skip_local);
764        assert!(restored.ignore_symlinks);
765        assert!(restored.cross_language);
766        assert_eq!(restored.min_corpus_size_for_shingle_filter, 2048);
767        assert_eq!(restored.min_corpus_size_for_token_cache, 8_000);
768        assert!(restored.ignore_imports);
769        assert_eq!(restored.normalization.ignore_identifiers, Some(true));
770        assert!(restored.normalization.ignore_string_values.is_none());
771        assert_eq!(restored.normalization.ignore_numeric_values, Some(false));
772    }
773
774    #[test]
775    fn normalization_none_fields_not_serialized() {
776        let config = NormalizationConfig::default();
777        let json = serde_json::to_string(&config).unwrap();
778        assert!(
779            !json.contains("ignoreIdentifiers"),
780            "None fields should be skipped"
781        );
782        assert!(
783            !json.contains("ignoreStringValues"),
784            "None fields should be skipped"
785        );
786        assert!(
787            !json.contains("ignoreNumericValues"),
788            "None fields should be skipped"
789        );
790    }
791
792    #[test]
793    fn normalization_some_fields_serialized() {
794        let config = NormalizationConfig {
795            ignore_identifiers: Some(true),
796            ignore_string_values: None,
797            ignore_numeric_values: Some(false),
798        };
799        let json = serde_json::to_string(&config).unwrap();
800        assert!(json.contains("ignoreIdentifiers"));
801        assert!(!json.contains("ignoreStringValues"));
802        assert!(json.contains("ignoreNumericValues"));
803    }
804
805    #[test]
806    fn min_occurrences_accepts_two_or_more() {
807        let json = r#"{"minOccurrences": 2}"#;
808        let config: DuplicatesConfig = serde_json::from_str(json).unwrap();
809        assert_eq!(config.min_occurrences, 2);
810
811        let json = r#"{"minOccurrences": 5}"#;
812        let config: DuplicatesConfig = serde_json::from_str(json).unwrap();
813        assert_eq!(config.min_occurrences, 5);
814    }
815
816    #[test]
817    fn min_occurrences_rejects_one() {
818        let json = r#"{"minOccurrences": 1}"#;
819        let err = serde_json::from_str::<DuplicatesConfig>(json).unwrap_err();
820        assert!(err.to_string().contains("at least 2"));
821    }
822
823    #[test]
824    fn min_occurrences_rejects_zero() {
825        let json = r#"{"minOccurrences": 0}"#;
826        let err = serde_json::from_str::<DuplicatesConfig>(json).unwrap_err();
827        assert!(err.to_string().contains("at least 2"));
828    }
829
830    #[test]
831    fn min_occurrences_rejects_one_in_toml() {
832        let toml_str = "minOccurrences = 1";
833        let err = toml::from_str::<DuplicatesConfig>(toml_str).unwrap_err();
834        assert!(err.to_string().contains("at least 2"));
835    }
836}