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::TaggedEnum(schema) => {
347 if let Value::Object(nested) = value {
348 let nested_log = repair_tagged_enum(nested, schema, path, options);
349 log.merge(nested_log);
350 }
351 }
352 FieldKind::TaggedEnumArray(schema) => {
353 if let Value::Array(arr) = value {
354 let arr_log = repair_tagged_enum_array(arr, schema, path, options);
355 log.merge(arr_log);
356 }
357 }
358 FieldKind::Integer | FieldKind::Number | FieldKind::Bool | FieldKind::String => {
359 coerce_value(value, kind, path, log);
360 }
361 }
362}
363
364fn coerce_value(value: &mut Value, kind: &FieldKind, path: &str, log: &mut RepairLog) {
368 let new_value = match (kind, &*value) {
369 (FieldKind::Integer, Value::String(s)) => s
370 .trim()
371 .parse::<i64>()
372 .ok()
373 .map(|n| Value::Number(n.into())),
374 (FieldKind::Number, Value::String(s)) => s
375 .trim()
376 .parse::<f64>()
377 .ok()
378 .and_then(Number::from_f64)
379 .map(Value::Number),
380 (FieldKind::Bool, Value::String(s)) => match s.trim().to_ascii_lowercase().as_str() {
381 "true" => Some(Value::Bool(true)),
382 "false" => Some(Value::Bool(false)),
383 _ => None,
384 },
385 (FieldKind::String, Value::Number(n)) => Some(Value::String(n.to_string())),
386 (FieldKind::String, Value::Bool(b)) => Some(Value::String(b.to_string())),
387 _ => None,
388 };
389
390 if let Some(new_value) = new_value {
391 log.corrections.push(Correction::new(
392 value.to_string(),
393 new_value.to_string(),
394 1.0,
395 path.to_string(),
396 ));
397 *value = new_value;
398 }
399}
400
401pub fn repair_tagged_enum(
417 obj: &mut Map<String, Value>,
418 schema: &TaggedEnumSchema,
419 path: &str,
420 options: &FuzzyOptions,
421) -> RepairLog {
422 let mut log = RepairLog::default();
423
424 let tag_value = if let Some(tag_val) = obj.get(&schema.tag_field).and_then(|v| v.as_str()) {
426 if !schema.is_valid_tag(tag_val) {
427 if let Some(m) = find_closest(
429 tag_val,
430 schema.tag_values(),
431 options.min_similarity,
432 options.algorithm,
433 ) {
434 log.corrections.push(Correction::new(
435 tag_val.to_string(),
436 m.candidate.clone(),
437 m.similarity,
438 format!("{}.{}", path, schema.tag_field),
439 ));
440 obj.insert(
441 schema.tag_field.clone(),
442 Value::String(m.candidate.clone()),
443 );
444 m.candidate
445 } else {
446 tag_val.to_string()
447 }
448 } else {
449 tag_val.to_string()
450 }
451 } else {
452 return log; };
454
455 let variant = schema.variant_schema(&tag_value);
457 let mut valid_fields: Vec<&str> = variant.map(|s| s.field_names().collect()).unwrap_or_default();
458 for def in &schema.global_fields {
459 if !valid_fields.contains(&def.name.as_str()) {
460 valid_fields.push(def.name.as_str());
461 }
462 }
463 if !valid_fields.is_empty() {
464 repair_field_names_into(
465 obj,
466 &valid_fields,
467 Some(schema.tag_field.as_str()),
468 path,
469 options,
470 &mut log,
471 );
472 }
473
474 if let Some(variant) = variant {
476 apply_field_kinds(obj, &variant.fields, path, options, &mut log);
477 }
478
479 apply_field_kinds(obj, &schema.global_fields, path, options, &mut log);
481
482 log
483}
484
485pub fn repair_enum_array(
489 arr: &mut [Value],
490 valid_values: &[&str],
491 path: &str,
492 options: &FuzzyOptions,
493) -> RepairLog {
494 let mut log = RepairLog::default();
495
496 for (i, item) in arr.iter_mut().enumerate() {
497 if let Value::String(s) = item {
498 if !valid_values.contains(&s.as_str()) {
499 if let Some(m) = find_closest(
500 s,
501 valid_values.iter().copied(),
502 options.min_similarity,
503 options.algorithm,
504 ) {
505 log.corrections.push(Correction::new(
506 s.clone(),
507 m.candidate.clone(),
508 m.similarity,
509 format!("{}[{}]", path, i),
510 ));
511 *item = Value::String(m.candidate);
512 }
513 }
514 }
515 }
516
517 log
518}
519
520pub fn repair_tagged_enum_json(
522 json: &str,
523 schema: &TaggedEnumSchema,
524 options: &FuzzyOptions,
525) -> Result<RepairResult, FuzzyError> {
526 let mut value: Value = serde_json::from_str(json)?;
527
528 let log = if let Some(obj) = value.as_object_mut() {
529 repair_tagged_enum(obj, schema, "$", options)
530 } else {
531 return Err(FuzzyError::NotObject);
532 };
533
534 Ok(RepairResult {
535 repaired: value,
536 corrections: log.corrections,
537 skipped: log.skipped,
538 })
539}
540
541pub fn repair_tagged_enum_array(
543 arr: &mut [Value],
544 schema: &TaggedEnumSchema,
545 path: &str,
546 options: &FuzzyOptions,
547) -> RepairLog {
548 let mut log = RepairLog::default();
549
550 for (i, item) in arr.iter_mut().enumerate() {
551 if let Some(obj) = item.as_object_mut() {
552 let item_path = format!("{}[{}]", path, i);
553 let item_log = repair_tagged_enum(obj, schema, &item_path, options);
554 log.merge(item_log);
555 }
556 }
557
558 log
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use crate::schema::{FieldKind, ObjectSchema};
565
566 #[test]
569 #[allow(clippy::needless_borrows_for_generic_args)]
570 fn test_v01_call_style_still_compiles() {
571 let schema = TaggedEnumSchema::new("type", &["AddDerive"], |_| {
572 Some(&["target", "derives"][..])
573 })
574 .with_enum_array("derives", &["Debug", "Clone"])
575 .with_nested_object("config", &["timeout"]);
576 let object_schema = ObjectSchema::new(&["name", "value"]);
577
578 assert!(schema.is_valid_tag("AddDerive"));
579 assert!(object_schema.is_valid_field("name"));
580 }
581
582 fn test_schema() -> TaggedEnumSchema {
583 TaggedEnumSchema::new(
584 "type",
585 &["AddDerive", "RemoveDerive", "RenameIdent"],
586 |tag| match tag {
587 "AddDerive" | "RemoveDerive" => Some(&["target", "derives"]),
588 "RenameIdent" => Some(&["from", "to", "kind"]),
589 _ => None,
590 },
591 )
592 }
593
594 #[test]
595 fn test_repair_tagged_enum_type_typo() {
596 let schema = test_schema();
597 let json = r#"{"type": "AddDeriv", "target": "User", "derives": ["Debug"]}"#;
598 let options = FuzzyOptions::default();
599
600 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
601
602 assert_eq!(result.repaired["type"], "AddDerive");
603 assert_eq!(result.corrections.len(), 1);
604 assert_eq!(result.corrections[0].original, "AddDeriv");
605 assert_eq!(result.corrections[0].corrected, "AddDerive");
606 }
607
608 #[test]
609 fn test_repair_tagged_enum_field_typo() {
610 let schema = test_schema();
611 let json = r#"{"type": "AddDerive", "taget": "User", "derives": ["Debug"]}"#;
612 let options = FuzzyOptions::default();
613
614 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
615
616 assert!(result.repaired.get("target").is_some());
617 assert!(result.repaired.get("taget").is_none());
618 assert_eq!(result.corrections.len(), 1);
619 }
620
621 #[test]
622 fn test_repair_tagged_enum_multiple_typos() {
623 let schema = test_schema();
624 let json = r#"{"type": "RenamIdent", "form": "old", "too": "new"}"#;
625 let options = FuzzyOptions::default();
626
627 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
628
629 assert_eq!(result.repaired["type"], "RenameIdent");
630 assert!(result.repaired.get("from").is_some());
631 assert!(result.repaired.get("to").is_some());
632 assert_eq!(result.corrections.len(), 3);
633 }
634
635 #[test]
636 fn test_repair_object_fields() {
637 let schema = ObjectSchema::new(["name", "module", "derives"]);
638 let mut obj: Map<String, Value> =
639 serde_json::from_str(r#"{"nam": "Test", "modul": "foo"}"#).unwrap();
640 let options = FuzzyOptions::default();
641
642 let log = repair_object_fields(&mut obj, &schema, "$", &options);
643
644 assert!(obj.contains_key("name"));
645 assert!(obj.contains_key("module"));
646 assert_eq!(log.correction_count(), 2);
647 }
648
649 #[test]
650 fn test_no_correction_needed() {
651 let schema = test_schema();
652 let json = r#"{"type": "AddDerive", "target": "User", "derives": ["Debug"]}"#;
653 let options = FuzzyOptions::default();
654
655 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
656
657 assert!(!result.has_corrections());
658 }
659
660 #[test]
661 fn test_high_similarity_threshold() {
662 let schema = test_schema();
663 let json = r#"{"type": "AddDeriv", "target": "User", "derives": ["Debug"]}"#;
664 let options = FuzzyOptions::default().with_min_similarity(0.99);
665
666 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
667
668 assert_eq!(result.repaired["type"], "AddDeriv");
670 assert!(!result.has_corrections());
671 }
672
673 #[test]
674 fn test_repair_array() {
675 let schema = test_schema();
676 let mut arr: Vec<Value> = serde_json::from_str(
677 r#"[
678 {"type": "AddDeriv", "taget": "User", "derives": ["Debug"]},
679 {"type": "RenamIdent", "form": "old", "too": "new"}
680 ]"#,
681 )
682 .unwrap();
683 let options = FuzzyOptions::default();
684
685 let log = repair_tagged_enum_array(&mut arr, &schema, "$.intents", &options);
686
687 assert_eq!(arr[0]["type"], "AddDerive");
688 assert!(arr[0].get("target").is_some());
689 assert_eq!(arr[1]["type"], "RenameIdent");
690 assert!(arr[1].get("from").is_some());
691 assert!(log.correction_count() >= 4);
692 }
693
694 #[test]
695 fn test_repair_enum_array_values() {
696 let schema =
697 TaggedEnumSchema::new("type", &["AddDerive"], |_| Some(&["target", "derives"][..]))
698 .with_enum_array("derives", ["Debug", "Clone", "Serialize", "Default"]);
699
700 let json =
701 r#"{"type": "AddDerive", "target": "User", "derives": ["Debg", "Clne", "Serializ"]}"#;
702 let options = FuzzyOptions::default();
703
704 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
705
706 assert_eq!(result.repaired["derives"][0], "Debug");
707 assert_eq!(result.repaired["derives"][1], "Clone");
708 assert_eq!(result.repaired["derives"][2], "Serialize");
709 assert_eq!(result.corrections.len(), 3);
710 }
711
712 #[test]
713 fn test_repair_nested_object_fields() {
714 let schema =
715 TaggedEnumSchema::new("type", &["Configure"], |_| Some(&["name", "config"][..]))
716 .with_nested_object("config", ["timeout", "retries", "enabled"]);
717
718 let json =
719 r#"{"type": "Configure", "name": "test", "config": {"timout": 30, "retres": 3}}"#;
720 let options = FuzzyOptions::default();
721
722 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
723
724 assert!(result.repaired["config"].get("timeout").is_some());
725 assert!(result.repaired["config"].get("retries").is_some());
726 assert_eq!(result.repaired["config"]["timeout"], 30);
727 assert_eq!(result.repaired["config"]["retries"], 3);
728 assert_eq!(result.corrections.len(), 2);
729 }
730
731 #[test]
732 fn test_repair_combined_all_features() {
733 let schema = TaggedEnumSchema::new("type", &["AddDerive"], |_| {
734 Some(&["target", "derives", "config"][..])
735 })
736 .with_enum_array("derives", ["Debug", "Clone", "Serialize"])
737 .with_nested_object("config", ["timeout", "retries"]);
738
739 let json = r#"{
740 "type": "AddDeriv",
741 "taget": "User",
742 "derives": ["Debg", "Clne"],
743 "config": {"timout": 30}
744 }"#;
745 let options = FuzzyOptions::default();
746
747 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
748
749 assert_eq!(result.repaired["type"], "AddDerive");
751 assert!(result.repaired.get("target").is_some());
753 assert_eq!(result.repaired["target"], "User");
754 assert_eq!(result.repaired["derives"][0], "Debug");
756 assert_eq!(result.repaired["derives"][1], "Clone");
757 assert!(result.repaired["config"].get("timeout").is_some());
759 assert_eq!(result.repaired["config"]["timeout"], 30);
760 assert_eq!(result.corrections.len(), 5);
762 }
763
764 #[test]
765 fn test_collision_skip_is_recorded() {
766 let json = r#"{"type": "AddDerive", "taget": "User", "targt": "Post"}"#;
770 let schema = test_schema();
771 let options = FuzzyOptions::default();
772
773 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
774
775 assert_eq!(result.repaired["target"], "User");
777 assert_eq!(result.repaired["targt"], "Post");
778 assert!(result.repaired.get("taget").is_none());
779 assert_eq!(result.corrections.len(), 1);
780 assert_eq!(result.corrections[0].original, "taget");
781 assert_eq!(result.corrections[0].corrected, "target");
782 assert_eq!(result.skipped.len(), 1);
784 assert_eq!(result.skipped[0].original, "targt");
785 assert_eq!(result.skipped[0].candidate, "target");
786 assert_eq!(result.skipped[0].reason, SkipReason::TargetExists);
787 }
788
789 #[test]
790 fn test_collision_existing_key_skip_is_recorded() {
791 let json = r#"{"type": "AddDerive", "target": "User", "taget": "Post"}"#;
794 let schema = test_schema();
795 let options = FuzzyOptions::default();
796
797 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
798
799 assert_eq!(result.repaired["target"], "User");
800 assert_eq!(result.repaired["taget"], "Post");
801 assert!(!result.has_corrections());
802 assert!(result.has_skipped());
803 assert_eq!(result.skipped[0].original, "taget");
804 assert_eq!(result.skipped[0].reason, SkipReason::TargetExists);
805 }
806
807 #[test]
808 fn test_repair_enum_array_no_correction_needed() {
809 let schema =
810 TaggedEnumSchema::new("type", &["AddDerive"], |_| Some(&["target", "derives"][..]))
811 .with_enum_array("derives", ["Debug", "Clone"]);
812
813 let json = r#"{"type": "AddDerive", "target": "User", "derives": ["Debug", "Clone"]}"#;
814 let options = FuzzyOptions::default();
815
816 let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
817
818 assert!(!result.has_corrections());
819 }
820
821 #[test]
826 fn test_deeply_nested_object_repair() {
827 let schema = TaggedEnumSchema::with_tag("type").with_variant(
829 "Configure",
830 ObjectSchema::new(["name"]).with_field_kind(
831 "config",
832 FieldKind::Object(ObjectSchema::new(["host"]).with_field_kind(
833 "server",
834 FieldKind::Object(ObjectSchema::new(["port"]).with_field_kind(
835 "limits",
836 FieldKind::Object(ObjectSchema::new(["max_conn", "timeout"])),
837 )),
838 )),
839 ),
840 );
841
842 let json = r#"{
843 "type": "Configure",
844 "name": "api",
845 "config": {"host": "x", "server": {"prot": 80, "limits": {"max_con": 10, "timout": 5}}}
846 }"#;
847 let result =
848 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
849
850 let server = &result.repaired["config"]["server"];
851 assert_eq!(server["port"], 80);
852 assert_eq!(server["limits"]["max_conn"], 10);
853 assert_eq!(server["limits"]["timeout"], 5);
854 assert_eq!(result.corrections.len(), 3);
855 assert!(result
857 .corrections
858 .iter()
859 .any(|c| c.field_path == "$.config.server.limits.timout"));
860 }
861
862 #[test]
863 fn test_object_array_repair() {
864 let schema = TaggedEnumSchema::with_tag("type").with_variant(
865 "Batch",
866 ObjectSchema::empty().with_field_kind(
867 "items",
868 FieldKind::ObjectArray(
869 ObjectSchema::new(["name"])
870 .with_field_kind("kind", FieldKind::enum_of(["file", "dir"])),
871 ),
872 ),
873 );
874
875 let json = r#"{
876 "type": "Batch",
877 "items": [
878 {"nam": "a", "kind": "fil"},
879 {"name": "b", "knd": "dirr"}
880 ]
881 }"#;
882 let result =
883 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
884
885 assert_eq!(result.repaired["items"][0]["name"], "a");
886 assert_eq!(result.repaired["items"][0]["kind"], "file");
887 assert_eq!(result.repaired["items"][1]["kind"], "dir");
888 assert_eq!(result.corrections.len(), 4);
889 assert!(result
890 .corrections
891 .iter()
892 .any(|c| c.field_path.starts_with("$.items[1]")));
893 }
894
895 #[test]
896 fn test_enum_value_repair_on_string_field() {
897 let schema = TaggedEnumSchema::with_tag("type").with_variant(
898 "SetLevel",
899 ObjectSchema::empty()
900 .with_field_kind("level", FieldKind::enum_of(["debug", "info", "warn"])),
901 );
902
903 let json = r#"{"type": "SetLevel", "level": "inof"}"#;
904 let result =
905 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
906
907 assert_eq!(result.repaired["level"], "info");
908 assert_eq!(result.corrections.len(), 1);
909 }
910
911 #[test]
912 fn test_type_coercion() {
913 let schema = TaggedEnumSchema::with_tag("type").with_variant(
914 "Configure",
915 ObjectSchema::empty()
916 .with_field_kind("timeout", FieldKind::Integer)
917 .with_field_kind("rate", FieldKind::Number)
918 .with_field_kind("enabled", FieldKind::Bool)
919 .with_field_kind("label", FieldKind::String),
920 );
921
922 let json = r#"{
923 "type": "Configure",
924 "timeout": "30",
925 "rate": "0.5",
926 "enabled": "true",
927 "label": 42
928 }"#;
929 let result =
930 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
931
932 assert_eq!(result.repaired["timeout"], 30);
933 assert_eq!(result.repaired["rate"], 0.5);
934 assert_eq!(result.repaired["enabled"], true);
935 assert_eq!(result.repaired["label"], "42");
936 assert_eq!(result.corrections.len(), 4);
937 assert!(result.corrections.iter().all(|c| c.similarity == 1.0));
939 }
940
941 #[test]
942 fn test_type_coercion_leaves_unparseable_untouched() {
943 let schema = TaggedEnumSchema::with_tag("type").with_variant(
944 "Configure",
945 ObjectSchema::empty()
946 .with_field_kind("timeout", FieldKind::Integer)
947 .with_field_kind("enabled", FieldKind::Bool),
948 );
949
950 let json = r#"{"type": "Configure", "timeout": "soon", "enabled": "maybe"}"#;
951 let result =
952 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
953
954 assert_eq!(result.repaired["timeout"], "soon");
955 assert_eq!(result.repaired["enabled"], "maybe");
956 assert!(!result.has_corrections());
957 }
958
959 #[test]
960 fn test_type_coercion_noop_when_already_typed() {
961 let schema = TaggedEnumSchema::with_tag("type").with_variant(
962 "Configure",
963 ObjectSchema::empty().with_field_kind("timeout", FieldKind::Integer),
964 );
965
966 let json = r#"{"type": "Configure", "timeout": 30}"#;
967 let result =
968 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
969
970 assert_eq!(result.repaired["timeout"], 30);
971 assert!(!result.has_corrections());
972 }
973
974 #[test]
975 fn test_dynamic_schema_repair() {
976 let tags = vec![String::from("Create"), String::from("Delete")];
978 let mut schema = TaggedEnumSchema::with_tag("kind");
979 for tag in &tags {
980 schema = schema.with_variant(tag, ObjectSchema::new(vec!["name", "path"]));
981 }
982
983 let json = r#"{"kind": "Creat", "nme": "x", "pth": "/tmp"}"#;
984 let result =
985 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
986
987 assert_eq!(result.repaired["kind"], "Create");
988 assert!(result.repaired.get("name").is_some());
989 assert!(result.repaired.get("path").is_some());
990 assert_eq!(result.corrections.len(), 3);
991 }
992
993 #[test]
994 fn test_nested_tagged_enum_field_repair() {
995 let action_schema = TaggedEnumSchema::with_tag("kind")
997 .with_variant("Move", ObjectSchema::new(["from", "to"]))
998 .with_variant("Copy", ObjectSchema::new(["from", "to"]));
999 let schema = TaggedEnumSchema::with_tag("type").with_variant(
1000 "Command",
1001 ObjectSchema::new(["name"])
1002 .with_field_kind("action", FieldKind::TaggedEnum(action_schema)),
1003 );
1004
1005 let json = r#"{
1006 "type": "Command",
1007 "name": "x",
1008 "action": {"kind": "Mve", "frm": "/a", "to": "/b"}
1009 }"#;
1010 let result =
1011 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
1012
1013 assert_eq!(result.repaired["action"]["kind"], "Move");
1014 assert!(result.repaired["action"].get("from").is_some());
1015 assert_eq!(result.corrections.len(), 2);
1016 assert!(result
1017 .corrections
1018 .iter()
1019 .any(|c| c.field_path == "$.action.kind"));
1020 }
1021
1022 #[test]
1023 fn test_tagged_enum_array_field_repair() {
1024 let intent_schema = TaggedEnumSchema::with_tag("type")
1026 .with_variant("AddDerive", ObjectSchema::new(["target"]))
1027 .with_variant("Rename", ObjectSchema::new(["from", "to"]));
1028 let schema = TaggedEnumSchema::with_tag("type").with_variant(
1029 "Batch",
1030 ObjectSchema::empty()
1031 .with_field_kind("intents", FieldKind::TaggedEnumArray(intent_schema)),
1032 );
1033
1034 let json = r#"{
1035 "type": "Batch",
1036 "intents": [
1037 {"type": "AddDeriv", "taget": "User"},
1038 {"type": "Renme", "from": "a", "too": "b"}
1039 ]
1040 }"#;
1041 let result =
1042 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
1043
1044 assert_eq!(result.repaired["intents"][0]["type"], "AddDerive");
1045 assert!(result.repaired["intents"][0].get("target").is_some());
1046 assert_eq!(result.repaired["intents"][1]["type"], "Rename");
1047 assert!(result.repaired["intents"][1].get("to").is_some());
1048 assert_eq!(result.corrections.len(), 4);
1049 assert!(result
1050 .corrections
1051 .iter()
1052 .any(|c| c.field_path.starts_with("$.intents[1]")));
1053 }
1054
1055 #[test]
1056 fn test_enum_array_inside_nested_object() {
1057 let schema = TaggedEnumSchema::with_tag("type").with_variant(
1059 "AddDerive",
1060 ObjectSchema::new(["target"]).with_field_kind(
1061 "config",
1062 FieldKind::Object(ObjectSchema::empty().with_field_kind(
1063 "derives",
1064 FieldKind::enum_array(["Debug", "Clone", "Serialize"]),
1065 )),
1066 ),
1067 );
1068
1069 let json = r#"{"type": "AddDerive", "target": "User", "config": {"derives": ["Debg"]}}"#;
1070 let result =
1071 repair_tagged_enum_json(json, &schema, &FuzzyOptions::default()).unwrap();
1072
1073 assert_eq!(result.repaired["config"]["derives"][0], "Debug");
1074 assert_eq!(result.corrections.len(), 1);
1075 assert_eq!(result.corrections[0].field_path, "$.config.derives[0]");
1076 }
1077}