Skip to main content

fuzzy_parser/
import.rs

1//! Importing repair schemas from JSON Schema documents
2//!
3//! Converts a JSON Schema (as a `serde_json::Value`) into a
4//! [`TaggedEnumSchema`] or [`ObjectSchema`], so schemas produced by
5//! `schemars`, Pydantic, or any other tool can drive fuzzy repair without
6//! hand-written builder code. With the `schemars` cargo feature enabled,
7//! [`TaggedEnumSchema::from_type`] / [`ObjectSchema::from_type`] derive the
8//! repair schema directly from a `#[derive(JsonSchema)]` type.
9//!
10//! # Supported subset (v1)
11//!
12//! - **Tagged enums**: `oneOf` where every branch carries a common tag
13//!   property with a `const` (or single-element `enum`) string value — the
14//!   shape schemars emits for `#[serde(tag = "...")]` (internally tagged)
15//!   and `#[serde(tag = "...", content = "...")]` (adjacently tagged).
16//! - **Keywords**: `properties`, `enum`, `const`, `type`, `items`,
17//!   `$ref` / `$defs` (also legacy `definitions`), including the
18//!   Draft 2020-12 sibling-`$ref` composition schemars emits for newtype
19//!   variants. `Option<T>`-style nullable wrappers
20//!   (`anyOf: [T, {type: null}]`) are unwrapped.
21//! - **Type mapping**: `string` → [`FieldKind::String`],
22//!   `integer` → [`FieldKind::Integer`], `number` → [`FieldKind::Number`],
23//!   `boolean` → [`FieldKind::Bool`], `object` → [`FieldKind::Object`]
24//!   (recursive), arrays of string enums → [`FieldKind::EnumArray`],
25//!   arrays of objects → [`FieldKind::ObjectArray`].
26//! - **Nested tagged enums**: a field whose schema is itself a
27//!   `oneOf` + tag `const` union maps to [`FieldKind::TaggedEnum`]
28//!   (arrays of them to [`FieldKind::TaggedEnumArray`]), so DSL shapes
29//!   like `intents: [tagged enum, ...]` repair recursively.
30//!
31//! # Deliberately unsupported (v1)
32//!
33//! - **Externally tagged enums** (serde's default representation) and
34//!   **untagged enums**: rejected with an error. Annotate the enum with
35//!   `#[serde(tag = "...")]` so a tag field exists for repair to anchor on.
36//! - **`required` / `default` completion**: missing fields are not filled
37//!   in; the keywords are ignored.
38//! - Constructs with no repair semantics (`allOf`, `patternProperties`,
39//!   tuples via `prefixItems`, non-tagged `oneOf` / `anyOf` on fields,
40//!   recursive `$ref` cycles) degrade to [`FieldKind::Any`] and are
41//!   reported as [`ImportWarning`]s rather than silently dropped.
42
43use crate::error::FuzzyError;
44use crate::schema::{FieldKind, ObjectSchema, TaggedEnumSchema};
45use serde_json::{Map, Value};
46
47/// Result of a JSON Schema import: the converted schema plus warnings for
48/// every construct that could not be mapped (degraded to [`FieldKind::Any`]).
49#[derive(Debug, Clone)]
50pub struct SchemaImport<S> {
51    /// The converted repair schema
52    pub schema: S,
53    /// Constructs that were skipped or degraded during conversion
54    pub warnings: Vec<ImportWarning>,
55}
56
57/// A construct in the source JSON Schema that could not be converted.
58///
59/// The affected field degrades to [`FieldKind::Any`] (no value repair)
60/// instead of failing the whole import.
61#[derive(Debug, Clone, PartialEq)]
62pub struct ImportWarning {
63    /// Dotted path of the affected location in the source schema
64    /// (e.g., `$.oneOf[2].properties.items`)
65    pub path: String,
66    /// What was skipped and why
67    pub detail: String,
68}
69
70impl TaggedEnumSchema {
71    /// Convert a JSON Schema document (e.g. `schemars` output) into a
72    /// [`TaggedEnumSchema`].
73    ///
74    /// Expects a `oneOf` at the root whose branches share a tag property
75    /// with a `const` string — the shape produced by
76    /// `#[serde(tag = "type")]` (see the [module docs](crate::import) for
77    /// the supported subset).
78    ///
79    /// # Example
80    ///
81    /// ```
82    /// use fuzzy_parser::{TaggedEnumSchema, FieldKind};
83    /// use serde_json::json;
84    ///
85    /// let json_schema = json!({
86    ///     "oneOf": [
87    ///         {
88    ///             "type": "object",
89    ///             "properties": {
90    ///                 "type": {"type": "string", "const": "AddDerive"},
91    ///                 "target": {"type": "string"},
92    ///                 "derives": {"type": "array", "items": {"type": "string", "enum": ["Debug", "Clone"]}}
93    ///             },
94    ///             "required": ["type", "target"]
95    ///         }
96    ///     ]
97    /// });
98    ///
99    /// let import = TaggedEnumSchema::from_json_schema(&json_schema).unwrap();
100    /// assert!(import.schema.is_valid_tag("AddDerive"));
101    /// assert!(import.warnings.is_empty());
102    /// ```
103    pub fn from_json_schema(root: &Value) -> Result<SchemaImport<TaggedEnumSchema>, FuzzyError> {
104        let mut ctx = ImportCtx::new(root);
105        let root_obj = ctx
106            .resolve_schema_object(root, "$")
107            .ok_or_else(|| FuzzyError::SchemaImport("root is not an object schema".into()))?;
108
109        let Some(branches) = root_obj.get("oneOf").and_then(Value::as_array) else {
110            if root_obj.contains_key("anyOf") {
111                return Err(FuzzyError::SchemaImport(
112                    "root uses `anyOf` — untagged enums are not supported; \
113                     annotate the enum with #[serde(tag = \"...\")]"
114                        .into(),
115                ));
116            }
117            return Err(FuzzyError::SchemaImport(
118                "expected `oneOf` at the schema root (internally or adjacently \
119                 tagged enum); for plain objects use ObjectSchema::from_json_schema"
120                    .into(),
121            ));
122        };
123
124        let schema = ctx
125            .convert_tagged_enum(branches, "$")
126            .map_err(FuzzyError::SchemaImport)?;
127
128        Ok(SchemaImport {
129            schema,
130            warnings: ctx.warnings,
131        })
132    }
133}
134
135impl ObjectSchema {
136    /// Convert a JSON Schema document for a plain (non-enum) object into
137    /// an [`ObjectSchema`].
138    ///
139    /// # Example
140    ///
141    /// ```
142    /// use fuzzy_parser::{FieldKind, ObjectSchema};
143    /// use serde_json::json;
144    ///
145    /// let json_schema = json!({
146    ///     "type": "object",
147    ///     "properties": {
148    ///         "name": {"type": "string"},
149    ///         "timeout": {"type": "integer"}
150    ///     }
151    /// });
152    ///
153    /// let import = ObjectSchema::from_json_schema(&json_schema).unwrap();
154    /// assert_eq!(import.schema.kind_of("timeout"), Some(&FieldKind::Integer));
155    /// ```
156    pub fn from_json_schema(root: &Value) -> Result<SchemaImport<ObjectSchema>, FuzzyError> {
157        let mut ctx = ImportCtx::new(root);
158        let root_obj = ctx
159            .resolve_schema_object(root, "$")
160            .ok_or_else(|| FuzzyError::SchemaImport("root is not an object schema".into()))?;
161
162        if !root_obj.contains_key("properties") {
163            return Err(FuzzyError::SchemaImport(
164                "expected an object schema with `properties` at the root".into(),
165            ));
166        }
167
168        let schema = ctx.convert_object(&root_obj, "$", None);
169        Ok(SchemaImport {
170            schema,
171            warnings: ctx.warnings,
172        })
173    }
174}
175
176#[cfg(feature = "schemars")]
177impl TaggedEnumSchema {
178    /// Derive a [`TaggedEnumSchema`] from a `#[derive(JsonSchema)]` type.
179    ///
180    /// The type must serialize as an internally or adjacently tagged enum
181    /// (`#[serde(tag = "...")]`). Requires the `schemars` cargo feature.
182    pub fn from_type<T: schemars::JsonSchema>() -> Result<SchemaImport<TaggedEnumSchema>, FuzzyError>
183    {
184        let json_schema = schemars::schema_for!(T);
185        let value = serde_json::to_value(&json_schema)?;
186        Self::from_json_schema(&value)
187    }
188}
189
190#[cfg(feature = "schemars")]
191impl ObjectSchema {
192    /// Derive an [`ObjectSchema`] from a `#[derive(JsonSchema)]` struct.
193    ///
194    /// Requires the `schemars` cargo feature.
195    pub fn from_type<T: schemars::JsonSchema>() -> Result<SchemaImport<ObjectSchema>, FuzzyError> {
196        let json_schema = schemars::schema_for!(T);
197        let value = serde_json::to_value(&json_schema)?;
198        Self::from_json_schema(&value)
199    }
200}
201
202// ============================================================================
203// Conversion internals
204// ============================================================================
205
206/// Conversion state: the schema document (for `$ref` resolution), the
207/// accumulated warnings, and the active `$ref` stack (cycle guard).
208struct ImportCtx<'a> {
209    root: &'a Value,
210    warnings: Vec<ImportWarning>,
211    ref_stack: Vec<String>,
212}
213
214impl<'a> ImportCtx<'a> {
215    fn new(root: &'a Value) -> Self {
216        Self {
217            root,
218            warnings: Vec::new(),
219            ref_stack: Vec::new(),
220        }
221    }
222
223    fn warn(&mut self, path: &str, detail: impl Into<String>) {
224        self.warnings.push(ImportWarning {
225            path: path.to_string(),
226            detail: detail.into(),
227        });
228    }
229
230    /// Look up an internal `$ref` (`#/$defs/Name` or `#/definitions/Name`).
231    fn lookup_ref(&self, reference: &str) -> Option<Value> {
232        let pointer = reference.strip_prefix('#')?;
233        self.root.pointer(pointer).cloned()
234    }
235
236    /// Fully resolve a schema node into an object map, following `$ref`
237    /// chains and merging sibling keywords (Draft 2020-12 allows `$ref`
238    /// alongside other keywords). Returns `None` on cycles, external refs,
239    /// or non-object schemas (a warning is recorded).
240    fn resolve_schema_object(&mut self, value: &Value, path: &str) -> Option<Map<String, Value>> {
241        let mut current = value.clone();
242        let mut visited: Vec<String> = Vec::new();
243
244        loop {
245            let Value::Object(obj) = &current else {
246                self.warn(path, "not an object schema");
247                return None;
248            };
249            let Some(reference) = obj.get("$ref").and_then(Value::as_str).map(str::to_string)
250            else {
251                return Some(obj.clone());
252            };
253            if visited.contains(&reference) {
254                self.warn(path, format!("recursive $ref `{}` cut", reference));
255                return None;
256            }
257            let Some(target) = self.lookup_ref(&reference) else {
258                self.warn(path, format!("unresolvable $ref `{}`", reference));
259                return None;
260            };
261            let merged = merge_sibling_ref(&target, obj);
262            visited.push(reference);
263            current = merged;
264        }
265    }
266
267    /// Convert a `oneOf` branch list into a [`TaggedEnumSchema`].
268    ///
269    /// Errors are returned as plain strings so callers can choose between
270    /// a hard error (schema root) and a warning + [`FieldKind::Any`]
271    /// degradation (field position).
272    fn convert_tagged_enum(
273        &mut self,
274        branches: &[Value],
275        base_path: &str,
276    ) -> Result<TaggedEnumSchema, String> {
277        // Resolve each branch (newtype variants carry a sibling $ref)
278        let mut resolved = Vec::with_capacity(branches.len());
279        for (i, branch) in branches.iter().enumerate() {
280            let path = format!("{}.oneOf[{}]", base_path, i);
281            let obj = self
282                .resolve_schema_object(branch, &path)
283                .ok_or_else(|| format!("{} is not an object schema", path))?;
284            resolved.push(obj);
285        }
286
287        // Detect the tag field: a property present in every branch with a
288        // const (or single-element enum) string value.
289        let candidates = tag_candidates(&resolved);
290        let tag_field = match candidates.len() {
291            1 => candidates.into_iter().next().expect("len checked"),
292            0 => {
293                return Err(
294                    "no common tag property with a `const` string was found across \
295                     `oneOf` branches; externally tagged enums (serde's default \
296                     representation) are not supported — annotate the enum with \
297                     #[serde(tag = \"...\")]"
298                        .into(),
299                )
300            }
301            _ => {
302                return Err(format!(
303                    "ambiguous tag field: multiple const properties are shared by \
304                     every `oneOf` branch: {:?}",
305                    candidates
306                ))
307            }
308        };
309
310        let mut schema = TaggedEnumSchema::with_tag(&tag_field);
311        for (i, branch) in resolved.iter().enumerate() {
312            let path = format!("{}.oneOf[{}]", base_path, i);
313            let tag_value = branch
314                .get("properties")
315                .and_then(Value::as_object)
316                .and_then(|props| props.get(&tag_field))
317                .and_then(tag_string)
318                .expect("tag candidates verified per branch");
319            if schema.is_valid_tag(&tag_value) {
320                self.warn(
321                    &path,
322                    format!(
323                        "duplicate tag value `{}`; branch replaces the earlier one",
324                        tag_value
325                    ),
326                );
327            }
328            let variant = self.convert_object(branch, &path, Some(&tag_field));
329            schema = schema.with_variant(tag_value, variant);
330        }
331
332        Ok(schema)
333    }
334
335    /// Convert an (already resolved) object schema's `properties` into an
336    /// [`ObjectSchema`], skipping the tag field if given.
337    fn convert_object(
338        &mut self,
339        obj: &Map<String, Value>,
340        path: &str,
341        skip: Option<&str>,
342    ) -> ObjectSchema {
343        for keyword in ["allOf", "patternProperties", "if", "not"] {
344            if obj.contains_key(keyword) {
345                self.warn(path, format!("unsupported keyword `{}` ignored", keyword));
346            }
347        }
348
349        let mut schema = ObjectSchema::empty();
350        if let Some(props) = obj.get("properties").and_then(Value::as_object) {
351            for (name, prop) in props {
352                if Some(name.as_str()) == skip {
353                    continue;
354                }
355                let field_path = format!("{}.properties.{}", path, name);
356                let kind = self.convert_kind(prop, &field_path);
357                schema = schema.with_field_kind(name, kind);
358            }
359        }
360        schema
361    }
362
363    /// Convert a schema node describing a field *value* into a [`FieldKind`].
364    fn convert_kind(&mut self, prop: &Value, path: &str) -> FieldKind {
365        let Some(obj) = prop.as_object() else {
366            // `true` / `false` schemas: accept anything / nothing — no repair
367            return FieldKind::Any;
368        };
369
370        // Follow $ref (with sibling merge and cycle guard)
371        if let Some(reference) = obj.get("$ref").and_then(Value::as_str).map(str::to_string) {
372            if self.ref_stack.contains(&reference) {
373                self.warn(path, format!("recursive $ref `{}` cut to Any", reference));
374                return FieldKind::Any;
375            }
376            let Some(target) = self.lookup_ref(&reference) else {
377                self.warn(path, format!("unresolvable $ref `{}`; left as Any", reference));
378                return FieldKind::Any;
379            };
380            let merged = merge_sibling_ref(&target, obj);
381            self.ref_stack.push(reference);
382            let kind = self.convert_kind(&merged, path);
383            self.ref_stack.pop();
384            return kind;
385        }
386
387        // Closed string sets
388        if let Some(values) = obj.get("enum").and_then(Value::as_array) {
389            if let Some(strings) = all_strings(values) {
390                return FieldKind::Enum(strings);
391            }
392            self.warn(path, "`enum` with non-string values; left as Any");
393            return FieldKind::Any;
394        }
395        if let Some(constant) = obj.get("const") {
396            if let Some(s) = constant.as_str() {
397                return FieldKind::Enum(vec![s.to_string()]);
398            }
399            return FieldKind::Any;
400        }
401
402        // Compositions: unwrap Option<T>-style nullables; a `oneOf` may be
403        // a nested tagged enum, which converts to its own repair schema.
404        for keyword in ["anyOf", "oneOf"] {
405            if let Some(branches) = obj.get(keyword).and_then(Value::as_array) {
406                if let Some(inner) = nullable_unwrap(branches) {
407                    return self.convert_kind(inner, path);
408                }
409                if keyword == "oneOf" {
410                    match self.convert_tagged_enum(branches, path) {
411                        Ok(nested) => return FieldKind::TaggedEnum(nested),
412                        Err(reason) => {
413                            self.warn(
414                                path,
415                                format!(
416                                    "`oneOf` on a field could not be imported as a \
417                                     tagged enum ({}); left as Any",
418                                    reason
419                                ),
420                            );
421                            return FieldKind::Any;
422                        }
423                    }
424                }
425                self.warn(
426                    path,
427                    "`anyOf` composition on a field is not supported; left as Any",
428                );
429                return FieldKind::Any;
430            }
431        }
432
433        match type_of(obj) {
434            Some("string") => FieldKind::String,
435            Some("integer") => FieldKind::Integer,
436            Some("number") => FieldKind::Number,
437            Some("boolean") => FieldKind::Bool,
438            Some("object") => FieldKind::Object(self.convert_object(obj, path, None)),
439            Some("array") => {
440                if obj.contains_key("prefixItems") {
441                    self.warn(path, "tuple (`prefixItems`) is not supported; left as Any");
442                    return FieldKind::Any;
443                }
444                let Some(items) = obj.get("items") else {
445                    return FieldKind::Any;
446                };
447                match self.convert_kind(items, &format!("{}.items", path)) {
448                    FieldKind::Object(inner) => FieldKind::ObjectArray(inner),
449                    FieldKind::TaggedEnum(inner) => FieldKind::TaggedEnumArray(inner),
450                    FieldKind::Enum(values) => FieldKind::EnumArray(values),
451                    // Arrays of plain scalars: nothing to repair per-element
452                    _ => FieldKind::Any,
453                }
454            }
455            // No / unknown type (accept-anything schema): no value repair
456            _ => FieldKind::Any,
457        }
458    }
459}
460
461/// Merge a `$ref` target with the sibling keywords of the referencing node
462/// (Draft 2020-12 sibling-`$ref` composition). Sibling keys win; the
463/// `properties` maps are unioned.
464fn merge_sibling_ref(target: &Value, sibling: &Map<String, Value>) -> Value {
465    let mut merged = target.clone();
466    let Value::Object(out) = &mut merged else {
467        // Target is a bool schema: the siblings alone carry the shape
468        let mut obj = sibling.clone();
469        obj.remove("$ref");
470        return Value::Object(obj);
471    };
472
473    for (key, value) in sibling {
474        if key == "$ref" {
475            continue;
476        }
477        match (out.get_mut(key), value) {
478            (Some(Value::Object(dst)), Value::Object(src)) if key == "properties" => {
479                for (prop_name, prop_value) in src {
480                    dst.insert(prop_name.clone(), prop_value.clone());
481                }
482            }
483            _ => {
484                out.insert(key.clone(), value.clone());
485            }
486        }
487    }
488    merged
489}
490
491/// Extract the tag string from a tag property schema: `const` or a
492/// single-element `enum`.
493fn tag_string(prop: &Value) -> Option<String> {
494    if let Some(s) = prop.get("const").and_then(Value::as_str) {
495        return Some(s.to_string());
496    }
497    if let Some(values) = prop.get("enum").and_then(Value::as_array) {
498        if let [single] = values.as_slice() {
499            return single.as_str().map(str::to_string);
500        }
501    }
502    None
503}
504
505/// Property names that carry a tag string in *every* branch, in the first
506/// branch's (deterministic) property order.
507fn tag_candidates(branches: &[Map<String, Value>]) -> Vec<String> {
508    let Some(first) = branches.first() else {
509        return Vec::new();
510    };
511    let Some(first_props) = first.get("properties").and_then(Value::as_object) else {
512        return Vec::new();
513    };
514
515    first_props
516        .iter()
517        .filter(|(_, prop)| tag_string(prop).is_some())
518        .map(|(name, _)| name.clone())
519        .filter(|name| {
520            branches.iter().skip(1).all(|branch| {
521                branch
522                    .get("properties")
523                    .and_then(Value::as_object)
524                    .and_then(|props| props.get(name))
525                    .and_then(tag_string)
526                    .is_some()
527            })
528        })
529        .collect()
530}
531
532/// Unwrap `Option<T>`-style nullable compositions: exactly two branches,
533/// one of which is `{"type": "null"}`.
534fn nullable_unwrap(branches: &[Value]) -> Option<&Value> {
535    let is_null =
536        |v: &Value| v.get("type").and_then(Value::as_str) == Some("null");
537    match branches {
538        [a, b] if is_null(a) && !is_null(b) => Some(b),
539        [a, b] if is_null(b) && !is_null(a) => Some(a),
540        _ => None,
541    }
542}
543
544/// Read the `type` keyword, tolerating the `["T", "null"]` nullable form.
545fn type_of(obj: &Map<String, Value>) -> Option<&str> {
546    match obj.get("type")? {
547        Value::String(s) => Some(s.as_str()),
548        Value::Array(types) => {
549            let non_null: Vec<&str> = types
550                .iter()
551                .filter_map(Value::as_str)
552                .filter(|t| *t != "null")
553                .collect();
554            match non_null.as_slice() {
555                [single] => Some(single),
556                _ => None,
557            }
558        }
559        _ => None,
560    }
561}
562
563/// Collect an all-string JSON array into owned strings.
564fn all_strings(values: &[Value]) -> Option<Vec<String>> {
565    values
566        .iter()
567        .map(|v| v.as_str().map(str::to_string))
568        .collect()
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use crate::repair::{repair_tagged_enum_json, FuzzyOptions};
575    use serde_json::json;
576
577    /// The shape schemars emits for `#[serde(tag = "tag")]` (internally
578    /// tagged): unit variant, struct variant, and a newtype variant with a
579    /// sibling `$ref` (verbatim structure from the schemars integration
580    /// snapshot for Draft 2020-12).
581    fn internally_tagged_schema() -> Value {
582        json!({
583            "$schema": "https://json-schema.org/draft/2020-12/schema",
584            "title": "Internal",
585            "oneOf": [
586                {
587                    "type": "object",
588                    "properties": {
589                        "tag": {"type": "string", "const": "UnitOne"}
590                    },
591                    "required": ["tag"]
592                },
593                {
594                    "type": "object",
595                    "properties": {
596                        "foo": {"type": "integer", "format": "int32"},
597                        "bar": {"type": "boolean"},
598                        "tag": {"type": "string", "const": "Struct"}
599                    },
600                    "required": ["tag", "foo", "bar"]
601                },
602                {
603                    "type": "object",
604                    "properties": {
605                        "tag": {"type": "string", "const": "StructNewType"}
606                    },
607                    "$ref": "#/$defs/Struct",
608                    "required": ["tag"]
609                }
610            ],
611            "$defs": {
612                "Struct": {
613                    "type": "object",
614                    "properties": {
615                        "foo": {"type": "integer", "format": "int32"},
616                        "bar": {"type": "boolean"}
617                    },
618                    "required": ["foo", "bar"]
619                }
620            }
621        })
622    }
623
624    #[test]
625    fn test_import_internally_tagged() {
626        let import = TaggedEnumSchema::from_json_schema(&internally_tagged_schema()).unwrap();
627        let schema = &import.schema;
628
629        assert_eq!(schema.tag_field, "tag");
630        assert!(schema.is_valid_tag("UnitOne"));
631        assert!(schema.is_valid_tag("Struct"));
632        assert!(schema.is_valid_tag("StructNewType"));
633        assert!(import.warnings.is_empty());
634
635        // Struct variant: fields mapped with coercion kinds
636        let variant = schema.variant_schema("Struct").unwrap();
637        assert_eq!(variant.kind_of("foo"), Some(&FieldKind::Integer));
638        assert_eq!(variant.kind_of("bar"), Some(&FieldKind::Bool));
639        // Tag field is not part of the variant fields
640        assert!(!variant.is_valid_field("tag"));
641
642        // Newtype variant: sibling $ref resolved into the same fields
643        let newtype = schema.variant_schema("StructNewType").unwrap();
644        assert_eq!(newtype.kind_of("foo"), Some(&FieldKind::Integer));
645    }
646
647    #[test]
648    fn test_import_then_repair_end_to_end() {
649        let import = TaggedEnumSchema::from_json_schema(&internally_tagged_schema()).unwrap();
650
651        // Tag typo + field typo + string-encoded integer
652        let llm_json = r#"{"tag": "Strct", "fo": "42", "bar": true}"#;
653        let result =
654            repair_tagged_enum_json(llm_json, &import.schema, &FuzzyOptions::default()).unwrap();
655
656        assert_eq!(result.repaired["tag"], "Struct");
657        assert_eq!(result.repaired["foo"], 42);
658        assert_eq!(result.corrections.len(), 3); // tag + rename + coercion
659    }
660
661    #[test]
662    fn test_import_adjacently_tagged() {
663        // schemars output shape for #[serde(tag = "tag", content = "content")]
664        let schema_doc = json!({
665            "oneOf": [
666                {
667                    "type": "object",
668                    "properties": {
669                        "tag": {"type": "string", "const": "Struct"},
670                        "content": {
671                            "type": "object",
672                            "properties": {
673                                "foo": {"type": "integer"},
674                                "bar": {"type": "boolean"}
675                            }
676                        }
677                    },
678                    "required": ["tag", "content"]
679                }
680            ]
681        });
682
683        let import = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap();
684        let variant = import.schema.variant_schema("Struct").unwrap();
685        let Some(FieldKind::Object(content)) = variant.kind_of("content") else {
686            panic!("expected content to map to an Object kind");
687        };
688        assert_eq!(content.kind_of("foo"), Some(&FieldKind::Integer));
689
690        // Repair reaches inside the content payload
691        let llm_json = r#"{"tag": "Struct", "content": {"fo": 1, "bar": false}}"#;
692        let result =
693            repair_tagged_enum_json(llm_json, &import.schema, &FuzzyOptions::default()).unwrap();
694        assert!(result.repaired["content"].get("foo").is_some());
695    }
696
697    #[test]
698    fn test_import_externally_tagged_rejected() {
699        // serde's default representation: variant name as the only key
700        let schema_doc = json!({
701            "oneOf": [
702                {
703                    "type": "object",
704                    "properties": {"StringNewType": {"type": "string"}},
705                    "additionalProperties": false,
706                    "required": ["StringNewType"]
707                },
708                {
709                    "type": "object",
710                    "properties": {"StructVariant": {"type": "object"}},
711                    "additionalProperties": false,
712                    "required": ["StructVariant"]
713                }
714            ]
715        });
716
717        let err = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap_err();
718        let message = err.to_string();
719        assert!(message.contains("serde(tag"), "got: {}", message);
720    }
721
722    #[test]
723    fn test_import_untagged_rejected() {
724        let schema_doc = json!({
725            "anyOf": [
726                {"type": "null"},
727                {"type": "integer"},
728                {"type": "object", "properties": {"foo": {"type": "integer"}}}
729            ]
730        });
731
732        let err = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap_err();
733        assert!(err.to_string().contains("untagged"));
734    }
735
736    #[test]
737    fn test_import_ambiguous_tag_rejected() {
738        // Two const properties shared by every branch
739        let schema_doc = json!({
740            "oneOf": [
741                {
742                    "type": "object",
743                    "properties": {
744                        "type": {"type": "string", "const": "A"},
745                        "version": {"type": "string", "const": "1"}
746                    }
747                },
748                {
749                    "type": "object",
750                    "properties": {
751                        "type": {"type": "string", "const": "B"},
752                        "version": {"type": "string", "const": "1"}
753                    }
754                }
755            ]
756        });
757
758        let err = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap_err();
759        assert!(err.to_string().contains("ambiguous"));
760    }
761
762    #[test]
763    fn test_import_recursive_ref_cut_with_warning() {
764        // struct Node { name: String, children: Vec<Node> }
765        let schema_doc = json!({
766            "oneOf": [
767                {
768                    "type": "object",
769                    "properties": {
770                        "type": {"type": "string", "const": "Tree"},
771                        "root": {"$ref": "#/$defs/Node"}
772                    }
773                }
774            ],
775            "$defs": {
776                "Node": {
777                    "type": "object",
778                    "properties": {
779                        "name": {"type": "string"},
780                        "children": {"type": "array", "items": {"$ref": "#/$defs/Node"}}
781                    }
782                }
783            }
784        });
785
786        let import = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap();
787        // One level of Node is converted...
788        let variant = import.schema.variant_schema("Tree").unwrap();
789        let Some(FieldKind::Object(node)) = variant.kind_of("root") else {
790            panic!("expected root to map to an Object kind");
791        };
792        assert_eq!(node.kind_of("name"), Some(&FieldKind::String));
793        // ...and the self-reference is cut to Any with a warning
794        assert_eq!(node.kind_of("children"), Some(&FieldKind::Any));
795        assert!(import
796            .warnings
797            .iter()
798            .any(|w| w.detail.contains("recursive $ref")));
799    }
800
801    #[test]
802    fn test_import_option_nullable_unwrap() {
803        let schema_doc = json!({
804            "oneOf": [
805                {
806                    "type": "object",
807                    "properties": {
808                        "type": {"type": "string", "const": "A"},
809                        "maybe_count": {"anyOf": [{"type": "integer"}, {"type": "null"}]},
810                        "maybe_level": {"type": ["string", "null"]}
811                    }
812                }
813            ]
814        });
815
816        let import = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap();
817        let variant = import.schema.variant_schema("A").unwrap();
818        assert_eq!(variant.kind_of("maybe_count"), Some(&FieldKind::Integer));
819        assert_eq!(variant.kind_of("maybe_level"), Some(&FieldKind::String));
820        assert!(import.warnings.is_empty());
821    }
822
823    #[test]
824    fn test_import_enum_and_object_arrays() {
825        let schema_doc = json!({
826            "oneOf": [
827                {
828                    "type": "object",
829                    "properties": {
830                        "type": {"type": "string", "const": "Batch"},
831                        "derives": {
832                            "type": "array",
833                            "items": {"type": "string", "enum": ["Debug", "Clone"]}
834                        },
835                        "items": {
836                            "type": "array",
837                            "items": {
838                                "type": "object",
839                                "properties": {"path": {"type": "string"}}
840                            }
841                        },
842                        "scores": {"type": "array", "items": {"type": "number"}}
843                    }
844                }
845            ]
846        });
847
848        let import = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap();
849        let variant = import.schema.variant_schema("Batch").unwrap();
850        assert_eq!(
851            variant.kind_of("derives"),
852            Some(&FieldKind::enum_array(["Debug", "Clone"]))
853        );
854        assert!(matches!(
855            variant.kind_of("items"),
856            Some(FieldKind::ObjectArray(_))
857        ));
858        // Arrays of plain scalars: nothing to repair per-element
859        assert_eq!(variant.kind_of("scores"), Some(&FieldKind::Any));
860    }
861
862    #[test]
863    fn test_import_nested_tagged_enum_field() {
864        // A field whose schema is itself a tagged union converts to a
865        // nested TaggedEnum kind (no degradation, no warnings).
866        let schema_doc = json!({
867            "oneOf": [
868                {
869                    "type": "object",
870                    "properties": {
871                        "type": {"type": "string", "const": "A"},
872                        "nested": {
873                            "oneOf": [
874                                {"type": "object", "properties": {"kind": {"const": "Expand"}, "value": {"type": "integer"}}},
875                                {"type": "object", "properties": {"kind": {"const": "Collapse"}}}
876                            ]
877                        }
878                    }
879                }
880            ]
881        });
882
883        let import = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap();
884        let variant = import.schema.variant_schema("A").unwrap();
885        let Some(FieldKind::TaggedEnum(nested)) = variant.kind_of("nested") else {
886            panic!("expected nested to map to a TaggedEnum kind");
887        };
888        assert_eq!(nested.tag_field, "kind");
889        assert!(nested.is_valid_tag("Expand"));
890        assert!(import.warnings.is_empty());
891
892        // Repair reaches into the nested union
893        let llm_json = r#"{"type": "A", "nested": {"kind": "Expnd", "vale": "3"}}"#;
894        let result =
895            repair_tagged_enum_json(llm_json, &import.schema, &FuzzyOptions::default()).unwrap();
896        assert_eq!(result.repaired["nested"]["kind"], "Expand");
897        assert_eq!(result.repaired["nested"]["value"], 3);
898    }
899
900    #[test]
901    fn test_import_array_of_tagged_enums() {
902        // DSL shape: a list of tagged intents
903        let schema_doc = json!({
904            "oneOf": [
905                {
906                    "type": "object",
907                    "properties": {
908                        "type": {"type": "string", "const": "Batch"},
909                        "intents": {
910                            "type": "array",
911                            "items": {
912                                "oneOf": [
913                                    {"type": "object", "properties": {
914                                        "type": {"const": "AddDerive"},
915                                        "target": {"type": "string"}
916                                    }},
917                                    {"type": "object", "properties": {
918                                        "type": {"const": "Rename"},
919                                        "from": {"type": "string"},
920                                        "to": {"type": "string"}
921                                    }}
922                                ]
923                            }
924                        }
925                    }
926                }
927            ]
928        });
929
930        let import = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap();
931        let variant = import.schema.variant_schema("Batch").unwrap();
932        assert!(matches!(
933            variant.kind_of("intents"),
934            Some(FieldKind::TaggedEnumArray(_))
935        ));
936        assert!(import.warnings.is_empty());
937
938        let llm_json = r#"{
939            "type": "Batch",
940            "intents": [
941                {"type": "AddDeriv", "taget": "User"},
942                {"type": "Renme", "from": "a", "too": "b"}
943            ]
944        }"#;
945        let result =
946            repair_tagged_enum_json(llm_json, &import.schema, &FuzzyOptions::default()).unwrap();
947        assert_eq!(result.repaired["intents"][0]["type"], "AddDerive");
948        assert!(result.repaired["intents"][0].get("target").is_some());
949        assert_eq!(result.repaired["intents"][1]["type"], "Rename");
950        assert!(result.repaired["intents"][1].get("to").is_some());
951    }
952
953    #[test]
954    fn test_import_untaggable_oneof_degrades_with_warning() {
955        // A oneOf that is not a tagged union (scalar branches) degrades
956        let schema_doc = json!({
957            "oneOf": [
958                {
959                    "type": "object",
960                    "properties": {
961                        "type": {"type": "string", "const": "A"},
962                        "loose": {
963                            "oneOf": [
964                                {"type": "integer"},
965                                {"type": "string"}
966                            ]
967                        }
968                    }
969                }
970            ]
971        });
972
973        let import = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap();
974        let variant = import.schema.variant_schema("A").unwrap();
975        assert_eq!(variant.kind_of("loose"), Some(&FieldKind::Any));
976        assert!(import
977            .warnings
978            .iter()
979            .any(|w| w.path.contains("loose") && w.detail.contains("tagged enum")));
980    }
981
982    #[test]
983    fn test_import_unresolvable_ref_degrades_with_warning() {
984        let schema_doc = json!({
985            "oneOf": [
986                {
987                    "type": "object",
988                    "properties": {
989                        "type": {"type": "string", "const": "A"},
990                        "ext": {"$ref": "https://example.com/other.json"}
991                    }
992                }
993            ]
994        });
995
996        let import = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap();
997        let variant = import.schema.variant_schema("A").unwrap();
998        assert_eq!(variant.kind_of("ext"), Some(&FieldKind::Any));
999        assert!(import
1000            .warnings
1001            .iter()
1002            .any(|w| w.detail.contains("unresolvable $ref")));
1003    }
1004
1005    #[test]
1006    fn test_import_plain_object_schema() {
1007        let schema_doc = json!({
1008            "type": "object",
1009            "properties": {
1010                "name": {"type": "string"},
1011                "timeout": {"type": "integer"},
1012                "level": {"type": "string", "enum": ["debug", "info"]}
1013            },
1014            "required": ["name"]
1015        });
1016
1017        let import = ObjectSchema::from_json_schema(&schema_doc).unwrap();
1018        assert_eq!(import.schema.kind_of("timeout"), Some(&FieldKind::Integer));
1019        assert_eq!(
1020            import.schema.kind_of("level"),
1021            Some(&FieldKind::enum_of(["debug", "info"]))
1022        );
1023        assert!(import.warnings.is_empty());
1024    }
1025
1026    #[test]
1027    fn test_import_plain_object_rejects_non_object() {
1028        let schema_doc = json!({"type": "string"});
1029        assert!(ObjectSchema::from_json_schema(&schema_doc).is_err());
1030    }
1031
1032    #[test]
1033    fn test_import_legacy_definitions_ref() {
1034        // Draft-07 style `definitions` instead of `$defs`
1035        let schema_doc = json!({
1036            "oneOf": [
1037                {
1038                    "type": "object",
1039                    "properties": {
1040                        "type": {"type": "string", "const": "A"},
1041                        "inner": {"$ref": "#/definitions/Inner"}
1042                    }
1043                }
1044            ],
1045            "definitions": {
1046                "Inner": {
1047                    "type": "object",
1048                    "properties": {"value": {"type": "number"}}
1049                }
1050            }
1051        });
1052
1053        let import = TaggedEnumSchema::from_json_schema(&schema_doc).unwrap();
1054        let variant = import.schema.variant_schema("A").unwrap();
1055        let Some(FieldKind::Object(inner)) = variant.kind_of("inner") else {
1056            panic!("expected inner to map to an Object kind");
1057        };
1058        assert_eq!(inner.kind_of("value"), Some(&FieldKind::Number));
1059    }
1060}
1061
1062#[cfg(all(test, feature = "schemars"))]
1063mod schemars_tests {
1064    use crate::repair::{repair_tagged_enum_json, FuzzyOptions};
1065    use crate::schema::{FieldKind, ObjectSchema, TaggedEnumSchema};
1066
1067    #[derive(serde::Serialize, schemars::JsonSchema)]
1068    #[serde(tag = "type")]
1069    #[allow(dead_code)]
1070    enum Intent {
1071        AddDerive {
1072            target: String,
1073            count: i32,
1074        },
1075        Rename {
1076            from: String,
1077            to: String,
1078        },
1079    }
1080
1081    #[test]
1082    fn test_from_type_internally_tagged() {
1083        let import = TaggedEnumSchema::from_type::<Intent>().unwrap();
1084        let schema = &import.schema;
1085
1086        assert_eq!(schema.tag_field, "type");
1087        assert!(schema.is_valid_tag("AddDerive"));
1088        assert!(schema.is_valid_tag("Rename"));
1089
1090        let variant = schema.variant_schema("AddDerive").unwrap();
1091        assert_eq!(variant.kind_of("target"), Some(&FieldKind::String));
1092        assert_eq!(variant.kind_of("count"), Some(&FieldKind::Integer));
1093
1094        // End to end: derive → import → repair
1095        let llm_json = r#"{"type": "AddDeriv", "taget": "User", "count": "3"}"#;
1096        let result =
1097            repair_tagged_enum_json(llm_json, schema, &FuzzyOptions::default()).unwrap();
1098        assert_eq!(result.repaired["type"], "AddDerive");
1099        assert_eq!(result.repaired["target"], "User");
1100        assert_eq!(result.repaired["count"], 3);
1101    }
1102
1103    #[derive(schemars::JsonSchema)]
1104    #[allow(dead_code)]
1105    struct Config {
1106        name: String,
1107        timeout: u32,
1108        enabled: bool,
1109    }
1110
1111    #[test]
1112    fn test_from_type_plain_struct() {
1113        let import = ObjectSchema::from_type::<Config>().unwrap();
1114        assert_eq!(import.schema.kind_of("timeout"), Some(&FieldKind::Integer));
1115        assert_eq!(import.schema.kind_of("enabled"), Some(&FieldKind::Bool));
1116    }
1117}