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
20fn 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#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
102#[serde(rename_all = "camelCase")]
103pub struct DuplicatesConfig {
104 #[serde(default = "default_true")]
106 pub enabled: bool,
107
108 #[serde(default)]
110 pub mode: DetectionMode,
111
112 #[serde(default)]
114 pub near: bool,
115
116 #[serde(default = "default_min_tokens")]
118 pub min_tokens: usize,
119
120 #[serde(default = "default_min_lines")]
122 pub min_lines: usize,
123
124 #[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 #[serde(default)]
137 pub threshold: f64,
138
139 #[serde(default)]
141 pub ignore: Vec<String>,
142
143 #[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 #[serde(default = "default_true")]
158 pub ignore_defaults: bool,
159
160 #[serde(default)]
162 pub skip_local: bool,
163
164 #[serde(default)]
174 pub ignore_symlinks: bool,
175
176 #[serde(default)]
182 pub cross_language: bool,
183
184 #[serde(default = "default_true")]
195 pub ignore_imports: bool,
196
197 #[serde(default)]
199 pub normalization: NormalizationConfig,
200
201 #[serde(default = "default_min_corpus_size_for_shingle_filter")]
204 pub min_corpus_size_for_shingle_filter: usize,
205
206 #[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#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
244#[serde(rename_all = "camelCase")]
245pub struct NormalizationConfig {
246 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub ignore_identifiers: Option<bool>,
250
251 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub ignore_string_values: Option<bool>,
255
256 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub ignore_numeric_values: Option<bool>,
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub struct ResolvedNormalization {
265 pub ignore_identifiers: bool,
267 pub ignore_string_values: bool,
269 pub ignore_numeric_values: bool,
271}
272
273impl ResolvedNormalization {
274 #[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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
299#[serde(rename_all = "lowercase")]
300pub enum DetectionMode {
301 Strict,
303 #[default]
305 Mild,
306 Weak,
308 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); }
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), 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); 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); assert_eq!(config.mode, DetectionMode::Weak);
530 assert_eq!(config.min_tokens, 50); assert_eq!(config.min_lines, 5); 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 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}