Skip to main content

fuzzy_parser/
repair.rs

1//! Generic JSON repair logic
2//!
3//! This module provides generic fuzzy repair functions that work with
4//! any schema provided by the caller.
5//!
6//! # Architecture
7//!
8//! Repair walks the JSON value tree guided by the schema:
9//!
10//! 1. **Field-name repair** — object keys are fuzzy-matched against the
11//!    schema's field names and renamed (collision-safe, first-win; skipped
12//!    renames are recorded as [`SkippedCorrection`]s).
13//! 2. **Value repair** — each field's value is repaired according to its
14//!    [`FieldKind`]: fuzzy correction against closed sets, recursive descent
15//!    into nested objects / arrays of objects, or scalar type coercion.
16//!
17//! Every change is recorded as a [`Correction`]; every rename that was
18//! *not* applied because the target key already existed is recorded as a
19//! [`SkippedCorrection`]. Nothing is changed silently.
20
21use crate::distance::{find_closest, Algorithm};
22use crate::error::FuzzyError;
23use crate::schema::{FieldDef, FieldKind, ObjectSchema, TaggedEnumSchema};
24use serde_json::{Map, Number, Value};
25
26/// Options for fuzzy repair
27#[derive(Debug, Clone)]
28pub struct FuzzyOptions {
29    /// Minimum similarity threshold (0.0 to 1.0)
30    ///
31    /// Values below this threshold will not be corrected.
32    /// Default: 0.7
33    pub min_similarity: f64,
34
35    /// Algorithm to use for similarity calculation
36    ///
37    /// Default: JaroWinkler (best for typos)
38    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    /// Create options with a custom minimum similarity threshold
52    pub fn with_min_similarity(mut self, min_similarity: f64) -> Self {
53        self.min_similarity = min_similarity;
54        self
55    }
56
57    /// Create options with a custom algorithm
58    pub fn with_algorithm(mut self, algorithm: Algorithm) -> Self {
59        self.algorithm = algorithm;
60        self
61    }
62}
63
64/// A single correction made during repair
65#[derive(Debug, Clone, PartialEq)]
66pub struct Correction {
67    /// The original (incorrect) value
68    pub original: String,
69    /// The corrected value
70    pub corrected: String,
71    /// Similarity score (0.0 to 1.0); `1.0` for type coercions
72    pub similarity: f64,
73    /// JSON path to the corrected field (e.g., "$.type", "$.target")
74    pub field_path: String,
75}
76
77impl Correction {
78    /// Create a new correction
79    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/// Why a candidate correction was not applied.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum SkipReason {
92    /// The rename target key already exists in the object, so renaming
93    /// would have overwritten data (first-win collision policy).
94    TargetExists,
95}
96
97/// A correction that was found but *not* applied.
98///
99/// Recorded when a typo key resolves to a candidate field name but the
100/// candidate already exists in the object (either as a literal key or
101/// because an earlier typo key won the rename). See
102/// [`repair_fields_with_list`] for the collision policy.
103#[derive(Debug, Clone, PartialEq)]
104pub struct SkippedCorrection {
105    /// The original key that was left unchanged
106    pub original: String,
107    /// The candidate it would have been renamed to
108    pub candidate: String,
109    /// Similarity score (0.0 to 1.0)
110    pub similarity: f64,
111    /// JSON path to the field (e.g., "$.targt")
112    pub field_path: String,
113    /// Why the correction was skipped
114    pub reason: SkipReason,
115}
116
117/// Accumulated log of a repair pass: applied and skipped corrections.
118#[derive(Debug, Clone, Default)]
119pub struct RepairLog {
120    /// Corrections that were applied
121    pub corrections: Vec<Correction>,
122    /// Corrections that were found but skipped (collision safety)
123    pub skipped: Vec<SkippedCorrection>,
124}
125
126impl RepairLog {
127    /// Check if any corrections were applied
128    pub fn has_corrections(&self) -> bool {
129        !self.corrections.is_empty()
130    }
131
132    /// Get the number of corrections applied
133    pub fn correction_count(&self) -> usize {
134        self.corrections.len()
135    }
136
137    /// Check if any corrections were skipped
138    pub fn has_skipped(&self) -> bool {
139        !self.skipped.is_empty()
140    }
141
142    /// Merge another log into this one
143    pub fn merge(&mut self, other: RepairLog) {
144        self.corrections.extend(other.corrections);
145        self.skipped.extend(other.skipped);
146    }
147}
148
149/// Result of a repair operation
150#[derive(Debug, Clone)]
151pub struct RepairResult {
152    /// The repaired JSON value
153    pub repaired: Value,
154    /// List of corrections made
155    pub corrections: Vec<Correction>,
156    /// List of corrections that were found but skipped (collision safety)
157    pub skipped: Vec<SkippedCorrection>,
158}
159
160impl RepairResult {
161    /// Check if any corrections were made
162    pub fn has_corrections(&self) -> bool {
163        !self.corrections.is_empty()
164    }
165
166    /// Get the number of corrections made
167    pub fn correction_count(&self) -> usize {
168        self.corrections.len()
169    }
170
171    /// Check if any corrections were skipped
172    pub fn has_skipped(&self) -> bool {
173        !self.skipped.is_empty()
174    }
175}
176
177// ============================================================================
178// Generic Repair Functions
179// ============================================================================
180
181/// Repair a JSON object using an [`ObjectSchema`]
182///
183/// This repairs:
184/// 1. Field names (fuzzy-matched against the schema's field names)
185/// 2. Field values according to each field's [`FieldKind`] — recursing
186///    into nested objects and arrays of objects to any depth
187///
188/// # Collision behavior (first-win)
189///
190/// See [`repair_fields_with_list`]. Skipped renames are recorded in the
191/// returned log's `skipped` list.
192pub 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
205/// Repair field names in a JSON object using a field list
206///
207/// # Collision behavior (first-win)
208///
209/// A key is only renamed when the target field does not already exist in the
210/// object. This guards against destroying data: if two typo keys resolve to
211/// the same candidate, the first one processed wins and the later key is left
212/// unchanged (recorded as a [`SkippedCorrection`] with
213/// [`SkipReason::TargetExists`]). The same applies when the candidate is
214/// already present as a literal key. Keys are processed in the object's
215/// iteration order (for `serde_json::Map` this is sorted key order by
216/// default, or insertion order with the `preserve_order` feature).
217pub 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
228/// Core field-name rename pass (collision-safe, first-win).
229fn 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    // Collect keys that need correction
238    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    // Process each invalid key
245    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            // Only correct if the target field doesn't already exist
253            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
276/// Apply each defined field's [`FieldKind`] to the object's values.
277fn 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
292/// Apply a single [`FieldKind`] to a value (recursive entry point).
293fn 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
352/// Coerce a scalar value toward the expected type (lossless only).
353///
354/// Unparseable or already-correct values are left untouched.
355fn 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
389/// Repair a tagged enum JSON object using a TaggedEnumSchema
390///
391/// This repairs:
392/// 1. The tag field value (e.g., "AddDeriv" -> "AddDerive")
393/// 2. The field names based on the tag value (variant fields + global fields)
394/// 3. Field values according to their [`FieldKind`] — enum arrays, nested
395///    objects (recursively, to any depth), arrays of objects, and scalar
396///    type coercion
397///
398/// # Collision behavior (first-win)
399///
400/// Field-name repair never overwrites an existing key: when two typo keys
401/// resolve to the same candidate, only the first is renamed and the later one
402/// is recorded as a [`SkippedCorrection`]. See [`repair_fields_with_list`]
403/// for details.
404pub 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    // Step 1: Repair tag field value
413    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            // Try to find closest match
416            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; // No tag field, can't repair fields
441    };
442
443    // Step 2: Repair field names (variant fields + global fields)
444    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    // Step 3: Apply variant field kinds (recursive)
463    if let Some(variant) = variant {
464        apply_field_kinds(obj, &variant.fields, path, options, &mut log);
465    }
466
467    // Step 4: Apply global field kinds (enum arrays, nested objects, coercion)
468    apply_field_kinds(obj, &schema.global_fields, path, options, &mut log);
469
470    log
471}
472
473/// Repair values in an enum array
474///
475/// Each string value in the array is fuzzy-matched against `valid_values`.
476pub 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
508/// Repair a tagged enum from JSON string
509pub 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
529/// Repair an array of tagged enums
530pub 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    /// Pins the 0.1 call style (borrowed `'static` slices) so it keeps
555    /// compiling against the 0.2 owned-schema API.
556    #[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        // With very high threshold, typo should not be corrected
657        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        // Tag value repaired
738        assert_eq!(result.repaired["type"], "AddDerive");
739        // Field name repaired
740        assert!(result.repaired.get("target").is_some());
741        assert_eq!(result.repaired["target"], "User");
742        // Enum array values repaired
743        assert_eq!(result.repaired["derives"][0], "Debug");
744        assert_eq!(result.repaired["derives"][1], "Clone");
745        // Nested object field repaired
746        assert!(result.repaired["config"].get("timeout").is_some());
747        assert_eq!(result.repaired["config"]["timeout"], 30);
748        // Total corrections: type + target + 2 derives + timeout = 5
749        assert_eq!(result.corrections.len(), 5);
750    }
751
752    #[test]
753    fn test_collision_skip_is_recorded() {
754        // Two typo keys ("taget", "targt") both resolve to "target".
755        // First-win: the first key processed is renamed; the second is left
756        // unchanged and recorded as a SkippedCorrection.
757        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        // Exactly one key won the rename; the other survives verbatim.
764        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        // The losing key is recorded as skipped
771        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        // The candidate already exists as a literal key: the typo key is NOT
780        // renamed onto it (no data loss), and the skip is recorded.
781        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    // ------------------------------------------------------------------
810    // New in 0.2: recursion, object arrays, coercion, enum values, dynamic
811    // ------------------------------------------------------------------
812
813    #[test]
814    fn test_deeply_nested_object_repair() {
815        // depth 3: config -> server -> limits
816        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        // Paths reflect the nesting
844        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        // Coercions are recorded with similarity 1.0
926        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        // Schema built from runtime strings (no 'static required)
965        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        // EnumArray nested inside an Object kind — impossible in 0.1
984        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}