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)]
142 pub ignore: Vec<String>,
143
144 #[serde(default, deserialize_with = "deserialize_ignored_clones")]
150 #[schemars(inner(regex(
151 pattern = r"^dup:(?:[0-9a-f]{8}|[0-9a-f]{16}(?:-r?[1-9][0-9]*)?):(?:[2-9]|[1-9][0-9]+)$"
152 )))]
153 pub ignored_clones: Vec<String>,
154
155 #[serde(default = "default_true")]
159 pub ignore_defaults: bool,
160
161 #[serde(default)]
163 pub skip_local: bool,
164
165 #[serde(default)]
175 pub ignore_symlinks: bool,
176
177 #[serde(default)]
183 pub cross_language: bool,
184
185 #[serde(default = "default_true")]
196 pub ignore_imports: bool,
197
198 #[serde(default)]
200 pub normalization: NormalizationConfig,
201
202 #[serde(default = "default_min_corpus_size_for_shingle_filter")]
205 pub min_corpus_size_for_shingle_filter: usize,
206
207 #[serde(default = "default_min_corpus_size_for_token_cache")]
212 pub min_corpus_size_for_token_cache: usize,
213}
214
215impl Default for DuplicatesConfig {
216 fn default() -> Self {
217 Self {
218 enabled: true,
219 mode: DetectionMode::default(),
220 near: false,
221 min_tokens: default_min_tokens(),
222 min_lines: default_min_lines(),
223 min_occurrences: default_min_occurrences(),
224 threshold: 0.0,
225 ignore: vec![],
226 ignored_clones: vec![],
227 ignore_defaults: true,
228 skip_local: false,
229 ignore_symlinks: false,
230 cross_language: false,
231 ignore_imports: true,
232 normalization: NormalizationConfig::default(),
233 min_corpus_size_for_shingle_filter: default_min_corpus_size_for_shingle_filter(),
234 min_corpus_size_for_token_cache: default_min_corpus_size_for_token_cache(),
235 }
236 }
237}
238
239#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
245#[serde(rename_all = "camelCase")]
246pub struct NormalizationConfig {
247 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub ignore_identifiers: Option<bool>,
251
252 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub ignore_string_values: Option<bool>,
256
257 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub ignore_numeric_values: Option<bool>,
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub struct ResolvedNormalization {
266 pub ignore_identifiers: bool,
268 pub ignore_string_values: bool,
270 pub ignore_numeric_values: bool,
272}
273
274impl ResolvedNormalization {
275 #[must_use]
277 pub fn resolve(mode: DetectionMode, overrides: &NormalizationConfig) -> Self {
278 let (default_ids, default_strings, default_numbers) = match mode {
279 DetectionMode::Strict | DetectionMode::Mild => (false, false, false),
280 DetectionMode::Weak => (false, true, false),
281 DetectionMode::Semantic => (true, true, true),
282 };
283
284 Self {
285 ignore_identifiers: overrides.ignore_identifiers.unwrap_or(default_ids),
286 ignore_string_values: overrides.ignore_string_values.unwrap_or(default_strings),
287 ignore_numeric_values: overrides.ignore_numeric_values.unwrap_or(default_numbers),
288 }
289 }
290}
291
292#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
300#[serde(rename_all = "lowercase")]
301pub enum DetectionMode {
302 Strict,
304 #[default]
306 Mild,
307 Weak,
309 Semantic,
311}
312
313impl std::fmt::Display for DetectionMode {
314 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315 match self {
316 Self::Strict => write!(f, "strict"),
317 Self::Mild => write!(f, "mild"),
318 Self::Weak => write!(f, "weak"),
319 Self::Semantic => write!(f, "semantic"),
320 }
321 }
322}
323
324impl std::str::FromStr for DetectionMode {
325 type Err = String;
326
327 fn from_str(s: &str) -> Result<Self, Self::Err> {
328 match s.to_lowercase().as_str() {
329 "strict" => Ok(Self::Strict),
330 "mild" => Ok(Self::Mild),
331 "weak" => Ok(Self::Weak),
332 "semantic" => Ok(Self::Semantic),
333 other => Err(format!("unknown detection mode: '{other}'")),
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn duplicates_config_defaults() {
344 let config = DuplicatesConfig::default();
345 assert!(config.enabled);
346 assert_eq!(config.mode, DetectionMode::Mild);
347 assert_eq!(config.min_tokens, 50);
348 assert_eq!(config.min_lines, 5);
349 assert_eq!(config.min_occurrences, 2);
350 assert!((config.threshold - 0.0).abs() < f64::EPSILON);
351 assert!(config.ignore.is_empty());
352 assert!(config.ignore_defaults);
353 assert!(!config.skip_local);
354 assert!(!config.ignore_symlinks);
355 assert!(!config.cross_language);
356 assert!(config.ignore_imports);
357 assert_eq!(config.min_corpus_size_for_shingle_filter, 1024);
358 assert_eq!(config.min_corpus_size_for_token_cache, 5_000);
359 }
360
361 #[test]
362 fn detection_mode_from_str_all_variants() {
363 assert_eq!(
364 "strict".parse::<DetectionMode>().unwrap(),
365 DetectionMode::Strict
366 );
367 assert_eq!(
368 "mild".parse::<DetectionMode>().unwrap(),
369 DetectionMode::Mild
370 );
371 assert_eq!(
372 "weak".parse::<DetectionMode>().unwrap(),
373 DetectionMode::Weak
374 );
375 assert_eq!(
376 "semantic".parse::<DetectionMode>().unwrap(),
377 DetectionMode::Semantic
378 );
379 }
380
381 #[test]
382 fn detection_mode_from_str_case_insensitive() {
383 assert_eq!(
384 "STRICT".parse::<DetectionMode>().unwrap(),
385 DetectionMode::Strict
386 );
387 assert_eq!(
388 "Weak".parse::<DetectionMode>().unwrap(),
389 DetectionMode::Weak
390 );
391 assert_eq!(
392 "SEMANTIC".parse::<DetectionMode>().unwrap(),
393 DetectionMode::Semantic
394 );
395 }
396
397 #[test]
398 fn detection_mode_from_str_unknown() {
399 let err = "foobar".parse::<DetectionMode>().unwrap_err();
400 assert!(err.contains("unknown detection mode"));
401 assert!(err.contains("foobar"));
402 }
403
404 #[test]
405 fn detection_mode_display() {
406 assert_eq!(DetectionMode::Strict.to_string(), "strict");
407 assert_eq!(DetectionMode::Mild.to_string(), "mild");
408 assert_eq!(DetectionMode::Weak.to_string(), "weak");
409 assert_eq!(DetectionMode::Semantic.to_string(), "semantic");
410 }
411
412 #[test]
413 fn resolve_strict_mode_all_false() {
414 let resolved =
415 ResolvedNormalization::resolve(DetectionMode::Strict, &NormalizationConfig::default());
416 assert!(!resolved.ignore_identifiers);
417 assert!(!resolved.ignore_string_values);
418 assert!(!resolved.ignore_numeric_values);
419 }
420
421 #[test]
422 fn resolve_mild_mode_all_false() {
423 let resolved =
424 ResolvedNormalization::resolve(DetectionMode::Mild, &NormalizationConfig::default());
425 assert!(!resolved.ignore_identifiers);
426 assert!(!resolved.ignore_string_values);
427 assert!(!resolved.ignore_numeric_values);
428 }
429
430 #[test]
431 fn resolve_weak_mode_only_strings_true() {
432 let resolved =
433 ResolvedNormalization::resolve(DetectionMode::Weak, &NormalizationConfig::default());
434 assert!(!resolved.ignore_identifiers);
435 assert!(resolved.ignore_string_values);
436 assert!(!resolved.ignore_numeric_values);
437 }
438
439 #[test]
440 fn resolve_semantic_mode_all_true() {
441 let resolved = ResolvedNormalization::resolve(
442 DetectionMode::Semantic,
443 &NormalizationConfig::default(),
444 );
445 assert!(resolved.ignore_identifiers);
446 assert!(resolved.ignore_string_values);
447 assert!(resolved.ignore_numeric_values);
448 }
449
450 #[test]
451 fn resolve_override_forces_true() {
452 let overrides = NormalizationConfig {
453 ignore_identifiers: Some(true),
454 ignore_string_values: None,
455 ignore_numeric_values: None,
456 };
457 let resolved = ResolvedNormalization::resolve(DetectionMode::Strict, &overrides);
458 assert!(resolved.ignore_identifiers);
459 assert!(!resolved.ignore_string_values);
460 assert!(!resolved.ignore_numeric_values);
461 }
462
463 #[test]
464 fn resolve_override_forces_false() {
465 let overrides = NormalizationConfig {
466 ignore_identifiers: Some(false),
467 ignore_string_values: Some(false),
468 ignore_numeric_values: None,
469 };
470 let resolved = ResolvedNormalization::resolve(DetectionMode::Semantic, &overrides);
471 assert!(!resolved.ignore_identifiers);
472 assert!(!resolved.ignore_string_values);
473 assert!(resolved.ignore_numeric_values); }
475
476 #[test]
477 fn resolve_all_overrides_on_weak() {
478 let overrides = NormalizationConfig {
479 ignore_identifiers: Some(true),
480 ignore_string_values: Some(false), ignore_numeric_values: Some(true),
482 };
483 let resolved = ResolvedNormalization::resolve(DetectionMode::Weak, &overrides);
484 assert!(resolved.ignore_identifiers);
485 assert!(!resolved.ignore_string_values); assert!(resolved.ignore_numeric_values);
487 }
488
489 #[test]
490 fn duplicates_config_json_all_fields() {
491 let json = r#"{
492 "enabled": false,
493 "mode": "semantic",
494 "near": true,
495 "minTokens": 100,
496 "minLines": 10,
497 "minOccurrences": 3,
498 "threshold": 5.0,
499 "ignore": ["**/vendor/**"],
500 "ignoredClones": ["dup:6f12ab34:2", "dup:0123456789abcdef-1:3"],
501 "ignoreDefaults": false,
502 "skipLocal": true,
503 "crossLanguage": true,
504 "ignoreImports": true
505 }"#;
506 let config: DuplicatesConfig = serde_json::from_str(json).unwrap();
507 assert!(!config.enabled);
508 assert_eq!(config.mode, DetectionMode::Semantic);
509 assert!(config.near);
510 assert_eq!(config.min_tokens, 100);
511 assert_eq!(config.min_lines, 10);
512 assert_eq!(config.min_occurrences, 3);
513 assert!((config.threshold - 5.0).abs() < f64::EPSILON);
514 assert_eq!(config.ignore, vec!["**/vendor/**"]);
515 assert_eq!(
516 config.ignored_clones,
517 vec!["dup:6f12ab34:2", "dup:0123456789abcdef-1:3"]
518 );
519 assert!(!config.ignore_defaults);
520 assert!(config.skip_local);
521 assert!(config.cross_language);
522 assert!(config.ignore_imports);
523 }
524
525 #[test]
526 fn duplicates_config_json_partial_uses_defaults() {
527 let json = r#"{"mode": "weak"}"#;
528 let config: DuplicatesConfig = serde_json::from_str(json).unwrap();
529 assert!(config.enabled); assert_eq!(config.mode, DetectionMode::Weak);
531 assert_eq!(config.min_tokens, 50); assert_eq!(config.min_lines, 5); assert!(config.ignored_clones.is_empty());
534 assert!(config.ignore_defaults);
535 }
536
537 #[test]
538 fn duplicates_config_json_ignore_defaults_merges_by_default() {
539 let json = r#"{"ignore": ["**/foo/**"]}"#;
540 let config: DuplicatesConfig = serde_json::from_str(json).unwrap();
541 assert_eq!(config.ignore, vec!["**/foo/**"]);
542 assert!(config.ignore_defaults);
543 }
544
545 #[test]
546 fn ignored_clones_accepts_corrected_and_legacy_collision_handles() {
547 let keys = ["dup:0123456789abcdef-r1:2", "dup:0123456789abcdef-1:2"];
548 let config: DuplicatesConfig = serde_json::from_value(serde_json::json!({
549 "ignoredClones": keys
550 }))
551 .expect("both collision generations remain valid config input");
552 assert_eq!(config.ignored_clones, keys);
553 }
554
555 #[test]
556 fn ignored_clones_rejects_malformed_keys() {
557 for key in [
558 "6f12ab34:2",
559 "dup:6f12ab3:2",
560 "dup:6F12AB34:2",
561 "dup:6f12ab34:1",
562 "dup:6f12ab34:02",
563 "dup:0123456789abcdef-0:2",
564 "dup:0123456789abcdef-01:2",
565 "dup:0123456789abcdef-extra:2",
566 "dup:0123456789abcdef-r:2",
567 "dup:0123456789abcdef-r0:2",
568 "dup:0123456789abcdef-r01:2",
569 "dup:0123456789abcdef-r+1:2",
570 "dup:0123456789abcdef-+1:2",
571 "dup:0123456789abcdef-rr1:2",
572 "dup:6f12ab34-r1:2",
573 ] {
574 let json = serde_json::json!({ "ignoredClones": [key] });
575 let error = serde_json::from_value::<DuplicatesConfig>(json).unwrap_err();
576 assert!(
577 error.to_string().contains("ignoredClones[0]"),
578 "unexpected error for {key}: {error}"
579 );
580 }
581 }
582
583 #[test]
584 fn ignore_imports_defaults_true_when_field_omitted() {
585 let empty: DuplicatesConfig = serde_json::from_str("{}").unwrap();
589 assert!(empty.ignore_imports);
590 let partial: DuplicatesConfig = serde_json::from_str(r#"{"minLines": 8}"#).unwrap();
591 assert!(partial.ignore_imports);
592 }
593
594 #[test]
595 fn ignore_imports_false_opts_out() {
596 let json: DuplicatesConfig = serde_json::from_str(r#"{"ignoreImports": false}"#).unwrap();
597 assert!(!json.ignore_imports);
598 let toml_cfg: DuplicatesConfig = toml::from_str("ignoreImports = false").unwrap();
599 assert!(!toml_cfg.ignore_imports);
600 }
601
602 #[test]
603 fn normalization_config_json_overrides() {
604 let json = r#"{
605 "ignoreIdentifiers": true,
606 "ignoreStringValues": false
607 }"#;
608 let config: NormalizationConfig = serde_json::from_str(json).unwrap();
609 assert_eq!(config.ignore_identifiers, Some(true));
610 assert_eq!(config.ignore_string_values, Some(false));
611 assert_eq!(config.ignore_numeric_values, None);
612 }
613
614 #[test]
615 fn duplicates_config_toml_all_fields() {
616 let toml_str = r#"
617enabled = false
618mode = "weak"
619near = true
620minTokens = 75
621minLines = 8
622minOccurrences = 3
623threshold = 3.0
624ignore = ["vendor/**"]
625ignoredClones = ["dup:6f12ab34:2"]
626skipLocal = true
627crossLanguage = true
628ignoreImports = true
629
630[normalization]
631ignoreIdentifiers = true
632ignoreStringValues = true
633ignoreNumericValues = false
634"#;
635 let config: DuplicatesConfig = toml::from_str(toml_str).unwrap();
636 assert!(!config.enabled);
637 assert_eq!(config.mode, DetectionMode::Weak);
638 assert!(config.near);
639 assert_eq!(config.min_tokens, 75);
640 assert_eq!(config.min_lines, 8);
641 assert_eq!(config.min_occurrences, 3);
642 assert!((config.threshold - 3.0).abs() < f64::EPSILON);
643 assert_eq!(config.ignore, vec!["vendor/**"]);
644 assert_eq!(config.ignored_clones, vec!["dup:6f12ab34:2"]);
645 assert!(config.skip_local);
646 assert!(config.cross_language);
647 assert!(config.ignore_imports);
648 assert_eq!(config.normalization.ignore_identifiers, Some(true));
649 assert_eq!(config.normalization.ignore_string_values, Some(true));
650 assert_eq!(config.normalization.ignore_numeric_values, Some(false));
651 }
652
653 #[test]
654 fn duplicates_config_toml_defaults() {
655 let toml_str = "";
656 let config: DuplicatesConfig = toml::from_str(toml_str).unwrap();
657 assert!(config.enabled);
658 assert_eq!(config.mode, DetectionMode::Mild);
659 assert_eq!(config.min_tokens, 50);
660 assert_eq!(config.min_lines, 5);
661 }
662
663 #[test]
664 fn normalization_config_default_all_none() {
665 let config = NormalizationConfig::default();
666 assert!(config.ignore_identifiers.is_none());
667 assert!(config.ignore_string_values.is_none());
668 assert!(config.ignore_numeric_values.is_none());
669 }
670
671 #[test]
672 fn normalization_config_empty_json_object() {
673 let config: NormalizationConfig = serde_json::from_str("{}").unwrap();
674 assert!(config.ignore_identifiers.is_none());
675 assert!(config.ignore_string_values.is_none());
676 assert!(config.ignore_numeric_values.is_none());
677 }
678
679 #[test]
680 fn detection_mode_default_is_mild() {
681 assert_eq!(DetectionMode::default(), DetectionMode::Mild);
682 }
683
684 #[test]
685 fn resolved_normalization_equality() {
686 let a = ResolvedNormalization {
687 ignore_identifiers: true,
688 ignore_string_values: false,
689 ignore_numeric_values: true,
690 };
691 let b = ResolvedNormalization {
692 ignore_identifiers: true,
693 ignore_string_values: false,
694 ignore_numeric_values: true,
695 };
696 assert_eq!(a, b);
697
698 let c = ResolvedNormalization {
699 ignore_identifiers: false,
700 ignore_string_values: false,
701 ignore_numeric_values: true,
702 };
703 assert_ne!(a, c);
704 }
705
706 #[test]
707 fn detection_mode_json_deserialization() {
708 let strict: DetectionMode = serde_json::from_str(r#""strict""#).unwrap();
709 assert_eq!(strict, DetectionMode::Strict);
710
711 let mild: DetectionMode = serde_json::from_str(r#""mild""#).unwrap();
712 assert_eq!(mild, DetectionMode::Mild);
713
714 let weak: DetectionMode = serde_json::from_str(r#""weak""#).unwrap();
715 assert_eq!(weak, DetectionMode::Weak);
716
717 let semantic: DetectionMode = serde_json::from_str(r#""semantic""#).unwrap();
718 assert_eq!(semantic, DetectionMode::Semantic);
719 }
720
721 #[test]
722 fn detection_mode_invalid_json() {
723 let result: Result<DetectionMode, _> = serde_json::from_str(r#""aggressive""#);
724 assert!(result.is_err());
725 }
726
727 #[test]
728 fn duplicates_config_json_roundtrip() {
729 let config = DuplicatesConfig {
730 enabled: false,
731 mode: DetectionMode::Semantic,
732 near: true,
733 min_tokens: 100,
734 min_lines: 10,
735 min_occurrences: 4,
736 threshold: 5.5,
737 ignore: vec!["test/**".to_string()],
738 ignored_clones: vec!["dup:6f12ab34:2".to_string()],
739 ignore_defaults: false,
740 skip_local: true,
741 ignore_symlinks: true,
742 cross_language: true,
743 ignore_imports: true,
744 normalization: NormalizationConfig {
745 ignore_identifiers: Some(true),
746 ignore_string_values: None,
747 ignore_numeric_values: Some(false),
748 },
749 min_corpus_size_for_shingle_filter: 2048,
750 min_corpus_size_for_token_cache: 8_000,
751 };
752 let json = serde_json::to_string(&config).unwrap();
753 let restored: DuplicatesConfig = serde_json::from_str(&json).unwrap();
754 assert!(!restored.enabled);
755 assert_eq!(restored.mode, DetectionMode::Semantic);
756 assert!(restored.near);
757 assert_eq!(restored.min_tokens, 100);
758 assert_eq!(restored.min_lines, 10);
759 assert_eq!(restored.min_occurrences, 4);
760 assert!((restored.threshold - 5.5).abs() < f64::EPSILON);
761 assert!(!restored.ignore_defaults);
762 assert_eq!(restored.ignored_clones, vec!["dup:6f12ab34:2"]);
763 assert!(restored.skip_local);
764 assert!(restored.ignore_symlinks);
765 assert!(restored.cross_language);
766 assert_eq!(restored.min_corpus_size_for_shingle_filter, 2048);
767 assert_eq!(restored.min_corpus_size_for_token_cache, 8_000);
768 assert!(restored.ignore_imports);
769 assert_eq!(restored.normalization.ignore_identifiers, Some(true));
770 assert!(restored.normalization.ignore_string_values.is_none());
771 assert_eq!(restored.normalization.ignore_numeric_values, Some(false));
772 }
773
774 #[test]
775 fn normalization_none_fields_not_serialized() {
776 let config = NormalizationConfig::default();
777 let json = serde_json::to_string(&config).unwrap();
778 assert!(
779 !json.contains("ignoreIdentifiers"),
780 "None fields should be skipped"
781 );
782 assert!(
783 !json.contains("ignoreStringValues"),
784 "None fields should be skipped"
785 );
786 assert!(
787 !json.contains("ignoreNumericValues"),
788 "None fields should be skipped"
789 );
790 }
791
792 #[test]
793 fn normalization_some_fields_serialized() {
794 let config = NormalizationConfig {
795 ignore_identifiers: Some(true),
796 ignore_string_values: None,
797 ignore_numeric_values: Some(false),
798 };
799 let json = serde_json::to_string(&config).unwrap();
800 assert!(json.contains("ignoreIdentifiers"));
801 assert!(!json.contains("ignoreStringValues"));
802 assert!(json.contains("ignoreNumericValues"));
803 }
804
805 #[test]
806 fn min_occurrences_accepts_two_or_more() {
807 let json = r#"{"minOccurrences": 2}"#;
808 let config: DuplicatesConfig = serde_json::from_str(json).unwrap();
809 assert_eq!(config.min_occurrences, 2);
810
811 let json = r#"{"minOccurrences": 5}"#;
812 let config: DuplicatesConfig = serde_json::from_str(json).unwrap();
813 assert_eq!(config.min_occurrences, 5);
814 }
815
816 #[test]
817 fn min_occurrences_rejects_one() {
818 let json = r#"{"minOccurrences": 1}"#;
819 let err = serde_json::from_str::<DuplicatesConfig>(json).unwrap_err();
820 assert!(err.to_string().contains("at least 2"));
821 }
822
823 #[test]
824 fn min_occurrences_rejects_zero() {
825 let json = r#"{"minOccurrences": 0}"#;
826 let err = serde_json::from_str::<DuplicatesConfig>(json).unwrap_err();
827 assert!(err.to_string().contains("at least 2"));
828 }
829
830 #[test]
831 fn min_occurrences_rejects_one_in_toml() {
832 let toml_str = "minOccurrences = 1";
833 let err = toml::from_str::<DuplicatesConfig>(toml_str).unwrap_err();
834 assert!(err.to_string().contains("at least 2"));
835 }
836}