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::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
364/// Coerce a scalar value toward the expected type (lossless only).
365///
366/// Unparseable or already-correct values are left untouched.
367fn 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
401/// Repair a tagged enum JSON object using a TaggedEnumSchema
402///
403/// This repairs:
404/// 1. The tag field value (e.g., "AddDeriv" -> "AddDerive")
405/// 2. The field names based on the tag value (variant fields + global fields)
406/// 3. Field values according to their [`FieldKind`] — enum arrays, nested
407///    objects (recursively, to any depth), arrays of objects, and scalar
408///    type coercion
409///
410/// # Collision behavior (first-win)
411///
412/// Field-name repair never overwrites an existing key: when two typo keys
413/// resolve to the same candidate, only the first is renamed and the later one
414/// is recorded as a [`SkippedCorrection`]. See [`repair_fields_with_list`]
415/// for details.
416pub 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    // Step 1: Repair tag field value
425    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            // Try to find closest match
428            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; // No tag field, can't repair fields
453    };
454
455    // Step 2: Repair field names (variant fields + global fields)
456    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    // Step 3: Apply variant field kinds (recursive)
475    if let Some(variant) = variant {
476        apply_field_kinds(obj, &variant.fields, path, options, &mut log);
477    }
478
479    // Step 4: Apply global field kinds (enum arrays, nested objects, coercion)
480    apply_field_kinds(obj, &schema.global_fields, path, options, &mut log);
481
482    log
483}
484
485/// Repair values in an enum array
486///
487/// Each string value in the array is fuzzy-matched against `valid_values`.
488pub 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
520/// Repair a tagged enum from JSON string
521pub 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
541/// Repair an array of tagged enums
542pub 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    /// Pins the 0.1 call style (borrowed `'static` slices) so it keeps
567    /// compiling against the 0.2 owned-schema API.
568    #[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        // With very high threshold, typo should not be corrected
669        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        // Tag value repaired
750        assert_eq!(result.repaired["type"], "AddDerive");
751        // Field name repaired
752        assert!(result.repaired.get("target").is_some());
753        assert_eq!(result.repaired["target"], "User");
754        // Enum array values repaired
755        assert_eq!(result.repaired["derives"][0], "Debug");
756        assert_eq!(result.repaired["derives"][1], "Clone");
757        // Nested object field repaired
758        assert!(result.repaired["config"].get("timeout").is_some());
759        assert_eq!(result.repaired["config"]["timeout"], 30);
760        // Total corrections: type + target + 2 derives + timeout = 5
761        assert_eq!(result.corrections.len(), 5);
762    }
763
764    #[test]
765    fn test_collision_skip_is_recorded() {
766        // Two typo keys ("taget", "targt") both resolve to "target".
767        // First-win: the first key processed is renamed; the second is left
768        // unchanged and recorded as a SkippedCorrection.
769        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        // Exactly one key won the rename; the other survives verbatim.
776        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        // The losing key is recorded as skipped
783        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        // The candidate already exists as a literal key: the typo key is NOT
792        // renamed onto it (no data loss), and the skip is recorded.
793        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    // ------------------------------------------------------------------
822    // New in 0.2: recursion, object arrays, coercion, enum values, dynamic
823    // ------------------------------------------------------------------
824
825    #[test]
826    fn test_deeply_nested_object_repair() {
827        // depth 3: config -> server -> limits
828        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        // Paths reflect the nesting
856        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        // Coercions are recorded with similarity 1.0
938        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        // Schema built from runtime strings (no 'static required)
977        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        // A variant field holds another tagged enum
996        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        // A variant field holds an array of tagged enums (DSL intents)
1025        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        // EnumArray nested inside an Object kind — impossible in 0.1
1058        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}