1use crate::error::FuzzyError;
44use crate::schema::{FieldKind, ObjectSchema, TaggedEnumSchema};
45use serde_json::{Map, Value};
46
47#[derive(Debug, Clone)]
50pub struct SchemaImport<S> {
51 pub schema: S,
53 pub warnings: Vec<ImportWarning>,
55}
56
57#[derive(Debug, Clone, PartialEq)]
62pub struct ImportWarning {
63 pub path: String,
66 pub detail: String,
68}
69
70impl TaggedEnumSchema {
71 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 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 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 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
202struct 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 fn lookup_ref(&self, reference: &str) -> Option<Value> {
232 let pointer = reference.strip_prefix('#')?;
233 self.root.pointer(pointer).cloned()
234 }
235
236 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) = ¤t 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 fn convert_tagged_enum(
273 &mut self,
274 branches: &[Value],
275 base_path: &str,
276 ) -> Result<TaggedEnumSchema, String> {
277 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 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 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 fn convert_kind(&mut self, prop: &Value, path: &str) -> FieldKind {
365 let Some(obj) = prop.as_object() else {
366 return FieldKind::Any;
368 };
369
370 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 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 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 _ => FieldKind::Any,
453 }
454 }
455 _ => FieldKind::Any,
457 }
458 }
459}
460
461fn 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 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
491fn 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
505fn 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
532fn 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
544fn 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
563fn 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 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 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 assert!(!variant.is_valid_field("tag"));
641
642 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 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); }
660
661 #[test]
662 fn test_import_adjacently_tagged() {
663 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 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 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 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 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 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 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 assert_eq!(variant.kind_of("scores"), Some(&FieldKind::Any));
860 }
861
862 #[test]
863 fn test_import_nested_tagged_enum_field() {
864 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 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 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 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 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 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}