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