1use schemars::Schema;
4use serde_json::Value as Json;
5use std::collections::HashMap;
6use std::collections::HashSet;
7
8#[derive(Clone, Debug)]
10pub enum FieldConstraint {
11 Enum(Vec<Json>),
13
14 Range {
16 minimum: Option<Json>,
17 maximum: Option<Json>,
18 },
19
20 Pattern(String),
22
23 MergePatch(Json),
25}
26
27pub trait SchemaTransform: Send + Sync {
29 fn apply(&self, tool: &str, schema: &mut Json);
31}
32
33#[derive(Default)]
42pub struct SchemaEngine {
43 per_tool: HashMap<String, Vec<(Vec<String>, FieldConstraint)>>,
44 global_strict: bool,
45 custom_transforms: Vec<Box<dyn SchemaTransform>>,
46}
47
48impl Clone for SchemaEngine {
49 fn clone(&self) -> Self {
50 Self {
52 per_tool: self.per_tool.clone(),
53 global_strict: self.global_strict,
54 custom_transforms: Vec::new(), }
56 }
57}
58
59impl std::fmt::Debug for SchemaEngine {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.debug_struct("SchemaEngine")
62 .field("per_tool", &self.per_tool)
63 .field("global_strict", &self.global_strict)
64 .field(
65 "custom_transforms",
66 &format!("[{} transforms]", self.custom_transforms.len()),
67 )
68 .finish()
69 }
70}
71
72impl SchemaEngine {
73 pub fn new() -> Self {
75 Self::default()
76 }
77
78 #[must_use]
80 pub fn with_strict(mut self, strict: bool) -> Self {
81 self.global_strict = strict;
82 self
83 }
84
85 pub fn is_strict(&self) -> bool {
87 self.global_strict
88 }
89
90 pub fn constrain_field(&mut self, tool: &str, json_path: Vec<String>, c: FieldConstraint) {
95 self.per_tool
96 .entry(tool.to_string())
97 .or_default()
98 .push((json_path, c));
99 }
100
101 pub fn add_transform<T: SchemaTransform + 'static>(&mut self, transform: T) {
103 self.custom_transforms.push(Box::new(transform));
104 }
105
106 #[expect(
108 clippy::needless_pass_by_value,
109 reason = "this public API intentionally consumes and returns Schema at the transformation boundary"
110 )]
111 pub fn transform(&self, tool: &str, schema: Schema) -> Schema {
112 let mut v = match serde_json::to_value(&schema) {
113 Ok(value) => value,
114 Err(error) => panic!("serialize schema: {error}"),
115 };
116
117 if self.global_strict
119 && let Some(obj) = v.as_object_mut()
120 {
121 obj.insert("additionalProperties".to_string(), Json::Bool(false));
122 }
123
124 if let Some(entries) = self.per_tool.get(tool) {
126 for (path, constraint) in entries {
127 Self::apply_constraint(&mut v, path, constraint);
128 }
129 }
130
131 for transform in &self.custom_transforms {
133 transform.apply(tool, &mut v);
134 }
135
136 match Schema::try_from(v) {
141 Ok(schema) => schema,
142 Err(error) => panic!("schema transform must produce a valid schema: {error}"),
143 }
144 }
145
146 fn apply_constraint(root: &mut Json, path: &[String], constraint: &FieldConstraint) {
147 let Some(node) = Self::find_node_mut(root, path) else {
148 return;
149 };
150 let Some(obj) = node.as_object_mut() else {
151 return;
152 };
153 match constraint {
154 FieldConstraint::Enum(vals) => {
155 obj.insert("enum".into(), Json::Array(vals.clone()));
156 }
157 FieldConstraint::Range { minimum, maximum } => {
158 if let Some(m) = minimum {
159 obj.insert("minimum".into(), m.clone());
160 }
161 if let Some(m) = maximum {
162 obj.insert("maximum".into(), m.clone());
163 }
164 }
165 FieldConstraint::Pattern(p) => {
166 obj.insert("pattern".into(), Json::String(p.clone()));
167 }
168 FieldConstraint::MergePatch(merge_patch) => {
169 json_patch::merge(node, merge_patch);
170 }
171 }
172 }
173
174 fn find_node_mut<'a>(root: &'a mut Json, path: &[String]) -> Option<&'a mut Json> {
175 let mut cur = root;
176 for seg in path {
177 cur = cur.as_object_mut()?.get_mut(seg)?;
178 }
179 Some(cur)
180 }
181}
182
183const OPTIONAL_PROPERTY_GUIDANCE: &str = "Optional; omit or use null.";
184
185#[derive(Clone, Default)]
186struct NullFirstOptional;
187
188impl schemars::transform::Transform for NullFirstOptional {
189 fn transform(&mut self, schema: &mut Schema) {
190 let mut value = match serde_json::to_value(&*schema) {
191 Ok(value) => value,
192 Err(error) => panic!("serialize schema: {error}"),
193 };
194 normalize_optional_properties(&mut value);
195 *schema = match Schema::try_from(value) {
196 Ok(schema) => schema,
197 Err(error) => panic!("NullFirstOptional must preserve schema validity: {error}"),
198 };
199 }
200}
201
202fn normalize_optional_properties(node: &mut Json) {
203 let Some(obj) = node.as_object_mut() else {
204 return;
205 };
206
207 recurse_object_entries(obj, "$defs");
208 recurse_object_entries(obj, "definitions");
209
210 let required = required_property_names(obj.get("required"));
211 if let Some(properties) = obj.get_mut("properties").and_then(Json::as_object_mut) {
212 for (property_name, property_schema) in properties {
213 if !required.contains(property_name.as_str()) {
214 normalize_known_nullable_shapes(property_schema);
215 if explicitly_allows_null(property_schema) {
216 annotate_optional_property(property_schema);
217 }
218 }
219 normalize_optional_properties(property_schema);
220 }
221 }
222
223 recurse_object_entries(obj, "dependentSchemas");
224 recurse_object_entries(obj, "patternProperties");
225 recurse_schema_entry(obj, "additionalProperties");
226 recurse_schema_entry(obj, "propertyNames");
227 recurse_schema_entry(obj, "unevaluatedProperties");
228 recurse_schema_entry(obj, "items");
229 recurse_schema_entry(obj, "unevaluatedItems");
230 recurse_schema_entry(obj, "contains");
231 recurse_schema_array_entry(obj, "prefixItems");
232 recurse_schema_array_entry(obj, "allOf");
233 recurse_schema_array_entry(obj, "anyOf");
234 recurse_schema_array_entry(obj, "oneOf");
235 recurse_schema_entry(obj, "if");
236 recurse_schema_entry(obj, "then");
237 recurse_schema_entry(obj, "else");
238 recurse_schema_entry(obj, "not");
239}
240
241fn recurse_object_entries(obj: &mut serde_json::Map<String, Json>, key: &str) {
242 let Some(entries) = obj.get_mut(key).and_then(Json::as_object_mut) else {
243 return;
244 };
245
246 for value in entries.values_mut() {
247 normalize_optional_properties(value);
248 }
249}
250
251fn recurse_schema_entry(obj: &mut serde_json::Map<String, Json>, key: &str) {
252 let Some(value) = obj.get_mut(key) else {
253 return;
254 };
255
256 normalize_optional_properties(value);
257}
258
259fn recurse_schema_array_entry(obj: &mut serde_json::Map<String, Json>, key: &str) {
260 let Some(values) = obj.get_mut(key).and_then(Json::as_array_mut) else {
261 return;
262 };
263
264 for value in values {
265 normalize_optional_properties(value);
266 }
267}
268
269fn required_property_names(required: Option<&Json>) -> HashSet<String> {
270 required
271 .and_then(Json::as_array)
272 .into_iter()
273 .flatten()
274 .filter_map(Json::as_str)
275 .map(str::to_owned)
276 .collect()
277}
278
279fn normalize_known_nullable_shapes(node: &mut Json) {
280 move_null_to_front_in_type_array(node);
281 move_null_to_front_in_enum_values(node);
282 move_null_to_front_in_any_of(node);
283}
284
285fn explicitly_allows_null(node: &Json) -> bool {
286 type_array_contains_null(node)
287 || enum_values_contain_null(node)
288 || any_of_contains_explicit_null_branch(node)
289}
290
291fn type_array_contains_null(node: &Json) -> bool {
292 node.as_object()
293 .and_then(|obj| obj.get("type"))
294 .and_then(Json::as_array)
295 .is_some_and(|type_values| {
296 type_values
297 .iter()
298 .any(|value| value == &Json::String("null".into()))
299 })
300}
301
302fn enum_values_contain_null(node: &Json) -> bool {
303 node.as_object()
304 .and_then(|obj| obj.get("enum"))
305 .and_then(Json::as_array)
306 .is_some_and(|enum_values| enum_values.iter().any(Json::is_null))
307}
308
309fn any_of_contains_explicit_null_branch(node: &Json) -> bool {
310 node.as_object()
311 .and_then(|obj| obj.get("anyOf"))
312 .and_then(Json::as_array)
313 .is_some_and(|any_of| any_of.iter().any(is_explicit_null_branch))
314}
315
316fn move_null_to_front_in_type_array(node: &mut Json) {
317 let Some(obj) = node.as_object_mut() else {
318 return;
319 };
320
321 let Some(type_values) = obj.get_mut("type").and_then(Json::as_array_mut) else {
322 return;
323 };
324
325 move_values_to_front(type_values, |value| value == &Json::String("null".into()));
326}
327
328fn move_null_to_front_in_enum_values(node: &mut Json) {
329 let Some(obj) = node.as_object_mut() else {
330 return;
331 };
332
333 let Some(enum_values) = obj.get_mut("enum").and_then(Json::as_array_mut) else {
334 return;
335 };
336
337 move_values_to_front(enum_values, Json::is_null);
338}
339
340fn move_null_to_front_in_any_of(node: &mut Json) {
341 let Some(obj) = node.as_object_mut() else {
342 return;
343 };
344
345 let Some(any_of) = obj.get_mut("anyOf").and_then(Json::as_array_mut) else {
346 return;
347 };
348
349 move_values_to_front(any_of, is_explicit_null_branch);
350}
351
352fn annotate_optional_property(node: &mut Json) {
353 let Some(obj) = node.as_object_mut() else {
354 return;
355 };
356
357 match obj.get_mut("description") {
358 Some(Json::String(description)) => {
359 if !description.contains(OPTIONAL_PROPERTY_GUIDANCE) {
360 description.push_str("\n\n");
361 description.push_str(OPTIONAL_PROPERTY_GUIDANCE);
362 }
363 }
364 Some(_) => {
365 }
367 None => {
368 obj.insert(
369 "description".to_string(),
370 Json::String(OPTIONAL_PROPERTY_GUIDANCE.to_string()),
371 );
372 }
373 }
374}
375
376fn move_values_to_front<F>(values: &mut Vec<Json>, predicate: F)
377where
378 F: Fn(&Json) -> bool,
379{
380 let mut matching = Vec::new();
381 let mut non_matching = Vec::new();
382
383 for value in values.drain(..) {
384 if predicate(&value) {
385 matching.push(value);
386 } else {
387 non_matching.push(value);
388 }
389 }
390
391 if matching.is_empty() {
392 *values = non_matching;
393 return;
394 }
395
396 matching.extend(non_matching);
397 *values = matching;
398}
399
400fn is_explicit_null_branch(node: &Json) -> bool {
401 matches!(
402 node,
403 Json::Object(obj) if obj.get("type") == Some(&Json::String("null".into()))
404 )
405}
406
407pub mod mcp_schema {
419 use super::NullFirstOptional;
420 use schemars::JsonSchema;
421 use schemars::Schema;
422 use schemars::generate::SchemaSettings;
423 use schemars::transform::RestrictFormats;
424 use std::any::TypeId;
425 use std::cell::RefCell;
426 use std::collections::HashMap;
427 use std::sync::Arc;
428
429 thread_local! {
430 static CACHE_FOR_TYPE: RefCell<HashMap<TypeId, Arc<Schema>>> = RefCell::new(HashMap::new());
431 static CACHE_FOR_OUTPUT: RefCell<HashMap<TypeId, Result<Arc<Schema>, String>>> = RefCell::new(HashMap::new());
432 }
433
434 fn settings() -> SchemaSettings {
435 SchemaSettings::draft2020_12()
436 .with_transform(RestrictFormats::default())
437 .with_transform(NullFirstOptional)
438 }
439
440 pub fn cached_schema_for<T: JsonSchema + 'static>() -> Arc<Schema> {
442 CACHE_FOR_TYPE.with(|cache| {
443 let mut cache = cache.borrow_mut();
444 if let Some(x) = cache.get(&TypeId::of::<T>()) {
445 return Arc::clone(x);
446 }
447 let generator = settings().into_generator();
448 let root = generator.into_root_schema_for::<T>();
449 let arc = Arc::new(root);
450 cache.insert(TypeId::of::<T>(), Arc::clone(&arc));
451 arc
452 })
453 }
454
455 pub fn cached_output_schema_for<T: JsonSchema + 'static>() -> Result<Arc<Schema>, String> {
458 CACHE_FOR_OUTPUT.with(|cache| {
459 let mut cache = cache.borrow_mut();
460 if let Some(r) = cache.get(&TypeId::of::<T>()) {
461 return r.clone();
462 }
463 let root = cached_schema_for::<T>();
464 let json = match serde_json::to_value(root.as_ref()) {
465 Ok(value) => value,
466 Err(error) => panic!("serialize output schema: {error}"),
467 };
468 let result = match json.get("type") {
469 Some(serde_json::Value::String(t)) if t == "object" => Ok(root),
470 Some(serde_json::Value::String(t)) => Err(format!(
471 "MCP requires output_schema root type 'object', found '{t}'"
472 )),
473 None => {
474 if json.get("properties").is_some() {
477 Ok(root)
478 } else {
479 Err(
480 "Schema missing 'type' — output_schema must have root type 'object'"
481 .to_string(),
482 )
483 }
484 }
485 Some(other) => Err(format!(
486 "Unexpected 'type' format: {other:?} — expected string 'object'"
487 )),
488 };
489 cache.insert(TypeId::of::<T>(), result.clone());
490 result
491 })
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use serde::Serialize;
499
500 #[derive(schemars::JsonSchema, Serialize)]
501 struct TestInput {
502 count: i32,
503 name: String,
504 }
505
506 #[test]
507 fn test_strict_mode() {
508 let engine = SchemaEngine::new().with_strict(true);
509 let schema = schemars::schema_for!(TestInput);
510 let transformed = engine.transform("test", schema);
511
512 let json = serde_json::to_value(&transformed).unwrap();
513 assert_eq!(json.get("additionalProperties"), Some(&Json::Bool(false)));
514 }
515
516 #[test]
517 fn test_is_strict_getter() {
518 let e = SchemaEngine::new();
519 assert!(!e.is_strict());
520 let e2 = SchemaEngine::new().with_strict(true);
521 assert!(e2.is_strict());
522 }
523
524 #[test]
525 fn test_enum_constraint() {
526 let mut engine = SchemaEngine::new();
527
528 let test_schema: Json = serde_json::json!({
530 "type": "object",
531 "properties": {
532 "name": {
533 "type": "string"
534 }
535 }
536 });
537
538 engine.constrain_field(
539 "test",
540 vec!["properties".into(), "name".into()],
541 FieldConstraint::Enum(vec![Json::String("a".into()), Json::String("b".into())]),
542 );
543
544 let schema: Schema = Schema::try_from(test_schema).unwrap();
545 let transformed = engine.transform("test", schema);
546
547 let json = serde_json::to_value(&transformed).unwrap();
548 let name_schema = &json["properties"]["name"];
549 assert!(name_schema.get("enum").is_some());
550 }
551
552 #[test]
553 fn test_range_constraint() {
554 let mut engine = SchemaEngine::new();
556 engine.constrain_field(
557 "test",
558 vec!["properties".into(), "count".into()],
559 FieldConstraint::Range {
560 minimum: Some(Json::Number(0.into())),
561 maximum: Some(Json::Number(100.into())),
562 },
563 );
564
565 let schema = schemars::schema_for!(TestInput);
567
568 let transformed = engine.transform("test", schema);
570
571 let json = serde_json::to_value(&transformed).unwrap();
573 let count_schema = &json["properties"]["count"];
574
575 let min = count_schema
577 .get("minimum")
578 .and_then(serde_json::Value::as_f64);
579 let max = count_schema
580 .get("maximum")
581 .and_then(serde_json::Value::as_f64);
582
583 assert_eq!(min, Some(0.0), "minimum constraint should be applied");
584 assert_eq!(max, Some(100.0), "maximum constraint should be applied");
585 }
586
587 mod mcp_schema_tests {
592 use super::Json;
593 use super::NullFirstOptional;
594 use super::OPTIONAL_PROPERTY_GUIDANCE;
595 use super::Schema;
596 use super::mcp_schema;
597 use schemars::transform::Transform;
598 use serde::Serialize;
599
600 fn property<'a>(schema: &'a Json, name: &str) -> &'a Json {
601 &schema["properties"][name]
602 }
603
604 fn required_names(schema: &Json) -> Vec<&str> {
605 schema["required"]
606 .as_array()
607 .into_iter()
608 .flatten()
609 .filter_map(Json::as_str)
610 .collect()
611 }
612
613 fn assert_optional_guidance(schema: &Json, name: &str) {
614 assert_eq!(
615 property(schema, name).get("description"),
616 Some(&Json::String(OPTIONAL_PROPERTY_GUIDANCE.to_string()))
617 );
618 }
619
620 #[derive(schemars::JsonSchema, Serialize)]
621 struct WithOption {
622 a: Option<String>,
623 }
624
625 #[test]
626 fn test_option_string_is_optional_nullable_with_null_first() {
627 let root = mcp_schema::cached_schema_for::<WithOption>();
628 let v = serde_json::to_value(root.as_ref()).unwrap();
629 let a = property(&v, "a");
630
631 assert_eq!(a.get("type"), Some(&serde_json::json!(["null", "string"])));
632 assert!(a.get("nullable").is_none());
633 assert!(required_names(&v).is_empty());
634 assert_optional_guidance(&v, "a");
635 }
636
637 #[derive(schemars::JsonSchema, Serialize)]
638 struct OutputObj {
639 x: i32,
640 }
641
642 #[test]
643 fn test_output_schema_validation_object() {
644 let ok = mcp_schema::cached_output_schema_for::<OutputObj>();
645 assert!(
646 ok.is_ok(),
647 "Object types should pass output schema validation"
648 );
649 }
650
651 #[test]
652 fn test_output_schema_validation_non_object() {
653 let bad = mcp_schema::cached_output_schema_for::<String>();
655 assert!(
656 bad.is_err(),
657 "Non-object types should fail output schema validation"
658 );
659 }
660
661 #[test]
662 fn test_draft_2020_12_uses_defs() {
663 let root = mcp_schema::cached_schema_for::<WithOption>();
664 let v = serde_json::to_value(root.as_ref()).unwrap();
665 assert!(v.is_object(), "Schema should be an object");
669 assert!(
670 v.get("$schema")
671 .and_then(|s| s.as_str())
672 .is_some_and(|s| s.contains("2020-12")),
673 "Schema should reference Draft 2020-12"
674 );
675 }
676
677 #[test]
678 fn test_caching_returns_same_arc() {
679 let first = mcp_schema::cached_schema_for::<OutputObj>();
680 let second = mcp_schema::cached_schema_for::<OutputObj>();
681 assert!(
682 std::sync::Arc::ptr_eq(&first, &second),
683 "Cached schemas should return the same Arc"
684 );
685 }
686
687 #[expect(dead_code)]
692 #[derive(schemars::JsonSchema, Serialize)]
693 enum TestEnum {
694 A,
695 B,
696 }
697
698 #[derive(schemars::JsonSchema, Serialize)]
699 struct HasOptEnum {
700 e: Option<TestEnum>,
701 }
702
703 #[test]
704 fn test_option_enum_keeps_any_of_with_null_first() {
705 let root = mcp_schema::cached_schema_for::<HasOptEnum>();
706 let v = serde_json::to_value(root.as_ref()).unwrap();
707 let e = property(&v, "e");
708 let any_of = e["anyOf"].as_array().expect("Option enum should use anyOf");
709
710 assert_eq!(any_of.len(), 2);
711 assert_eq!(any_of[0], serde_json::json!({ "type": "null" }));
712 assert!(any_of[1].get("$ref").is_some());
713 assert_optional_guidance(&v, "e");
714 }
715
716 #[derive(schemars::JsonSchema, Serialize)]
717 struct Unsigneds {
718 a: u32,
719 b: u64,
720 }
721
722 #[test]
723 fn test_strip_uint_formats() {
724 let root = mcp_schema::cached_schema_for::<Unsigneds>();
725 let v = serde_json::to_value(root.as_ref()).unwrap();
726 let pa = &v["properties"]["a"];
727 let pb = &v["properties"]["b"];
728
729 assert!(
730 pa.get("format").is_none(),
731 "u32 should not include non-standard 'format'"
732 );
733 assert!(
734 pb.get("format").is_none(),
735 "u64 should not include non-standard 'format'"
736 );
737 assert_eq!(
738 pa.get("minimum").and_then(serde_json::Value::as_u64),
739 Some(0),
740 "u32 minimum must be preserved"
741 );
742 assert_eq!(
743 pb.get("minimum").and_then(serde_json::Value::as_u64),
744 Some(0),
745 "u64 minimum must be preserved"
746 );
747 }
748
749 #[derive(schemars::JsonSchema, Serialize)]
750 struct HasOptString {
751 s: Option<String>,
752 }
753
754 #[test]
755 fn test_option_string_uses_null_first_without_nullable_keyword() {
756 let root = mcp_schema::cached_schema_for::<HasOptString>();
757 let v = serde_json::to_value(root.as_ref()).unwrap();
758 let s = property(&v, "s");
759
760 assert_eq!(s.get("type"), Some(&serde_json::json!(["null", "string"])));
761 assert!(
762 s.get("nullable").is_none(),
763 "Option<String> should not have nullable keyword"
764 );
765 assert_optional_guidance(&v, "s");
766 }
767
768 #[derive(schemars::JsonSchema, Serialize)]
769 struct NestedInner {
770 leaf: Option<String>,
771 }
772
773 #[derive(schemars::JsonSchema, Serialize)]
774 struct NestedOuter {
775 nested: Option<NestedInner>,
776 }
777
778 #[test]
779 fn test_nested_optional_properties_are_normalized_recursively() {
780 let root = mcp_schema::cached_schema_for::<NestedOuter>();
781 let v = serde_json::to_value(root.as_ref()).unwrap();
782 let nested = property(&v, "nested");
783 let nested_any_of = nested["anyOf"]
784 .as_array()
785 .expect("Nested option should keep anyOf branches");
786
787 assert_eq!(nested_any_of[0], serde_json::json!({ "type": "null" }));
788 assert!(nested_any_of[1].get("$ref").is_some());
789 assert_optional_guidance(&v, "nested");
790
791 let defs = v["$defs"]
792 .as_object()
793 .expect("Nested type should use $defs");
794 let inner = defs
795 .values()
796 .find(|schema| schema["properties"].get("leaf").is_some())
797 .expect("NestedInner schema should exist in $defs");
798
799 assert_eq!(
800 inner["properties"]["leaf"]["type"],
801 serde_json::json!(["null", "string"])
802 );
803 assert_eq!(
804 inner["properties"]["leaf"]["description"],
805 serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
806 );
807 }
808
809 #[derive(schemars::JsonSchema, Serialize)]
810 struct HasOptVec {
811 values: Option<Vec<String>>,
812 }
813
814 #[test]
815 fn test_option_vec_property_keeps_outer_nullability_with_null_first() {
816 let root = mcp_schema::cached_schema_for::<HasOptVec>();
817 let v = serde_json::to_value(root.as_ref()).unwrap();
818 let values = property(&v, "values");
819
820 assert_eq!(
821 values.get("type"),
822 Some(&serde_json::json!(["null", "array"]))
823 );
824 assert_eq!(values["items"]["type"], serde_json::json!("string"));
825 assert_optional_guidance(&v, "values");
826 }
827
828 #[derive(schemars::JsonSchema, Serialize)]
829 struct HasNestedOptionalItems {
830 values: Option<Vec<Option<String>>>,
831 }
832
833 #[test]
834 fn test_inner_nullability_is_preserved() {
835 let root = mcp_schema::cached_schema_for::<HasNestedOptionalItems>();
836 let v = serde_json::to_value(root.as_ref()).unwrap();
837 let values = property(&v, "values");
838 let item_type = values["items"]["type"]
839 .as_array()
840 .expect("Inner Option<String> should remain nullable");
841
842 assert_eq!(
843 values.get("type"),
844 Some(&serde_json::json!(["null", "array"]))
845 );
846 assert!(item_type.contains(&serde_json::json!("string")));
847 assert!(item_type.contains(&serde_json::json!("null")));
848 assert_optional_guidance(&v, "values");
849 }
850
851 #[test]
852 fn test_required_fields_remain_unchanged() {
853 let mut schema = Schema::try_from(serde_json::json!({
854 "type": "object",
855 "properties": {
856 "required_field": { "type": ["string", "null"] },
857 "optional_field": { "type": ["string", "null"] }
858 },
859 "required": ["required_field"]
860 }))
861 .unwrap();
862
863 NullFirstOptional.transform(&mut schema);
864
865 let v = serde_json::to_value(&schema).unwrap();
866 let required_type = v["properties"]["required_field"]["type"]
867 .as_array()
868 .expect("Required field should keep nullable type array");
869
870 assert!(required_type.contains(&serde_json::json!("string")));
871 assert!(required_type.contains(&serde_json::json!("null")));
872 assert_eq!(
873 v["properties"]["optional_field"]["type"],
874 serde_json::json!(["null", "string"])
875 );
876 assert_eq!(
877 v["properties"]["optional_field"]["description"],
878 serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
879 );
880 assert!(
881 v["properties"]["required_field"]
882 .get("description")
883 .is_none()
884 );
885 }
886
887 #[test]
888 fn test_manual_any_of_null_branch_moves_to_front() {
889 let mut schema = Schema::try_from(serde_json::json!({
890 "type": "object",
891 "properties": {
892 "optional_field": {
893 "anyOf": [
894 { "type": "string" },
895 { "type": "integer" },
896 { "type": "null" }
897 ]
898 }
899 }
900 }))
901 .unwrap();
902
903 NullFirstOptional.transform(&mut schema);
904
905 let v = serde_json::to_value(&schema).unwrap();
906 assert_eq!(
907 v["properties"]["optional_field"]["anyOf"],
908 serde_json::json!([
909 { "type": "null" },
910 { "type": "string" },
911 { "type": "integer" }
912 ])
913 );
914 assert_eq!(
915 v["properties"]["optional_field"]["description"],
916 serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
917 );
918 }
919
920 #[test]
921 fn test_manual_enum_null_moves_to_front() {
922 let mut schema = Schema::try_from(serde_json::json!({
923 "type": "object",
924 "properties": {
925 "optional_field": {
926 "enum": ["alpha", null, "beta"]
927 }
928 }
929 }))
930 .unwrap();
931
932 NullFirstOptional.transform(&mut schema);
933
934 let v = serde_json::to_value(&schema).unwrap();
935 assert_eq!(
936 v["properties"]["optional_field"]["enum"],
937 serde_json::json!([null, "alpha", "beta"])
938 );
939 assert_eq!(
940 v["properties"]["optional_field"]["description"],
941 serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
942 );
943 }
944
945 #[test]
946 fn test_existing_description_appends_guidance_once() {
947 let mut schema = Schema::try_from(serde_json::json!({
948 "type": "object",
949 "properties": {
950 "optional_field": {
951 "description": "Existing description.",
952 "type": ["string", "null"]
953 }
954 }
955 }))
956 .unwrap();
957
958 NullFirstOptional.transform(&mut schema);
959 NullFirstOptional.transform(&mut schema);
960
961 let v = serde_json::to_value(&schema).unwrap();
962 assert_eq!(
963 v["properties"]["optional_field"]["description"],
964 serde_json::json!("Existing description.\n\nOptional; omit or use null.")
965 );
966 assert_eq!(
967 v["properties"]["optional_field"]["type"],
968 serde_json::json!(["null", "string"])
969 );
970 }
971
972 #[test]
973 fn test_non_nullable_optional_property_does_not_get_null_guidance() {
974 let mut schema = Schema::try_from(serde_json::json!({
975 "type": "object",
976 "properties": {
977 "optional_field": {
978 "type": "string"
979 }
980 }
981 }))
982 .unwrap();
983
984 NullFirstOptional.transform(&mut schema);
985
986 let v = serde_json::to_value(&schema).unwrap();
987 assert!(
988 v["properties"]["optional_field"]
989 .get("description")
990 .is_none()
991 );
992 }
993
994 #[test]
995 fn test_dependent_schemas_are_normalized_recursively() {
996 let mut schema = Schema::try_from(serde_json::json!({
997 "type": "object",
998 "properties": {
999 "trigger": { "type": "boolean" }
1000 },
1001 "dependentSchemas": {
1002 "trigger": {
1003 "type": "object",
1004 "properties": {
1005 "nested_optional": {
1006 "type": ["string", "null"]
1007 }
1008 }
1009 }
1010 }
1011 }))
1012 .unwrap();
1013
1014 NullFirstOptional.transform(&mut schema);
1015
1016 let v = serde_json::to_value(&schema).unwrap();
1017 assert_eq!(
1018 v["dependentSchemas"]["trigger"]["properties"]["nested_optional"]["type"],
1019 serde_json::json!(["null", "string"])
1020 );
1021 assert_eq!(
1022 v["dependentSchemas"]["trigger"]["properties"]["nested_optional"]["description"],
1023 serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
1024 );
1025 }
1026
1027 #[test]
1028 fn test_unevaluated_items_are_normalized_recursively() {
1029 let mut schema = Schema::try_from(serde_json::json!({
1030 "type": "array",
1031 "unevaluatedItems": {
1032 "type": "object",
1033 "properties": {
1034 "nested_optional": {
1035 "type": ["string", "null"]
1036 }
1037 }
1038 }
1039 }))
1040 .unwrap();
1041
1042 NullFirstOptional.transform(&mut schema);
1043
1044 let v = serde_json::to_value(&schema).unwrap();
1045 assert_eq!(
1046 v["unevaluatedItems"]["properties"]["nested_optional"]["type"],
1047 serde_json::json!(["null", "string"])
1048 );
1049 assert_eq!(
1050 v["unevaluatedItems"]["properties"]["nested_optional"]["description"],
1051 serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
1052 );
1053 }
1054 }
1055}