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