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
6use crate::distance::{find_closest, Algorithm};
7use crate::error::FuzzyError;
8use crate::schema::{ObjectSchema, TaggedEnumSchema};
9use serde_json::{Map, Value};
10
11/// Options for fuzzy repair
12#[derive(Debug, Clone)]
13pub struct FuzzyOptions {
14    /// Minimum similarity threshold (0.0 to 1.0)
15    ///
16    /// Values below this threshold will not be corrected.
17    /// Default: 0.7
18    pub min_similarity: f64,
19
20    /// Algorithm to use for similarity calculation
21    ///
22    /// Default: JaroWinkler (best for typos)
23    pub algorithm: Algorithm,
24}
25
26impl Default for FuzzyOptions {
27    fn default() -> Self {
28        Self {
29            min_similarity: 0.7,
30            algorithm: Algorithm::JaroWinkler,
31        }
32    }
33}
34
35impl FuzzyOptions {
36    /// Create options with a custom minimum similarity threshold
37    pub fn with_min_similarity(mut self, min_similarity: f64) -> Self {
38        self.min_similarity = min_similarity;
39        self
40    }
41
42    /// Create options with a custom algorithm
43    pub fn with_algorithm(mut self, algorithm: Algorithm) -> Self {
44        self.algorithm = algorithm;
45        self
46    }
47}
48
49/// A single correction made during repair
50#[derive(Debug, Clone, PartialEq)]
51pub struct Correction {
52    /// The original (incorrect) value
53    pub original: String,
54    /// The corrected value
55    pub corrected: String,
56    /// Similarity score (0.0 to 1.0)
57    pub similarity: f64,
58    /// JSON path to the corrected field (e.g., "$.type", "$.target")
59    pub field_path: String,
60}
61
62impl Correction {
63    /// Create a new correction
64    pub fn new(original: String, corrected: String, similarity: f64, field_path: String) -> Self {
65        Self {
66            original,
67            corrected,
68            similarity,
69            field_path,
70        }
71    }
72}
73
74/// Result of a repair operation
75#[derive(Debug, Clone)]
76pub struct RepairResult {
77    /// The repaired JSON value
78    pub repaired: Value,
79    /// List of corrections made
80    pub corrections: Vec<Correction>,
81}
82
83impl RepairResult {
84    /// Check if any corrections were made
85    pub fn has_corrections(&self) -> bool {
86        !self.corrections.is_empty()
87    }
88
89    /// Get the number of corrections made
90    pub fn correction_count(&self) -> usize {
91        self.corrections.len()
92    }
93}
94
95// ============================================================================
96// Generic Repair Functions
97// ============================================================================
98
99/// Repair field names in a JSON object using an ObjectSchema
100///
101/// Returns the list of corrections made.
102pub fn repair_object_fields(
103    obj: &mut Map<String, Value>,
104    schema: &ObjectSchema,
105    path: &str,
106    options: &FuzzyOptions,
107) -> Vec<Correction> {
108    repair_fields_with_list(obj, schema.valid_fields, path, options)
109}
110
111/// Repair field names in a JSON object using a field list
112///
113/// Returns the list of corrections made.
114///
115/// # Collision behavior (first-win)
116///
117/// A key is only renamed when the target field does not already exist in the
118/// object. This guards against destroying data: if two typo keys resolve to
119/// the same candidate, the first one processed wins and the later key is left
120/// unchanged (no correction is recorded for it). The same applies when the
121/// candidate is already present as a literal key. Keys are processed in the
122/// object's iteration order (for `serde_json::Map` this is sorted key order
123/// by default, or insertion order with the `preserve_order` feature).
124/// Recording skipped collisions in the result is planned for a future
125/// release, as it requires extending the public result types.
126pub fn repair_fields_with_list(
127    obj: &mut Map<String, Value>,
128    valid_fields: &[&str],
129    path: &str,
130    options: &FuzzyOptions,
131) -> Vec<Correction> {
132    let mut corrections = Vec::new();
133
134    // Collect keys that need correction
135    let keys_to_check: Vec<String> = obj
136        .keys()
137        .filter(|k| !valid_fields.contains(&k.as_str()))
138        .cloned()
139        .collect();
140
141    // Process each invalid key
142    for key in keys_to_check {
143        if let Some(m) = find_closest(
144            &key,
145            valid_fields.iter().copied(),
146            options.min_similarity,
147            options.algorithm,
148        ) {
149            // Only correct if the target field doesn't already exist
150            if !obj.contains_key(&m.candidate) {
151                if let Some(val) = obj.remove(&key) {
152                    corrections.push(Correction::new(
153                        key.clone(),
154                        m.candidate.clone(),
155                        m.similarity,
156                        format!("{}.{}", path, key),
157                    ));
158                    obj.insert(m.candidate, val);
159                }
160            }
161        }
162    }
163
164    corrections
165}
166
167/// Repair a tagged enum JSON object using a TaggedEnumSchema
168///
169/// This repairs:
170/// 1. The tag field value (e.g., "AddDeriv" -> "AddDerive")
171/// 2. The field names based on the tag value
172/// 3. Values in enum array fields (e.g., ["Debg"] -> ["Debug"])
173/// 4. Field names in nested objects
174///
175/// Returns the list of corrections made.
176///
177/// # Collision behavior (first-win)
178///
179/// Field-name repair never overwrites an existing key: when two typo keys
180/// resolve to the same candidate, only the first is renamed and the later one
181/// is left as-is without a recorded correction. See
182/// [`repair_fields_with_list`] for details.
183pub fn repair_tagged_enum<F>(
184    obj: &mut Map<String, Value>,
185    schema: &TaggedEnumSchema<F>,
186    path: &str,
187    options: &FuzzyOptions,
188) -> Vec<Correction>
189where
190    F: Fn(&str) -> Option<&'static [&'static str]>,
191{
192    let mut corrections = Vec::new();
193
194    // Step 1: Repair tag field value
195    let tag_value = if let Some(tag_val) = obj.get(schema.tag_field).and_then(|v| v.as_str()) {
196        if !schema.is_valid_tag(tag_val) {
197            // Try to find closest match
198            if let Some(m) = find_closest(
199                tag_val,
200                schema.valid_tags.iter().copied(),
201                options.min_similarity,
202                options.algorithm,
203            ) {
204                corrections.push(Correction::new(
205                    tag_val.to_string(),
206                    m.candidate.clone(),
207                    m.similarity,
208                    format!("{}.{}", path, schema.tag_field),
209                ));
210                obj.insert(
211                    schema.tag_field.to_string(),
212                    Value::String(m.candidate.clone()),
213                );
214                m.candidate
215            } else {
216                tag_val.to_string()
217            }
218        } else {
219            tag_val.to_string()
220        }
221    } else {
222        return corrections; // No tag field, can't repair fields
223    };
224
225    // Step 2: Repair field names based on tag value
226    if let Some(valid_fields) = schema.get_fields(&tag_value) {
227        // Filter out the tag field itself from the check
228        let keys_to_check: Vec<String> = obj
229            .keys()
230            .filter(|k| *k != schema.tag_field && !valid_fields.contains(&k.as_str()))
231            .cloned()
232            .collect();
233
234        for key in keys_to_check {
235            if let Some(m) = find_closest(
236                &key,
237                valid_fields.iter().copied(),
238                options.min_similarity,
239                options.algorithm,
240            ) {
241                if !obj.contains_key(&m.candidate) {
242                    if let Some(val) = obj.remove(&key) {
243                        corrections.push(Correction::new(
244                            key.clone(),
245                            m.candidate.clone(),
246                            m.similarity,
247                            format!("{}.{}", path, key),
248                        ));
249                        obj.insert(m.candidate, val);
250                    }
251                }
252            }
253        }
254    }
255
256    // Step 3: Repair enum array values
257    for (field_name, valid_values) in &schema.enum_arrays {
258        if let Some(Value::Array(arr)) = obj.get_mut(*field_name) {
259            let field_path = format!("{}.{}", path, field_name);
260            let arr_corrections = repair_enum_array(arr, valid_values, &field_path, options);
261            corrections.extend(arr_corrections);
262        }
263    }
264
265    // Step 4: Repair nested object fields
266    for (field_name, valid_fields) in &schema.nested_objects {
267        if let Some(Value::Object(nested_obj)) = obj.get_mut(*field_name) {
268            let nested_path = format!("{}.{}", path, field_name);
269            let nested_corrections =
270                repair_fields_with_list(nested_obj, valid_fields, &nested_path, options);
271            corrections.extend(nested_corrections);
272        }
273    }
274
275    corrections
276}
277
278/// Repair values in an enum array
279///
280/// Each string value in the array is fuzzy-matched against `valid_values`.
281pub fn repair_enum_array(
282    arr: &mut [Value],
283    valid_values: &[&str],
284    path: &str,
285    options: &FuzzyOptions,
286) -> Vec<Correction> {
287    let mut corrections = Vec::new();
288
289    for (i, item) in arr.iter_mut().enumerate() {
290        if let Value::String(s) = item {
291            if !valid_values.contains(&s.as_str()) {
292                if let Some(m) = find_closest(
293                    s,
294                    valid_values.iter().copied(),
295                    options.min_similarity,
296                    options.algorithm,
297                ) {
298                    corrections.push(Correction::new(
299                        s.clone(),
300                        m.candidate.clone(),
301                        m.similarity,
302                        format!("{}[{}]", path, i),
303                    ));
304                    *item = Value::String(m.candidate);
305                }
306            }
307        }
308    }
309
310    corrections
311}
312
313/// Repair a tagged enum from JSON string
314pub fn repair_tagged_enum_json<F>(
315    json: &str,
316    schema: &TaggedEnumSchema<F>,
317    options: &FuzzyOptions,
318) -> Result<RepairResult, FuzzyError>
319where
320    F: Fn(&str) -> Option<&'static [&'static str]>,
321{
322    let mut value: Value = serde_json::from_str(json)?;
323
324    let corrections = if let Some(obj) = value.as_object_mut() {
325        repair_tagged_enum(obj, schema, "$", options)
326    } else {
327        return Err(FuzzyError::NotObject);
328    };
329
330    Ok(RepairResult {
331        repaired: value,
332        corrections,
333    })
334}
335
336/// Repair an array of tagged enums
337pub fn repair_tagged_enum_array<F>(
338    arr: &mut [Value],
339    schema: &TaggedEnumSchema<F>,
340    path: &str,
341    options: &FuzzyOptions,
342) -> Vec<Correction>
343where
344    F: Fn(&str) -> Option<&'static [&'static str]>,
345{
346    let mut all_corrections = Vec::new();
347
348    for (i, item) in arr.iter_mut().enumerate() {
349        if let Some(obj) = item.as_object_mut() {
350            let item_path = format!("{}[{}]", path, i);
351            let corrections = repair_tagged_enum(obj, schema, &item_path, options);
352            all_corrections.extend(corrections);
353        }
354    }
355
356    all_corrections
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    /// Field resolver signature used by the test schemas: maps a tag to its
364    /// allowed field list. Aliased to keep the `test_schema` return type
365    /// readable (and to satisfy `clippy::type_complexity`).
366    type FieldResolver = fn(&str) -> Option<&'static [&'static str]>;
367
368    fn test_schema() -> TaggedEnumSchema<FieldResolver> {
369        TaggedEnumSchema::new(
370            "type",
371            &["AddDerive", "RemoveDerive", "RenameIdent"],
372            |tag| match tag {
373                "AddDerive" | "RemoveDerive" => Some(&["target", "derives"]),
374                "RenameIdent" => Some(&["from", "to", "kind"]),
375                _ => None,
376            },
377        )
378    }
379
380    #[test]
381    fn test_repair_tagged_enum_type_typo() {
382        let schema = test_schema();
383        let json = r#"{"type": "AddDeriv", "target": "User", "derives": ["Debug"]}"#;
384        let options = FuzzyOptions::default();
385
386        let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
387
388        assert_eq!(result.repaired["type"], "AddDerive");
389        assert_eq!(result.corrections.len(), 1);
390        assert_eq!(result.corrections[0].original, "AddDeriv");
391        assert_eq!(result.corrections[0].corrected, "AddDerive");
392    }
393
394    #[test]
395    fn test_repair_tagged_enum_field_typo() {
396        let schema = test_schema();
397        let json = r#"{"type": "AddDerive", "taget": "User", "derives": ["Debug"]}"#;
398        let options = FuzzyOptions::default();
399
400        let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
401
402        assert!(result.repaired.get("target").is_some());
403        assert!(result.repaired.get("taget").is_none());
404        assert_eq!(result.corrections.len(), 1);
405    }
406
407    #[test]
408    fn test_repair_tagged_enum_multiple_typos() {
409        let schema = test_schema();
410        let json = r#"{"type": "RenamIdent", "form": "old", "too": "new"}"#;
411        let options = FuzzyOptions::default();
412
413        let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
414
415        assert_eq!(result.repaired["type"], "RenameIdent");
416        assert!(result.repaired.get("from").is_some());
417        assert!(result.repaired.get("to").is_some());
418        assert_eq!(result.corrections.len(), 3);
419    }
420
421    #[test]
422    fn test_repair_object_fields() {
423        let schema = ObjectSchema::new(&["name", "module", "derives"]);
424        let mut obj: Map<String, Value> =
425            serde_json::from_str(r#"{"nam": "Test", "modul": "foo"}"#).unwrap();
426        let options = FuzzyOptions::default();
427
428        let corrections = repair_object_fields(&mut obj, &schema, "$", &options);
429
430        assert!(obj.contains_key("name"));
431        assert!(obj.contains_key("module"));
432        assert_eq!(corrections.len(), 2);
433    }
434
435    #[test]
436    fn test_no_correction_needed() {
437        let schema = test_schema();
438        let json = r#"{"type": "AddDerive", "target": "User", "derives": ["Debug"]}"#;
439        let options = FuzzyOptions::default();
440
441        let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
442
443        assert!(!result.has_corrections());
444    }
445
446    #[test]
447    fn test_high_similarity_threshold() {
448        let schema = test_schema();
449        let json = r#"{"type": "AddDeriv", "target": "User", "derives": ["Debug"]}"#;
450        let options = FuzzyOptions::default().with_min_similarity(0.99);
451
452        let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
453
454        // With very high threshold, typo should not be corrected
455        assert_eq!(result.repaired["type"], "AddDeriv");
456        assert!(!result.has_corrections());
457    }
458
459    #[test]
460    fn test_repair_array() {
461        let schema = test_schema();
462        let mut arr: Vec<Value> = serde_json::from_str(
463            r#"[
464                {"type": "AddDeriv", "taget": "User", "derives": ["Debug"]},
465                {"type": "RenamIdent", "form": "old", "too": "new"}
466            ]"#,
467        )
468        .unwrap();
469        let options = FuzzyOptions::default();
470
471        let corrections = repair_tagged_enum_array(&mut arr, &schema, "$.intents", &options);
472
473        assert_eq!(arr[0]["type"], "AddDerive");
474        assert!(arr[0].get("target").is_some());
475        assert_eq!(arr[1]["type"], "RenameIdent");
476        assert!(arr[1].get("from").is_some());
477        assert!(corrections.len() >= 4);
478    }
479
480    #[test]
481    fn test_repair_enum_array_values() {
482        let schema =
483            TaggedEnumSchema::new("type", &["AddDerive"], |_| Some(&["target", "derives"][..]))
484                .with_enum_array("derives", &["Debug", "Clone", "Serialize", "Default"]);
485
486        let json =
487            r#"{"type": "AddDerive", "target": "User", "derives": ["Debg", "Clne", "Serializ"]}"#;
488        let options = FuzzyOptions::default();
489
490        let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
491
492        assert_eq!(result.repaired["derives"][0], "Debug");
493        assert_eq!(result.repaired["derives"][1], "Clone");
494        assert_eq!(result.repaired["derives"][2], "Serialize");
495        assert_eq!(result.corrections.len(), 3);
496    }
497
498    #[test]
499    fn test_repair_nested_object_fields() {
500        let schema =
501            TaggedEnumSchema::new("type", &["Configure"], |_| Some(&["name", "config"][..]))
502                .with_nested_object("config", &["timeout", "retries", "enabled"]);
503
504        let json =
505            r#"{"type": "Configure", "name": "test", "config": {"timout": 30, "retres": 3}}"#;
506        let options = FuzzyOptions::default();
507
508        let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
509
510        assert!(result.repaired["config"].get("timeout").is_some());
511        assert!(result.repaired["config"].get("retries").is_some());
512        assert_eq!(result.repaired["config"]["timeout"], 30);
513        assert_eq!(result.repaired["config"]["retries"], 3);
514        assert_eq!(result.corrections.len(), 2);
515    }
516
517    #[test]
518    fn test_repair_combined_all_features() {
519        let schema = TaggedEnumSchema::new("type", &["AddDerive"], |_| {
520            Some(&["target", "derives", "config"][..])
521        })
522        .with_enum_array("derives", &["Debug", "Clone", "Serialize"])
523        .with_nested_object("config", &["timeout", "retries"]);
524
525        let json = r#"{
526            "type": "AddDeriv",
527            "taget": "User",
528            "derives": ["Debg", "Clne"],
529            "config": {"timout": 30}
530        }"#;
531        let options = FuzzyOptions::default();
532
533        let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
534
535        // Tag value repaired
536        assert_eq!(result.repaired["type"], "AddDerive");
537        // Field name repaired
538        assert!(result.repaired.get("target").is_some());
539        assert_eq!(result.repaired["target"], "User");
540        // Enum array values repaired
541        assert_eq!(result.repaired["derives"][0], "Debug");
542        assert_eq!(result.repaired["derives"][1], "Clone");
543        // Nested object field repaired
544        assert!(result.repaired["config"].get("timeout").is_some());
545        assert_eq!(result.repaired["config"]["timeout"], 30);
546        // Total corrections: type + target + 2 derives + timeout = 5
547        assert_eq!(result.corrections.len(), 5);
548    }
549
550    #[test]
551    fn test_collision_first_win_two_typos_same_candidate() {
552        // Two typo keys ("taget", "targt") both resolve to "target".
553        // First-win: the first key processed is renamed, the second is left
554        // unchanged and no correction is recorded for it. This test pins the
555        // current behavior; recording skipped collisions requires extending
556        // the public result types and is deferred to a future release.
557        let json = r#"{"type": "AddDerive", "taget": "User", "targt": "Post"}"#;
558        let schema = test_schema();
559        let options = FuzzyOptions::default();
560
561        let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
562
563        // Exactly one key won the rename; the other survives verbatim.
564        assert_eq!(result.repaired["target"], "User");
565        assert_eq!(result.repaired["targt"], "Post");
566        assert!(result.repaired.get("taget").is_none());
567        assert_eq!(result.corrections.len(), 1);
568        assert_eq!(result.corrections[0].original, "taget");
569        assert_eq!(result.corrections[0].corrected, "target");
570    }
571
572    #[test]
573    fn test_collision_first_win_existing_key_preserved() {
574        // The candidate already exists as a literal key: the typo key is NOT
575        // renamed onto it (no data loss), and no correction is recorded.
576        let json = r#"{"type": "AddDerive", "target": "User", "taget": "Post"}"#;
577        let schema = test_schema();
578        let options = FuzzyOptions::default();
579
580        let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
581
582        assert_eq!(result.repaired["target"], "User");
583        assert_eq!(result.repaired["taget"], "Post");
584        assert!(!result.has_corrections());
585    }
586
587    #[test]
588    fn test_repair_enum_array_no_correction_needed() {
589        let schema =
590            TaggedEnumSchema::new("type", &["AddDerive"], |_| Some(&["target", "derives"][..]))
591                .with_enum_array("derives", &["Debug", "Clone"]);
592
593        let json = r#"{"type": "AddDerive", "target": "User", "derives": ["Debug", "Clone"]}"#;
594        let options = FuzzyOptions::default();
595
596        let result = repair_tagged_enum_json(json, &schema, &options).unwrap();
597
598        assert!(!result.has_corrections());
599    }
600}