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