1use crate::error::FuzzyError;
40use crate::schema::{FieldKind, ObjectSchema, TaggedEnumSchema};
41use serde_json::{Map, Value};
42
43#[derive(Debug, Clone)]
46pub struct SchemaImport<S> {
47 pub schema: S,
49 pub warnings: Vec<ImportWarning>,
51}
52
53#[derive(Debug, Clone, PartialEq)]
58pub struct ImportWarning {
59 pub path: String,
62 pub detail: String,
64}
65
66impl TaggedEnumSchema {
67 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 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 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 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 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 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
243struct 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 fn lookup_ref(&self, reference: &str) -> Option<Value> {
273 let pointer = reference.strip_prefix('#')?;
274 self.root.pointer(pointer).cloned()
275 }
276
277 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) = ¤t 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 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 fn convert_kind(&mut self, prop: &Value, path: &str) -> FieldKind {
338 let Some(obj) = prop.as_object() else {
339 return FieldKind::Any;
341 };
342
343 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 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 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 _ => FieldKind::Any,
412 }
413 }
414 _ => FieldKind::Any,
416 }
417 }
418}
419
420fn 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 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
450fn 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
464fn 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
491fn 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
503fn 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
522fn 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 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 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 assert!(!variant.is_valid_field("tag"));
600
601 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 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); }
619
620 #[test]
621 fn test_import_adjacently_tagged() {
622 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 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 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 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 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 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 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 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 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 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}