1use crate::distance::{find_closest, Algorithm};
22use crate::error::FuzzyError;
23use crate::schema::{FieldDef, FieldKind, ObjectSchema, TaggedEnumSchema};
24use serde_json::{Map, Number, Value};
25
26#[derive(Debug, Clone)]
28pub struct FuzzyOptions {
29 pub min_similarity: f64,
34
35 pub algorithm: Algorithm,
39}
40
41impl Default for FuzzyOptions {
42 fn default() -> Self {
43 Self {
44 min_similarity: 0.7,
45 algorithm: Algorithm::JaroWinkler,
46 }
47 }
48}
49
50impl FuzzyOptions {
51 pub fn with_min_similarity(mut self, min_similarity: f64) -> Self {
53 self.min_similarity = min_similarity;
54 self
55 }
56
57 pub fn with_algorithm(mut self, algorithm: Algorithm) -> Self {
59 self.algorithm = algorithm;
60 self
61 }
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct Correction {
67 pub original: String,
69 pub corrected: String,
71 pub similarity: f64,
73 pub field_path: String,
75}
76
77impl Correction {
78 pub fn new(original: String, corrected: String, similarity: f64, field_path: String) -> Self {
80 Self {
81 original,
82 corrected,
83 similarity,
84 field_path,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum SkipReason {
92 TargetExists,
95}
96
97#[derive(Debug, Clone, PartialEq)]
104pub struct SkippedCorrection {
105 pub original: String,
107 pub candidate: String,
109 pub similarity: f64,
111 pub field_path: String,
113 pub reason: SkipReason,
115}
116
117#[derive(Debug, Clone, Default)]
119pub struct RepairLog {
120 pub corrections: Vec<Correction>,
122 pub skipped: Vec<SkippedCorrection>,
124}
125
126impl RepairLog {
127 pub fn has_corrections(&self) -> bool {
129 !self.corrections.is_empty()
130 }
131
132 pub fn correction_count(&self) -> usize {
134 self.corrections.len()
135 }
136
137 pub fn has_skipped(&self) -> bool {
139 !self.skipped.is_empty()
140 }
141
142 pub fn merge(&mut self, other: RepairLog) {
144 self.corrections.extend(other.corrections);
145 self.skipped.extend(other.skipped);
146 }
147}
148
149#[derive(Debug, Clone)]
151pub struct RepairResult {
152 pub repaired: Value,
154 pub corrections: Vec<Correction>,
156 pub skipped: Vec<SkippedCorrection>,
158}
159
160impl RepairResult {
161 pub fn has_corrections(&self) -> bool {
163 !self.corrections.is_empty()
164 }
165
166 pub fn correction_count(&self) -> usize {
168 self.corrections.len()
169 }
170
171 pub fn has_skipped(&self) -> bool {
173 !self.skipped.is_empty()
174 }
175}
176
177pub fn repair_object_fields(
193 obj: &mut Map<String, Value>,
194 schema: &ObjectSchema,
195 path: &str,
196 options: &FuzzyOptions,
197) -> RepairLog {
198 let mut log = RepairLog::default();
199 let valid_fields: Vec<&str> = schema.field_names().collect();
200 repair_field_names_into(obj, &valid_fields, None, path, options, &mut log);
201 apply_field_kinds(obj, &schema.fields, path, options, &mut log);
202 log
203}
204
205pub fn repair_fields_with_list(
218 obj: &mut Map<String, Value>,
219 valid_fields: &[&str],
220 path: &str,
221 options: &FuzzyOptions,
222) -> RepairLog {
223 let mut log = RepairLog::default();
224 repair_field_names_into(obj, valid_fields, None, path, options, &mut log);
225 log
226}
227
228fn repair_field_names_into(
230 obj: &mut Map<String, Value>,
231 valid_fields: &[&str],
232 skip_key: Option<&str>,
233 path: &str,
234 options: &FuzzyOptions,
235 log: &mut RepairLog,
236) {
237 let keys_to_check: Vec<String> = obj
239 .keys()
240 .filter(|k| Some(k.as_str()) != skip_key && !valid_fields.contains(&k.as_str()))
241 .cloned()
242 .collect();
243
244 for key in keys_to_check {
246 if let Some(m) = find_closest(
247 &key,
248 valid_fields.iter().copied(),
249 options.min_similarity,
250 options.algorithm,
251 ) {
252 if !obj.contains_key(&m.candidate) {
254 if let Some(val) = obj.remove(&key) {
255 log.corrections.push(Correction::new(
256 key.clone(),
257 m.candidate.clone(),
258 m.similarity,
259 format!("{}.{}", path, key),
260 ));
261 obj.insert(m.candidate, val);
262 }
263 } else {
264 log.skipped.push(SkippedCorrection {
265 original: key.clone(),
266 candidate: m.candidate,
267 similarity: m.similarity,
268 field_path: format!("{}.{}", path, key),
269 reason: SkipReason::TargetExists,
270 });
271 }
272 }
273 }
274}
275
276fn apply_field_kinds(
278 obj: &mut Map<String, Value>,
279 fields: &[FieldDef],
280 path: &str,
281 options: &FuzzyOptions,
282 log: &mut RepairLog,
283) {
284 for def in fields {
285 if let Some(value) = obj.get_mut(&def.name) {
286 let field_path = format!("{}.{}", path, def.name);
287 apply_kind(value, &def.kind, &field_path, options, log);
288 }
289 }
290}
291
292fn apply_kind(
294 value: &mut Value,
295 kind: &FieldKind,
296 path: &str,
297 options: &FuzzyOptions,
298 log: &mut RepairLog,
299) {
300 match kind {
301 FieldKind::Any => {}
302 FieldKind::Enum(valid_values) => {
303 if let Value::String(s) = value {
304 if !valid_values.iter().any(|v| v == s) {
305 if let Some(m) = find_closest(
306 s,
307 valid_values.iter().map(|v| v.as_str()),
308 options.min_similarity,
309 options.algorithm,
310 ) {
311 log.corrections.push(Correction::new(
312 s.clone(),
313 m.candidate.clone(),
314 m.similarity,
315 path.to_string(),
316 ));
317 *value = Value::String(m.candidate);
318 }
319 }
320 }
321 }
322 FieldKind::EnumArray(valid_values) => {
323 if let Value::Array(arr) = value {
324 let valid: Vec<&str> = valid_values.iter().map(|v| v.as_str()).collect();
325 let arr_log = repair_enum_array(arr, &valid, path, options);
326 log.merge(arr_log);
327 }
328 }
329 FieldKind::Object(schema) => {
330 if let Value::Object(nested) = value {
331 let nested_log = repair_object_fields(nested, schema, path, options);
332 log.merge(nested_log);
333 }
334 }
335 FieldKind::ObjectArray(schema) => {
336 if let Value::Array(arr) = value {
337 for (i, item) in arr.iter_mut().enumerate() {
338 if let Value::Object(nested) = item {
339 let item_path = format!("{}[{}]", path, i);
340 let nested_log = repair_object_fields(nested, schema, &item_path, options);
341 log.merge(nested_log);
342 }
343 }
344 }
345 }
346 FieldKind::Integer | FieldKind::Number | FieldKind::Bool | FieldKind::String => {
347 coerce_value(value, kind, path, log);
348 }
349 }
350}
351
352fn coerce_value(value: &mut Value, kind: &FieldKind, path: &str, log: &mut RepairLog) {
356 let new_value = match (kind, &*value) {
357 (FieldKind::Integer, Value::String(s)) => s
358 .trim()
359 .parse::<i64>()
360 .ok()
361 .map(|n| Value::Number(n.into())),
362 (FieldKind::Number, Value::String(s)) => s
363 .trim()
364 .parse::<f64>()
365 .ok()
366 .and_then(Number::from_f64)
367 .map(Value::Number),
368 (FieldKind::Bool, Value::String(s)) => match s.trim().to_ascii_lowercase().as_str() {
369 "true" => Some(Value::Bool(true)),
370 "false" => Some(Value::Bool(false)),
371 _ => None,
372 },
373 (FieldKind::String, Value::Number(n)) => Some(Value::String(n.to_string())),
374 (FieldKind::String, Value::Bool(b)) => Some(Value::String(b.to_string())),
375 _ => None,
376 };
377
378 if let Some(new_value) = new_value {
379 log.corrections.push(Correction::new(
380 value.to_string(),
381 new_value.to_string(),
382 1.0,
383 path.to_string(),
384 ));
385 *value = new_value;
386 }
387}
388
389pub fn repair_tagged_enum(
405 obj: &mut Map<String, Value>,
406 schema: &TaggedEnumSchema,
407 path: &str,
408 options: &FuzzyOptions,
409) -> RepairLog {
410 let mut log = RepairLog::default();
411
412 let tag_value = if let Some(tag_val) = obj.get(&schema.tag_field).and_then(|v| v.as_str()) {
414 if !schema.is_valid_tag(tag_val) {
415 if let Some(m) = find_closest(
417 tag_val,
418 schema.tag_values(),
419 options.min_similarity,
420 options.algorithm,
421 ) {
422 log.corrections.push(Correction::new(
423 tag_val.to_string(),
424 m.candidate.clone(),
425 m.similarity,
426 format!("{}.{}", path, schema.tag_field),
427 ));
428 obj.insert(
429 schema.tag_field.clone(),
430 Value::String(m.candidate.clone()),
431 );
432 m.candidate
433 } else {
434 tag_val.to_string()
435 }
436 } else {
437 tag_val.to_string()
438 }
439 } else {
440 return log; };
442
443 let variant = schema.variant_schema(&tag_value);
445 let mut valid_fields: Vec<&str> = variant.map(|s| s.field_names().collect()).unwrap_or_default();
446 for def in &schema.global_fields {
447 if !valid_fields.contains(&def.name.as_str()) {
448 valid_fields.push(def.name.as_str());
449 }
450 }
451 if !valid_fields.is_empty() {
452 repair_field_names_into(
453 obj,
454 &valid_fields,
455 Some(schema.tag_field.as_str()),
456 path,
457 options,
458 &mut log,
459 );
460 }
461
462 if let Some(variant) = variant {
464 apply_field_kinds(obj, &variant.fields, path, options, &mut log);
465 }
466
467 apply_field_kinds(obj, &schema.global_fields, path, options, &mut log);
469
470 log
471}
472
473pub fn repair_enum_array(
477 arr: &mut [Value],
478 valid_values: &[&str],
479 path: &str,
480 options: &FuzzyOptions,
481) -> RepairLog {
482 let mut log = RepairLog::default();
483
484 for (i, item) in arr.iter_mut().enumerate() {
485 if let Value::String(s) = item {
486 if !valid_values.contains(&s.as_str()) {
487 if let Some(m) = find_closest(
488 s,
489 valid_values.iter().copied(),
490 options.min_similarity,
491 options.algorithm,
492 ) {
493 log.corrections.push(Correction::new(
494 s.clone(),
495 m.candidate.clone(),
496 m.similarity,
497 format!("{}[{}]", path, i),
498 ));
499 *item = Value::String(m.candidate);
500 }
501 }
502 }
503 }
504
505 log
506}
507
508pub fn repair_tagged_enum_json(
510 json: &str,
511 schema: &TaggedEnumSchema,
512 options: &FuzzyOptions,
513) -> Result<RepairResult, FuzzyError> {
514 let mut value: Value = serde_json::from_str(json)?;
515
516 let log = if let Some(obj) = value.as_object_mut() {
517 repair_tagged_enum(obj, schema, "$", options)
518 } else {
519 return Err(FuzzyError::NotObject);
520 };
521
522 Ok(RepairResult {
523 repaired: value,
524 corrections: log.corrections,
525 skipped: log.skipped,
526 })
527}
528
529pub fn repair_tagged_enum_array(
531 arr: &mut [Value],
532 schema: &TaggedEnumSchema,
533 path: &str,
534 options: &FuzzyOptions,
535) -> RepairLog {
536 let mut log = RepairLog::default();
537
538 for (i, item) in arr.iter_mut().enumerate() {
539 if let Some(obj) = item.as_object_mut() {
540 let item_path = format!("{}[{}]", path, i);
541 let item_log = repair_tagged_enum(obj, schema, &item_path, options);
542 log.merge(item_log);
543 }
544 }
545
546 log
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use crate::schema::{FieldKind, ObjectSchema};
553
554 #[test]
557 #[allow(clippy::needless_borrows_for_generic_args)]
558 fn test_v01_call_style_still_compiles() {
559 let schema = TaggedEnumSchema::new("type", &["AddDerive"], |_| {
560 Some(&["target", "derives"][..])
561 })
562 .with_enum_array("derives", &["Debug", "Clone"])
563 .with_nested_object("config", &["timeout"]);
564 let object_schema = ObjectSchema::new(&["name", "value"]);
565
566 assert!(schema.is_valid_tag("AddDerive"));
567 assert!(object_schema.is_valid_field("name"));
568 }
569
570 fn test_schema() -> TaggedEnumSchema {
571 TaggedEnumSchema::new(
572 "type",
573 &["AddDerive", "RemoveDerive", "RenameIdent"],
574 |tag| match tag {
575 "AddDerive" | "RemoveDerive" => Some(&["target", "derives"]),
576 "RenameIdent" => Some(&["from", "to", "kind"]),
577 _ => None,
578 },
579 )
580 }
581
582 #[test]
583 fn test_repair_tagged_enum_type_typo() {
584 let schema = test_schema();
585 let json = r#"{"type": "AddDeriv", "target": "User", "derives": ["Debug"]}"#;
586 let options = FuzzyOptions::default();
587
588 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
589
590 assert_eq!(result.repaired["type"], "AddDerive");
591 assert_eq!(result.corrections.len(), 1);
592 assert_eq!(result.corrections[0].original, "AddDeriv");
593 assert_eq!(result.corrections[0].corrected, "AddDerive");
594 }
595
596 #[test]
597 fn test_repair_tagged_enum_field_typo() {
598 let schema = test_schema();
599 let json = r#"{"type": "AddDerive", "taget": "User", "derives": ["Debug"]}"#;
600 let options = FuzzyOptions::default();
601
602 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
603
604 assert!(result.repaired.get("target").is_some());
605 assert!(result.repaired.get("taget").is_none());
606 assert_eq!(result.corrections.len(), 1);
607 }
608
609 #[test]
610 fn test_repair_tagged_enum_multiple_typos() {
611 let schema = test_schema();
612 let json = r#"{"type": "RenamIdent", "form": "old", "too": "new"}"#;
613 let options = FuzzyOptions::default();
614
615 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
616
617 assert_eq!(result.repaired["type"], "RenameIdent");
618 assert!(result.repaired.get("from").is_some());
619 assert!(result.repaired.get("to").is_some());
620 assert_eq!(result.corrections.len(), 3);
621 }
622
623 #[test]
624 fn test_repair_object_fields() {
625 let schema = ObjectSchema::new(["name", "module", "derives"]);
626 let mut obj: Map<String, Value> =
627 serde_json::from_str(r#"{"nam": "Test", "modul": "foo"}"#).unwrap();
628 let options = FuzzyOptions::default();
629
630 let log = repair_object_fields(&mut obj, &schema, "$", &options);
631
632 assert!(obj.contains_key("name"));
633 assert!(obj.contains_key("module"));
634 assert_eq!(log.correction_count(), 2);
635 }
636
637 #[test]
638 fn test_no_correction_needed() {
639 let schema = test_schema();
640 let json = r#"{"type": "AddDerive", "target": "User", "derives": ["Debug"]}"#;
641 let options = FuzzyOptions::default();
642
643 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
644
645 assert!(!result.has_corrections());
646 }
647
648 #[test]
649 fn test_high_similarity_threshold() {
650 let schema = test_schema();
651 let json = r#"{"type": "AddDeriv", "target": "User", "derives": ["Debug"]}"#;
652 let options = FuzzyOptions::default().with_min_similarity(0.99);
653
654 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
655
656 assert_eq!(result.repaired["type"], "AddDeriv");
658 assert!(!result.has_corrections());
659 }
660
661 #[test]
662 fn test_repair_array() {
663 let schema = test_schema();
664 let mut arr: Vec<Value> = serde_json::from_str(
665 r#"[
666 {"type": "AddDeriv", "taget": "User", "derives": ["Debug"]},
667 {"type": "RenamIdent", "form": "old", "too": "new"}
668 ]"#,
669 )
670 .unwrap();
671 let options = FuzzyOptions::default();
672
673 let log = repair_tagged_enum_array(&mut arr, &schema, "$.intents", &options);
674
675 assert_eq!(arr[0]["type"], "AddDerive");
676 assert!(arr[0].get("target").is_some());
677 assert_eq!(arr[1]["type"], "RenameIdent");
678 assert!(arr[1].get("from").is_some());
679 assert!(log.correction_count() >= 4);
680 }
681
682 #[test]
683 fn test_repair_enum_array_values() {
684 let schema =
685 TaggedEnumSchema::new("type", &["AddDerive"], |_| Some(&["target", "derives"][..]))
686 .with_enum_array("derives", ["Debug", "Clone", "Serialize", "Default"]);
687
688 let json =
689 r#"{"type": "AddDerive", "target": "User", "derives": ["Debg", "Clne", "Serializ"]}"#;
690 let options = FuzzyOptions::default();
691
692 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
693
694 assert_eq!(result.repaired["derives"][0], "Debug");
695 assert_eq!(result.repaired["derives"][1], "Clone");
696 assert_eq!(result.repaired["derives"][2], "Serialize");
697 assert_eq!(result.corrections.len(), 3);
698 }
699
700 #[test]
701 fn test_repair_nested_object_fields() {
702 let schema =
703 TaggedEnumSchema::new("type", &["Configure"], |_| Some(&["name", "config"][..]))
704 .with_nested_object("config", ["timeout", "retries", "enabled"]);
705
706 let json =
707 r#"{"type": "Configure", "name": "test", "config": {"timout": 30, "retres": 3}}"#;
708 let options = FuzzyOptions::default();
709
710 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
711
712 assert!(result.repaired["config"].get("timeout").is_some());
713 assert!(result.repaired["config"].get("retries").is_some());
714 assert_eq!(result.repaired["config"]["timeout"], 30);
715 assert_eq!(result.repaired["config"]["retries"], 3);
716 assert_eq!(result.corrections.len(), 2);
717 }
718
719 #[test]
720 fn test_repair_combined_all_features() {
721 let schema = TaggedEnumSchema::new("type", &["AddDerive"], |_| {
722 Some(&["target", "derives", "config"][..])
723 })
724 .with_enum_array("derives", ["Debug", "Clone", "Serialize"])
725 .with_nested_object("config", ["timeout", "retries"]);
726
727 let json = r#"{
728 "type": "AddDeriv",
729 "taget": "User",
730 "derives": ["Debg", "Clne"],
731 "config": {"timout": 30}
732 }"#;
733 let options = FuzzyOptions::default();
734
735 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
736
737 assert_eq!(result.repaired["type"], "AddDerive");
739 assert!(result.repaired.get("target").is_some());
741 assert_eq!(result.repaired["target"], "User");
742 assert_eq!(result.repaired["derives"][0], "Debug");
744 assert_eq!(result.repaired["derives"][1], "Clone");
745 assert!(result.repaired["config"].get("timeout").is_some());
747 assert_eq!(result.repaired["config"]["timeout"], 30);
748 assert_eq!(result.corrections.len(), 5);
750 }
751
752 #[test]
753 fn test_collision_skip_is_recorded() {
754 let json = r#"{"type": "AddDerive", "taget": "User", "targt": "Post"}"#;
758 let schema = test_schema();
759 let options = FuzzyOptions::default();
760
761 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
762
763 assert_eq!(result.repaired["target"], "User");
765 assert_eq!(result.repaired["targt"], "Post");
766 assert!(result.repaired.get("taget").is_none());
767 assert_eq!(result.corrections.len(), 1);
768 assert_eq!(result.corrections[0].original, "taget");
769 assert_eq!(result.corrections[0].corrected, "target");
770 assert_eq!(result.skipped.len(), 1);
772 assert_eq!(result.skipped[0].original, "targt");
773 assert_eq!(result.skipped[0].candidate, "target");
774 assert_eq!(result.skipped[0].reason, SkipReason::TargetExists);
775 }
776
777 #[test]
778 fn test_collision_existing_key_skip_is_recorded() {
779 let json = r#"{"type": "AddDerive", "target": "User", "taget": "Post"}"#;
782 let schema = test_schema();
783 let options = FuzzyOptions::default();
784
785 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
786
787 assert_eq!(result.repaired["target"], "User");
788 assert_eq!(result.repaired["taget"], "Post");
789 assert!(!result.has_corrections());
790 assert!(result.has_skipped());
791 assert_eq!(result.skipped[0].original, "taget");
792 assert_eq!(result.skipped[0].reason, SkipReason::TargetExists);
793 }
794
795 #[test]
796 fn test_repair_enum_array_no_correction_needed() {
797 let schema =
798 TaggedEnumSchema::new("type", &["AddDerive"], |_| Some(&["target", "derives"][..]))
799 .with_enum_array("derives", ["Debug", "Clone"]);
800
801 let json = r#"{"type": "AddDerive", "target": "User", "derives": ["Debug", "Clone"]}"#;
802 let options = FuzzyOptions::default();
803
804 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
805
806 assert!(!result.has_corrections());
807 }
808
809 #[test]
814 fn test_deeply_nested_object_repair() {
815 let schema = TaggedEnumSchema::with_tag("type").with_variant(
817 "Configure",
818 ObjectSchema::new(["name"]).with_field_kind(
819 "config",
820 FieldKind::Object(ObjectSchema::new(["host"]).with_field_kind(
821 "server",
822 FieldKind::Object(ObjectSchema::new(["port"]).with_field_kind(
823 "limits",
824 FieldKind::Object(ObjectSchema::new(["max_conn", "timeout"])),
825 )),
826 )),
827 ),
828 );
829
830 let json = r#"{
831 "type": "Configure",
832 "name": "api",
833 "config": {"host": "x", "server": {"prot": 80, "limits": {"max_con": 10, "timout": 5}}}
834 }"#;
835 let result =
836 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
837
838 let server = &result.repaired["config"]["server"];
839 assert_eq!(server["port"], 80);
840 assert_eq!(server["limits"]["max_conn"], 10);
841 assert_eq!(server["limits"]["timeout"], 5);
842 assert_eq!(result.corrections.len(), 3);
843 assert!(result
845 .corrections
846 .iter()
847 .any(|c| c.field_path == "$.config.server.limits.timout"));
848 }
849
850 #[test]
851 fn test_object_array_repair() {
852 let schema = TaggedEnumSchema::with_tag("type").with_variant(
853 "Batch",
854 ObjectSchema::empty().with_field_kind(
855 "items",
856 FieldKind::ObjectArray(
857 ObjectSchema::new(["name"])
858 .with_field_kind("kind", FieldKind::enum_of(["file", "dir"])),
859 ),
860 ),
861 );
862
863 let json = r#"{
864 "type": "Batch",
865 "items": [
866 {"nam": "a", "kind": "fil"},
867 {"name": "b", "knd": "dirr"}
868 ]
869 }"#;
870 let result =
871 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
872
873 assert_eq!(result.repaired["items"][0]["name"], "a");
874 assert_eq!(result.repaired["items"][0]["kind"], "file");
875 assert_eq!(result.repaired["items"][1]["kind"], "dir");
876 assert_eq!(result.corrections.len(), 4);
877 assert!(result
878 .corrections
879 .iter()
880 .any(|c| c.field_path.starts_with("$.items[1]")));
881 }
882
883 #[test]
884 fn test_enum_value_repair_on_string_field() {
885 let schema = TaggedEnumSchema::with_tag("type").with_variant(
886 "SetLevel",
887 ObjectSchema::empty()
888 .with_field_kind("level", FieldKind::enum_of(["debug", "info", "warn"])),
889 );
890
891 let json = r#"{"type": "SetLevel", "level": "inof"}"#;
892 let result =
893 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
894
895 assert_eq!(result.repaired["level"], "info");
896 assert_eq!(result.corrections.len(), 1);
897 }
898
899 #[test]
900 fn test_type_coercion() {
901 let schema = TaggedEnumSchema::with_tag("type").with_variant(
902 "Configure",
903 ObjectSchema::empty()
904 .with_field_kind("timeout", FieldKind::Integer)
905 .with_field_kind("rate", FieldKind::Number)
906 .with_field_kind("enabled", FieldKind::Bool)
907 .with_field_kind("label", FieldKind::String),
908 );
909
910 let json = r#"{
911 "type": "Configure",
912 "timeout": "30",
913 "rate": "0.5",
914 "enabled": "true",
915 "label": 42
916 }"#;
917 let result =
918 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
919
920 assert_eq!(result.repaired["timeout"], 30);
921 assert_eq!(result.repaired["rate"], 0.5);
922 assert_eq!(result.repaired["enabled"], true);
923 assert_eq!(result.repaired["label"], "42");
924 assert_eq!(result.corrections.len(), 4);
925 assert!(result.corrections.iter().all(|c| c.similarity == 1.0));
927 }
928
929 #[test]
930 fn test_type_coercion_leaves_unparseable_untouched() {
931 let schema = TaggedEnumSchema::with_tag("type").with_variant(
932 "Configure",
933 ObjectSchema::empty()
934 .with_field_kind("timeout", FieldKind::Integer)
935 .with_field_kind("enabled", FieldKind::Bool),
936 );
937
938 let json = r#"{"type": "Configure", "timeout": "soon", "enabled": "maybe"}"#;
939 let result =
940 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
941
942 assert_eq!(result.repaired["timeout"], "soon");
943 assert_eq!(result.repaired["enabled"], "maybe");
944 assert!(!result.has_corrections());
945 }
946
947 #[test]
948 fn test_type_coercion_noop_when_already_typed() {
949 let schema = TaggedEnumSchema::with_tag("type").with_variant(
950 "Configure",
951 ObjectSchema::empty().with_field_kind("timeout", FieldKind::Integer),
952 );
953
954 let json = r#"{"type": "Configure", "timeout": 30}"#;
955 let result =
956 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
957
958 assert_eq!(result.repaired["timeout"], 30);
959 assert!(!result.has_corrections());
960 }
961
962 #[test]
963 fn test_dynamic_schema_repair() {
964 let tags = vec![String::from("Create"), String::from("Delete")];
966 let mut schema = TaggedEnumSchema::with_tag("kind");
967 for tag in &tags {
968 schema = schema.with_variant(tag, ObjectSchema::new(vec!["name", "path"]));
969 }
970
971 let json = r#"{"kind": "Creat", "nme": "x", "pth": "/tmp"}"#;
972 let result =
973 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
974
975 assert_eq!(result.repaired["kind"], "Create");
976 assert!(result.repaired.get("name").is_some());
977 assert!(result.repaired.get("path").is_some());
978 assert_eq!(result.corrections.len(), 3);
979 }
980
981 #[test]
982 fn test_enum_array_inside_nested_object() {
983 let schema = TaggedEnumSchema::with_tag("type").with_variant(
985 "AddDerive",
986 ObjectSchema::new(["target"]).with_field_kind(
987 "config",
988 FieldKind::Object(ObjectSchema::empty().with_field_kind(
989 "derives",
990 FieldKind::enum_array(["Debug", "Clone", "Serialize"]),
991 )),
992 ),
993 );
994
995 let json = r#"{"type": "AddDerive", "target": "User", "config": {"derives": ["Debg"]}}"#;
996 let result =
997 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
998
999 assert_eq!(result.repaired["config"]["derives"][0], "Debug");
1000 assert_eq!(result.corrections.len(), 1);
1001 assert_eq!(result.corrections[0].field_path, "$.config.derives[0]");
1002 }
1003}