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