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