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