Skip to main content

adk_core/
schema_utils.rs

1//! # Schema Utilities
2//!
3//! Shared utility functions for normalizing JSON Schema documents across
4//! multiple LLM provider adapters. Each function operates on `serde_json::Value`
5//! via mutable references for in-place transformation and recurses into nested
6//! schemas (properties, items, additionalProperties, allOf, anyOf, oneOf, etc.).
7//!
8//! These utilities are independently unit-testable and composable — each adapter
9//! selects which transforms to apply and in what order.
10//!
11//! ## Example
12//!
13//! ```rust
14//! use serde_json::json;
15//! use adk_core::schema_utils;
16//!
17//! let mut schema = json!({
18//!     "$schema": "http://json-schema.org/draft-07/schema#",
19//!     "type": "object",
20//!     "properties": {
21//!         "name": { "type": "string" }
22//!     }
23//! });
24//!
25//! schema_utils::strip_schema_keyword(&mut schema);
26//! assert!(schema.get("$schema").is_none());
27//! ```
28
29use std::borrow::Cow;
30
31use serde_json::{Map, Value};
32
33/// Removes the `$schema` keyword from the schema and all nested sub-schemas.
34///
35/// Many LLM providers reject schemas containing the `$schema` meta-keyword.
36///
37/// # Example
38///
39/// ```rust
40/// use serde_json::json;
41/// use adk_core::schema_utils::strip_schema_keyword;
42///
43/// let mut schema = json!({
44///     "$schema": "http://json-schema.org/draft-07/schema#",
45///     "type": "object",
46///     "properties": {
47///         "nested": {
48///             "$schema": "http://json-schema.org/draft-07/schema#",
49///             "type": "string"
50///         }
51///     }
52/// });
53///
54/// strip_schema_keyword(&mut schema);
55/// assert!(schema.get("$schema").is_none());
56/// ```
57pub fn strip_schema_keyword(schema: &mut Value) {
58    if let Some(obj) = schema.as_object_mut() {
59        obj.remove("$schema");
60    }
61    recurse_into_subschemas(schema, strip_schema_keyword);
62}
63
64/// Removes conditional keywords (`if`, `then`, `else`) from the schema and all
65/// nested sub-schemas.
66///
67/// Providers like Gemini, OpenAI, and Anthropic do not support JSON Schema
68/// conditional composition.
69///
70/// # Example
71///
72/// ```rust
73/// use serde_json::json;
74/// use adk_core::schema_utils::strip_conditional_keywords;
75///
76/// let mut schema = json!({
77///     "type": "object",
78///     "if": { "properties": { "kind": { "const": "a" } } },
79///     "then": { "required": ["extra"] },
80///     "else": { "required": [] }
81/// });
82///
83/// strip_conditional_keywords(&mut schema);
84/// assert!(schema.get("if").is_none());
85/// assert!(schema.get("then").is_none());
86/// assert!(schema.get("else").is_none());
87/// ```
88pub fn strip_conditional_keywords(schema: &mut Value) {
89    if let Some(obj) = schema.as_object_mut() {
90        obj.remove("if");
91        obj.remove("then");
92        obj.remove("else");
93    }
94    recurse_into_subschemas(schema, strip_conditional_keywords);
95}
96
97/// Adds `"type": "object"` when `properties` exists without a `type` field.
98///
99/// Some schema authors omit the explicit `type` when `properties` is present.
100/// Most LLM providers require the `type` field to be explicit.
101///
102/// # Example
103///
104/// ```rust
105/// use serde_json::json;
106/// use adk_core::schema_utils::add_implicit_object_type;
107///
108/// let mut schema = json!({
109///     "properties": {
110///         "name": { "type": "string" }
111///     }
112/// });
113///
114/// add_implicit_object_type(&mut schema);
115/// assert_eq!(schema["type"], "object");
116/// ```
117pub fn add_implicit_object_type(schema: &mut Value) {
118    if let Some(obj) = schema.as_object_mut() {
119        if obj.contains_key("properties") && !obj.contains_key("type") {
120            obj.insert("type".to_string(), Value::String("object".to_string()));
121        }
122    }
123    recurse_into_subschemas(schema, add_implicit_object_type);
124}
125
126/// Converts `const` values to single-element `enum` arrays.
127///
128/// Providers that do not support the `const` keyword can still enforce fixed
129/// values via a single-element `enum`.
130///
131/// # Example
132///
133/// ```rust
134/// use serde_json::json;
135/// use adk_core::schema_utils::convert_const_to_enum;
136///
137/// let mut schema = json!({
138///     "type": "string",
139///     "const": "fixed_value"
140/// });
141///
142/// convert_const_to_enum(&mut schema);
143/// assert!(schema.get("const").is_none());
144/// assert_eq!(schema["enum"], json!(["fixed_value"]));
145/// ```
146pub fn convert_const_to_enum(schema: &mut Value) {
147    if let Some(obj) = schema.as_object_mut() {
148        if let Some(const_val) = obj.remove("const") {
149            obj.insert("enum".to_string(), Value::Array(vec![const_val]));
150        }
151    }
152    recurse_into_subschemas(schema, convert_const_to_enum);
153}
154
155/// Removes `format` values not in the `allowed` list from the schema and all
156/// nested sub-schemas.
157///
158/// Some providers reject schemas with unsupported format annotations. This
159/// function strips any `format` value not present in the provided allowlist.
160///
161/// # Arguments
162///
163/// * `schema` - The schema to transform in place.
164/// * `allowed` - Slice of allowed format strings (e.g., `&["date-time", "email"]`).
165///
166/// # Example
167///
168/// ```rust
169/// use serde_json::json;
170/// use adk_core::schema_utils::strip_unsupported_formats;
171///
172/// let mut schema = json!({
173///     "type": "string",
174///     "format": "hostname"
175/// });
176///
177/// strip_unsupported_formats(&mut schema, &["date-time", "email", "uri"]);
178/// assert!(schema.get("format").is_none());
179/// ```
180pub fn strip_unsupported_formats(schema: &mut Value, allowed: &[&str]) {
181    if let Some(obj) = schema.as_object_mut() {
182        let should_remove =
183            obj.get("format").and_then(|f| f.as_str()).is_some_and(|f| !allowed.contains(&f));
184        if should_remove {
185            obj.remove("format");
186        }
187    }
188    // Recurse manually since we need to pass `allowed` through
189    recurse_into_subschemas_with_context(schema, allowed, strip_unsupported_formats);
190}
191
192/// Truncates a tool name to at most `max_bytes` bytes, preserving valid UTF-8.
193///
194/// If the name is already within the limit, returns a borrowed reference.
195/// Otherwise, truncates at the nearest character boundary at or before
196/// `max_bytes` and returns an owned string.
197///
198/// # Arguments
199///
200/// * `name` - The tool name to potentially truncate.
201/// * `max_bytes` - Maximum byte length for the result.
202///
203/// # Example
204///
205/// ```rust
206/// use adk_core::schema_utils::truncate_tool_name;
207///
208/// let short = truncate_tool_name("hello", 64);
209/// assert_eq!(short, "hello");
210///
211/// let long = "a".repeat(100);
212/// let truncated = truncate_tool_name(&long, 64);
213/// assert_eq!(truncated.len(), 64);
214/// ```
215pub fn truncate_tool_name(name: &str, max_bytes: usize) -> Cow<'_, str> {
216    if name.len() <= max_bytes {
217        Cow::Borrowed(name)
218    } else {
219        // Find the nearest char boundary at or before max_bytes
220        let mut end = max_bytes;
221        while end > 0 && !name.is_char_boundary(end) {
222            end -= 1;
223        }
224        Cow::Owned(name[..end].to_string())
225    }
226}
227
228/// Removes JSON `null` values from `enum` arrays in the schema and all nested
229/// sub-schemas.
230///
231/// If removing null results in an empty `enum` array, the `enum` keyword is
232/// removed entirely.
233///
234/// # Example
235///
236/// ```rust
237/// use serde_json::json;
238/// use adk_core::schema_utils::strip_null_from_enum;
239///
240/// let mut schema = json!({
241///     "type": "string",
242///     "enum": ["a", null, "b"]
243/// });
244///
245/// strip_null_from_enum(&mut schema);
246/// assert_eq!(schema["enum"], json!(["a", "b"]));
247/// ```
248pub fn strip_null_from_enum(schema: &mut Value) {
249    if let Some(obj) = schema.as_object_mut() {
250        if let Some(enum_val) = obj.get_mut("enum") {
251            if let Some(arr) = enum_val.as_array_mut() {
252                arr.retain(|v| !v.is_null());
253                if arr.is_empty() {
254                    obj.remove("enum");
255                }
256            }
257        }
258    }
259    recurse_into_subschemas(schema, strip_null_from_enum);
260}
261
262/// Resolves `$ref` references by inlining the referenced sub-schema from a
263/// definitions map.
264///
265/// Handles both `#/definitions/<name>` (Draft 4–7) and `#/$defs/<name>`
266/// (Draft 2019-09+) reference formats. Unresolvable references are replaced
267/// with `{"type": "object"}`. Circular reference chains are broken by
268/// replacing the schema with `{"type": "object"}` when `depth` exceeds 10.
269///
270/// After resolving a `$ref`, the function recurses into the inlined schema
271/// (incrementing depth) to resolve any nested references. When no `$ref` is
272/// present, the function recurses into all sub-schemas (properties, items,
273/// allOf, anyOf, oneOf, etc.).
274///
275/// # Arguments
276///
277/// * `schema` - The schema to transform in place.
278/// * `definitions` - A map of definition names to their sub-schemas (combined
279///   from both `definitions` and `$defs` at the top level).
280/// * `depth` - Current recursion depth. Pass `0` for the initial call.
281///
282/// # Example
283///
284/// ```rust
285/// use serde_json::{json, Map, Value};
286/// use adk_core::schema_utils::resolve_refs;
287///
288/// let mut defs = Map::new();
289/// defs.insert("Address".to_string(), json!({"type": "object", "properties": {"street": {"type": "string"}}}));
290///
291/// let mut schema = json!({
292///     "type": "object",
293///     "properties": {
294///         "home": { "$ref": "#/definitions/Address" }
295///     }
296/// });
297///
298/// resolve_refs(&mut schema, &defs, 0);
299/// assert_eq!(schema["properties"]["home"]["type"], "object");
300/// assert!(schema["properties"]["home"].get("$ref").is_none());
301/// ```
302pub fn resolve_refs(schema: &mut Value, definitions: &Map<String, Value>, depth: usize) {
303    // Break circular chains at max depth 10
304    if depth > 10 {
305        // Only replace if this schema itself has a $ref (circular chain detected)
306        if schema.as_object().is_some_and(|obj| obj.contains_key("$ref")) {
307            *schema = serde_json::json!({"type": "object"});
308        }
309        return;
310    }
311
312    let Some(obj) = schema.as_object() else {
313        return;
314    };
315
316    if let Some(ref_val) = obj.get("$ref").and_then(|v| v.as_str()) {
317        // Parse the ref path: #/definitions/<name> or #/$defs/<name>
318        let name =
319            ref_val.strip_prefix("#/definitions/").or_else(|| ref_val.strip_prefix("#/$defs/"));
320
321        if let Some(def_name) = name {
322            if let Some(def_schema) = definitions.get(def_name) {
323                // Replace the entire schema node with the referenced sub-schema
324                *schema = def_schema.clone();
325            } else {
326                // Unresolvable ref — replace with fallback
327                *schema = serde_json::json!({"type": "object"});
328            }
329        } else {
330            // Unsupported ref format — replace with fallback
331            *schema = serde_json::json!({"type": "object"});
332        }
333
334        // Recursively resolve refs in the inlined schema
335        resolve_refs(schema, definitions, depth + 1);
336    } else {
337        // No $ref — recurse into all sub-schemas
338        resolve_refs_recurse(schema, definitions, depth);
339    }
340}
341
342/// Recurses into all sub-schema locations to resolve nested `$ref` references.
343fn resolve_refs_recurse(schema: &mut Value, definitions: &Map<String, Value>, depth: usize) {
344    let Some(obj) = schema.as_object_mut() else {
345        return;
346    };
347
348    // properties
349    if let Some(props) = obj.get_mut("properties") {
350        if let Some(props_obj) = props.as_object_mut() {
351            for value in props_obj.values_mut() {
352                resolve_refs(value, definitions, depth);
353            }
354        }
355    }
356
357    // items (single schema or array)
358    if let Some(items) = obj.get_mut("items") {
359        if items.is_object() {
360            resolve_refs(items, definitions, depth);
361        } else if let Some(arr) = items.as_array_mut() {
362            for item in arr.iter_mut() {
363                resolve_refs(item, definitions, depth);
364            }
365        }
366    }
367
368    // additionalProperties (when it's a schema object, not a boolean)
369    if let Some(additional) = obj.get_mut("additionalProperties") {
370        if additional.is_object() {
371            resolve_refs(additional, definitions, depth);
372        }
373    }
374
375    // allOf, anyOf, oneOf
376    for keyword in &["allOf", "anyOf", "oneOf"] {
377        if let Some(arr_val) = obj.get_mut(*keyword) {
378            if let Some(arr) = arr_val.as_array_mut() {
379                for sub in arr.iter_mut() {
380                    resolve_refs(sub, definitions, depth);
381                }
382            }
383        }
384    }
385
386    // not
387    if let Some(not_schema) = obj.get_mut("not") {
388        if not_schema.is_object() {
389            resolve_refs(not_schema, definitions, depth);
390        }
391    }
392
393    // patternProperties
394    if let Some(pattern_props) = obj.get_mut("patternProperties") {
395        if let Some(pp_obj) = pattern_props.as_object_mut() {
396            for value in pp_obj.values_mut() {
397                resolve_refs(value, definitions, depth);
398            }
399        }
400    }
401
402    // prefixItems
403    if let Some(prefix_items) = obj.get_mut("prefixItems") {
404        if let Some(arr) = prefix_items.as_array_mut() {
405            for item in arr.iter_mut() {
406                resolve_refs(item, definitions, depth);
407            }
408        }
409    }
410
411    // if, then, else
412    for keyword in &["if", "then", "else"] {
413        if let Some(sub) = obj.get_mut(*keyword) {
414            if sub.is_object() {
415                resolve_refs(sub, definitions, depth);
416            }
417        }
418    }
419}
420
421/// Recursively applies a transform function to all nested sub-schemas.
422///
423/// This traverses into:
424/// - `properties` (each property value)
425/// - `items` (single schema or array of schemas)
426/// - `additionalProperties` (when it's a schema object)
427/// - `allOf`, `anyOf`, `oneOf` (each sub-schema in the array)
428/// - `not` (single sub-schema)
429/// - `patternProperties` (each property value)
430/// - `prefixItems` (each sub-schema in the array)
431/// - `if`, `then`, `else` (single sub-schemas)
432fn recurse_into_subschemas(schema: &mut Value, transform: fn(&mut Value)) {
433    let Some(obj) = schema.as_object_mut() else {
434        return;
435    };
436
437    // properties
438    if let Some(props) = obj.get_mut("properties") {
439        if let Some(props_obj) = props.as_object_mut() {
440            for value in props_obj.values_mut() {
441                transform(value);
442            }
443        }
444    }
445
446    // items (single schema or array)
447    if let Some(items) = obj.get_mut("items") {
448        if items.is_object() {
449            transform(items);
450        } else if let Some(arr) = items.as_array_mut() {
451            for item in arr.iter_mut() {
452                transform(item);
453            }
454        }
455    }
456
457    // additionalProperties (when it's a schema object, not a boolean)
458    if let Some(additional) = obj.get_mut("additionalProperties") {
459        if additional.is_object() {
460            transform(additional);
461        }
462    }
463
464    // allOf, anyOf, oneOf
465    for keyword in &["allOf", "anyOf", "oneOf"] {
466        if let Some(arr_val) = obj.get_mut(*keyword) {
467            if let Some(arr) = arr_val.as_array_mut() {
468                for sub in arr.iter_mut() {
469                    transform(sub);
470                }
471            }
472        }
473    }
474
475    // not
476    if let Some(not_schema) = obj.get_mut("not") {
477        if not_schema.is_object() {
478            transform(not_schema);
479        }
480    }
481
482    // patternProperties
483    if let Some(pattern_props) = obj.get_mut("patternProperties") {
484        if let Some(pp_obj) = pattern_props.as_object_mut() {
485            for value in pp_obj.values_mut() {
486                transform(value);
487            }
488        }
489    }
490
491    // prefixItems
492    if let Some(prefix_items) = obj.get_mut("prefixItems") {
493        if let Some(arr) = prefix_items.as_array_mut() {
494            for item in arr.iter_mut() {
495                transform(item);
496            }
497        }
498    }
499
500    // if, then, else (for transforms that don't strip them)
501    for keyword in &["if", "then", "else"] {
502        if let Some(sub) = obj.get_mut(*keyword) {
503            if sub.is_object() {
504                transform(sub);
505            }
506        }
507    }
508}
509
510/// Recursively applies a transform function with additional context to all nested sub-schemas.
511///
512/// Same traversal as [`recurse_into_subschemas`] but passes a context parameter through.
513fn recurse_into_subschemas_with_context<C: ?Sized>(
514    schema: &mut Value,
515    ctx: &C,
516    transform: fn(&mut Value, &C),
517) {
518    let Some(obj) = schema.as_object_mut() else {
519        return;
520    };
521
522    // properties
523    if let Some(props) = obj.get_mut("properties") {
524        if let Some(props_obj) = props.as_object_mut() {
525            for value in props_obj.values_mut() {
526                transform(value, ctx);
527            }
528        }
529    }
530
531    // items (single schema or array)
532    if let Some(items) = obj.get_mut("items") {
533        if items.is_object() {
534            transform(items, ctx);
535        } else if let Some(arr) = items.as_array_mut() {
536            for item in arr.iter_mut() {
537                transform(item, ctx);
538            }
539        }
540    }
541
542    // additionalProperties (when it's a schema object, not a boolean)
543    if let Some(additional) = obj.get_mut("additionalProperties") {
544        if additional.is_object() {
545            transform(additional, ctx);
546        }
547    }
548
549    // allOf, anyOf, oneOf
550    for keyword in &["allOf", "anyOf", "oneOf"] {
551        if let Some(arr_val) = obj.get_mut(*keyword) {
552            if let Some(arr) = arr_val.as_array_mut() {
553                for sub in arr.iter_mut() {
554                    transform(sub, ctx);
555                }
556            }
557        }
558    }
559
560    // not
561    if let Some(not_schema) = obj.get_mut("not") {
562        if not_schema.is_object() {
563            transform(not_schema, ctx);
564        }
565    }
566
567    // patternProperties
568    if let Some(pattern_props) = obj.get_mut("patternProperties") {
569        if let Some(pp_obj) = pattern_props.as_object_mut() {
570            for value in pp_obj.values_mut() {
571                transform(value, ctx);
572            }
573        }
574    }
575
576    // prefixItems
577    if let Some(prefix_items) = obj.get_mut("prefixItems") {
578        if let Some(arr) = prefix_items.as_array_mut() {
579            for item in arr.iter_mut() {
580                transform(item, ctx);
581            }
582        }
583    }
584
585    // if, then, else
586    for keyword in &["if", "then", "else"] {
587        if let Some(sub) = obj.get_mut(*keyword) {
588            if sub.is_object() {
589                transform(sub, ctx);
590            }
591        }
592    }
593}
594
595/// Collapses `anyOf`/`oneOf` arrays to the first non-null sub-schema.
596///
597/// For each `anyOf` or `oneOf` array, finds the first sub-schema that is NOT
598/// `{"type": "null"}`, merges its fields into the parent schema, and removes
599/// the combiner key. If all sub-schemas are null, uses the first one.
600///
601/// Recurses into nested schemas after collapsing.
602///
603/// # Example
604///
605/// ```rust
606/// use serde_json::json;
607/// use adk_core::schema_utils::collapse_combiners;
608///
609/// let mut schema = json!({
610///     "anyOf": [
611///         {"type": "null"},
612///         {"type": "string", "minLength": 1}
613///     ]
614/// });
615///
616/// collapse_combiners(&mut schema);
617/// assert_eq!(schema["type"], "string");
618/// assert_eq!(schema["minLength"], 1);
619/// assert!(schema.get("anyOf").is_none());
620/// ```
621pub fn collapse_combiners(schema: &mut Value) {
622    let Some(obj) = schema.as_object_mut() else {
623        return;
624    };
625
626    for keyword in &["anyOf", "oneOf"] {
627        if let Some(arr_val) = obj.remove(*keyword) {
628            if let Some(arr) = arr_val.as_array() {
629                // Find the first non-null sub-schema
630                let chosen = arr.iter().find(|sub| !is_null_schema(sub)).or_else(|| arr.first());
631
632                if let Some(chosen_schema) = chosen {
633                    if let Some(chosen_obj) = chosen_schema.as_object() {
634                        // Merge chosen sub-schema fields into parent
635                        for (key, value) in chosen_obj {
636                            obj.insert(key.clone(), value.clone());
637                        }
638                    }
639                }
640            }
641            // Only process one combiner keyword per level
642            break;
643        }
644    }
645
646    recurse_into_subschemas(schema, collapse_combiners);
647}
648
649/// Merges `allOf` arrays by combining all sub-schemas into a single schema.
650///
651/// Combines `properties`, `required`, and other fields from all sub-schemas.
652/// If `type` conflicts across sub-schemas, prefers `"object"`. Removes the
653/// `allOf` key after merging.
654///
655/// Recurses into nested schemas after merging.
656///
657/// # Example
658///
659/// ```rust
660/// use serde_json::json;
661/// use adk_core::schema_utils::merge_all_of;
662///
663/// let mut schema = json!({
664///     "allOf": [
665///         {"type": "object", "properties": {"a": {"type": "string"}}},
666///         {"properties": {"b": {"type": "number"}}, "required": ["b"]}
667///     ]
668/// });
669///
670/// merge_all_of(&mut schema);
671/// assert!(schema.get("allOf").is_none());
672/// assert_eq!(schema["properties"]["a"]["type"], "string");
673/// assert_eq!(schema["properties"]["b"]["type"], "number");
674/// assert_eq!(schema["required"], json!(["b"]));
675/// ```
676pub fn merge_all_of(schema: &mut Value) {
677    let Some(obj) = schema.as_object_mut() else {
678        return;
679    };
680
681    if let Some(arr_val) = obj.remove("allOf") {
682        if let Some(arr) = arr_val.as_array() {
683            let mut merged_properties = Map::new();
684            let mut merged_required: Vec<Value> = Vec::new();
685            let mut merged_type: Option<Value> = None;
686            let mut other_fields = Map::new();
687
688            for sub in arr {
689                let Some(sub_obj) = sub.as_object() else {
690                    continue;
691                };
692
693                for (key, value) in sub_obj {
694                    match key.as_str() {
695                        "properties" => {
696                            if let Some(props) = value.as_object() {
697                                for (pk, pv) in props {
698                                    merged_properties.insert(pk.clone(), pv.clone());
699                                }
700                            }
701                        }
702                        "required" => {
703                            if let Some(req_arr) = value.as_array() {
704                                for item in req_arr {
705                                    if !merged_required.contains(item) {
706                                        merged_required.push(item.clone());
707                                    }
708                                }
709                            }
710                        }
711                        "type" => {
712                            if let Some(existing) = &merged_type {
713                                // Conflict: prefer "object"
714                                if existing != value {
715                                    merged_type = Some(Value::String("object".to_string()));
716                                }
717                            } else {
718                                merged_type = Some(value.clone());
719                            }
720                        }
721                        _ => {
722                            other_fields.insert(key.clone(), value.clone());
723                        }
724                    }
725                }
726            }
727
728            // Merge other fields first (lower priority)
729            for (key, value) in other_fields {
730                obj.entry(key).or_insert(value);
731            }
732
733            // Merge type
734            if let Some(type_val) = merged_type {
735                obj.insert("type".to_string(), type_val);
736            }
737
738            // Merge properties
739            if !merged_properties.is_empty() {
740                let existing_props =
741                    obj.entry("properties").or_insert_with(|| Value::Object(Map::new()));
742                if let Some(existing_obj) = existing_props.as_object_mut() {
743                    for (key, value) in merged_properties {
744                        existing_obj.insert(key, value);
745                    }
746                }
747            }
748
749            // Merge required
750            if !merged_required.is_empty() {
751                let existing_required =
752                    obj.entry("required").or_insert_with(|| Value::Array(Vec::new()));
753                if let Some(existing_arr) = existing_required.as_array_mut() {
754                    for item in merged_required {
755                        if !existing_arr.contains(&item) {
756                            existing_arr.push(item);
757                        }
758                    }
759                }
760            }
761        }
762    }
763
764    recurse_into_subschemas(schema, merge_all_of);
765}
766
767/// Collapses type arrays to the first non-null type string.
768///
769/// When `type` is an array like `["string", "null"]`, collapses to the first
770/// non-null type string (e.g., `"string"`). If all types are `"null"`, uses
771/// `"null"`.
772///
773/// Recurses into nested schemas.
774///
775/// # Example
776///
777/// ```rust
778/// use serde_json::json;
779/// use adk_core::schema_utils::collapse_type_arrays;
780///
781/// let mut schema = json!({
782///     "type": ["string", "null"]
783/// });
784///
785/// collapse_type_arrays(&mut schema);
786/// assert_eq!(schema["type"], "string");
787/// ```
788pub fn collapse_type_arrays(schema: &mut Value) {
789    if let Some(obj) = schema.as_object_mut() {
790        if let Some(type_val) = obj.get("type").cloned() {
791            if let Some(arr) = type_val.as_array() {
792                let chosen =
793                    arr.iter().find(|t| t.as_str() != Some("null")).or_else(|| arr.first());
794
795                if let Some(chosen_type) = chosen {
796                    obj.insert("type".to_string(), chosen_type.clone());
797                }
798            }
799        }
800    }
801    recurse_into_subschemas(schema, collapse_type_arrays);
802}
803
804/// Enforces a maximum nesting depth for object schemas.
805///
806/// Tracks nesting depth through object schemas. When `current >= max_depth`,
807/// replaces the schema with `{"type": "object"}` and emits a `tracing::warn!()`
808/// log. Recurses into properties/items/etc, incrementing depth for object schemas.
809///
810/// # Arguments
811///
812/// * `schema` - The schema to enforce depth on.
813/// * `max_depth` - Maximum allowed nesting depth.
814/// * `current` - Current depth (start at 0).
815///
816/// # Example
817///
818/// ```rust
819/// use serde_json::json;
820/// use adk_core::schema_utils::enforce_nesting_depth;
821///
822/// let mut schema = json!({
823///     "type": "object",
824///     "properties": {
825///         "level1": {
826///             "type": "object",
827///             "properties": {
828///                 "level2": { "type": "string" }
829///             }
830///         }
831///     }
832/// });
833///
834/// enforce_nesting_depth(&mut schema, 1, 0);
835/// // level1 is at depth 1, so it gets replaced
836/// assert_eq!(schema["properties"]["level1"], json!({"type": "object"}));
837/// ```
838pub fn enforce_nesting_depth(schema: &mut Value, max_depth: usize, current: usize) {
839    let Some(obj) = schema.as_object_mut() else {
840        return;
841    };
842
843    let is_object_schema = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "object")
844        || obj.contains_key("properties");
845
846    if is_object_schema && current >= max_depth {
847        tracing::warn!(
848            depth = current,
849            max_depth,
850            "schema nesting depth exceeded, truncating to {{\"type\": \"object\"}}"
851        );
852        *schema = serde_json::json!({"type": "object"});
853        return;
854    }
855
856    let next_depth = if is_object_schema { current + 1 } else { current };
857
858    // Recurse into properties
859    if let Some(props) = obj.get_mut("properties") {
860        if let Some(props_obj) = props.as_object_mut() {
861            for value in props_obj.values_mut() {
862                enforce_nesting_depth(value, max_depth, next_depth);
863            }
864        }
865    }
866
867    // Recurse into items
868    if let Some(items) = obj.get_mut("items") {
869        if items.is_object() {
870            enforce_nesting_depth(items, max_depth, next_depth);
871        } else if let Some(arr) = items.as_array_mut() {
872            for item in arr.iter_mut() {
873                enforce_nesting_depth(item, max_depth, next_depth);
874            }
875        }
876    }
877
878    // Recurse into additionalProperties
879    if let Some(additional) = obj.get_mut("additionalProperties") {
880        if additional.is_object() {
881            enforce_nesting_depth(additional, max_depth, next_depth);
882        }
883    }
884
885    // Recurse into allOf, anyOf, oneOf
886    for keyword in &["allOf", "anyOf", "oneOf"] {
887        if let Some(arr_val) = obj.get_mut(*keyword) {
888            if let Some(arr) = arr_val.as_array_mut() {
889                for sub in arr.iter_mut() {
890                    enforce_nesting_depth(sub, max_depth, next_depth);
891                }
892            }
893        }
894    }
895
896    // Recurse into not
897    if let Some(not_schema) = obj.get_mut("not") {
898        if not_schema.is_object() {
899            enforce_nesting_depth(not_schema, max_depth, next_depth);
900        }
901    }
902
903    // Recurse into patternProperties
904    if let Some(pattern_props) = obj.get_mut("patternProperties") {
905        if let Some(pp_obj) = pattern_props.as_object_mut() {
906            for value in pp_obj.values_mut() {
907                enforce_nesting_depth(value, max_depth, next_depth);
908            }
909        }
910    }
911}
912
913/// Returns `true` if the schema represents a null type.
914///
915/// Matches `{"type": "null"}` exactly (with no other fields) or schemas
916/// where the only meaningful content is `type: null`.
917fn is_null_schema(schema: &Value) -> bool {
918    schema
919        .as_object()
920        .and_then(|obj| obj.get("type"))
921        .and_then(|t| t.as_str())
922        .is_some_and(|t| t == "null")
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928    use serde_json::json;
929
930    // --- strip_schema_keyword tests ---
931
932    #[test]
933    fn test_strip_schema_keyword_top_level() {
934        let mut schema = json!({
935            "$schema": "http://json-schema.org/draft-07/schema#",
936            "type": "object"
937        });
938        strip_schema_keyword(&mut schema);
939        assert!(schema.get("$schema").is_none());
940        assert_eq!(schema["type"], "object");
941    }
942
943    #[test]
944    fn test_strip_schema_keyword_nested() {
945        let mut schema = json!({
946            "type": "object",
947            "properties": {
948                "child": {
949                    "$schema": "http://json-schema.org/draft-07/schema#",
950                    "type": "string"
951                }
952            }
953        });
954        strip_schema_keyword(&mut schema);
955        assert!(schema["properties"]["child"].get("$schema").is_none());
956    }
957
958    #[test]
959    fn test_strip_schema_keyword_no_op_when_absent() {
960        let mut schema = json!({"type": "string"});
961        let expected = schema.clone();
962        strip_schema_keyword(&mut schema);
963        assert_eq!(schema, expected);
964    }
965
966    // --- strip_conditional_keywords tests ---
967
968    #[test]
969    fn test_strip_conditional_keywords() {
970        let mut schema = json!({
971            "type": "object",
972            "if": { "properties": { "kind": { "const": "a" } } },
973            "then": { "required": ["extra"] },
974            "else": { "required": [] },
975            "properties": { "kind": { "type": "string" } }
976        });
977        strip_conditional_keywords(&mut schema);
978        assert!(schema.get("if").is_none());
979        assert!(schema.get("then").is_none());
980        assert!(schema.get("else").is_none());
981        assert!(schema.get("properties").is_some());
982    }
983
984    #[test]
985    fn test_strip_conditional_keywords_nested() {
986        let mut schema = json!({
987            "type": "object",
988            "properties": {
989                "child": {
990                    "type": "object",
991                    "if": { "const": true },
992                    "then": { "type": "string" }
993                }
994            }
995        });
996        strip_conditional_keywords(&mut schema);
997        assert!(schema["properties"]["child"].get("if").is_none());
998        assert!(schema["properties"]["child"].get("then").is_none());
999    }
1000
1001    // --- add_implicit_object_type tests ---
1002
1003    #[test]
1004    fn test_add_implicit_object_type() {
1005        let mut schema = json!({
1006            "properties": {
1007                "name": { "type": "string" }
1008            }
1009        });
1010        add_implicit_object_type(&mut schema);
1011        assert_eq!(schema["type"], "object");
1012    }
1013
1014    #[test]
1015    fn test_add_implicit_object_type_no_op_when_type_present() {
1016        let mut schema = json!({
1017            "type": "object",
1018            "properties": {
1019                "name": { "type": "string" }
1020            }
1021        });
1022        let expected = schema.clone();
1023        add_implicit_object_type(&mut schema);
1024        assert_eq!(schema, expected);
1025    }
1026
1027    #[test]
1028    fn test_add_implicit_object_type_nested() {
1029        let mut schema = json!({
1030            "type": "object",
1031            "properties": {
1032                "nested": {
1033                    "properties": {
1034                        "field": { "type": "number" }
1035                    }
1036                }
1037            }
1038        });
1039        add_implicit_object_type(&mut schema);
1040        assert_eq!(schema["properties"]["nested"]["type"], "object");
1041    }
1042
1043    // --- convert_const_to_enum tests ---
1044
1045    #[test]
1046    fn test_convert_const_to_enum() {
1047        let mut schema = json!({
1048            "type": "string",
1049            "const": "fixed"
1050        });
1051        convert_const_to_enum(&mut schema);
1052        assert!(schema.get("const").is_none());
1053        assert_eq!(schema["enum"], json!(["fixed"]));
1054    }
1055
1056    #[test]
1057    fn test_convert_const_to_enum_null() {
1058        let mut schema = json!({
1059            "const": null
1060        });
1061        convert_const_to_enum(&mut schema);
1062        assert!(schema.get("const").is_none());
1063        assert_eq!(schema["enum"], json!([null]));
1064    }
1065
1066    #[test]
1067    fn test_convert_const_to_enum_nested() {
1068        let mut schema = json!({
1069            "type": "object",
1070            "properties": {
1071                "status": {
1072                    "type": "string",
1073                    "const": "active"
1074                }
1075            }
1076        });
1077        convert_const_to_enum(&mut schema);
1078        assert_eq!(schema["properties"]["status"]["enum"], json!(["active"]));
1079    }
1080
1081    // --- strip_unsupported_formats tests ---
1082
1083    #[test]
1084    fn test_strip_unsupported_formats_removes_unsupported() {
1085        let mut schema = json!({
1086            "type": "string",
1087            "format": "hostname"
1088        });
1089        strip_unsupported_formats(&mut schema, &["date-time", "email"]);
1090        assert!(schema.get("format").is_none());
1091    }
1092
1093    #[test]
1094    fn test_strip_unsupported_formats_keeps_allowed() {
1095        let mut schema = json!({
1096            "type": "string",
1097            "format": "email"
1098        });
1099        strip_unsupported_formats(&mut schema, &["date-time", "email"]);
1100        assert_eq!(schema["format"], "email");
1101    }
1102
1103    #[test]
1104    fn test_strip_unsupported_formats_nested() {
1105        let mut schema = json!({
1106            "type": "object",
1107            "properties": {
1108                "created": { "type": "string", "format": "date-time" },
1109                "hostname": { "type": "string", "format": "hostname" }
1110            }
1111        });
1112        strip_unsupported_formats(&mut schema, &["date-time"]);
1113        assert_eq!(schema["properties"]["created"]["format"], "date-time");
1114        assert!(schema["properties"]["hostname"].get("format").is_none());
1115    }
1116
1117    // --- truncate_tool_name tests ---
1118
1119    #[test]
1120    fn test_truncate_tool_name_short() {
1121        let result = truncate_tool_name("short_name", 64);
1122        assert_eq!(result, "short_name");
1123        assert!(matches!(result, Cow::Borrowed(_)));
1124    }
1125
1126    #[test]
1127    fn test_truncate_tool_name_exact_boundary() {
1128        let name = "a".repeat(64);
1129        let result = truncate_tool_name(&name, 64);
1130        assert_eq!(result.len(), 64);
1131        assert!(matches!(result, Cow::Borrowed(_)));
1132    }
1133
1134    #[test]
1135    fn test_truncate_tool_name_over_limit() {
1136        let name = "a".repeat(100);
1137        let result = truncate_tool_name(&name, 64);
1138        assert_eq!(result.len(), 64);
1139        assert!(matches!(result, Cow::Owned(_)));
1140    }
1141
1142    #[test]
1143    fn test_truncate_tool_name_multibyte_boundary() {
1144        // "é" is 2 bytes in UTF-8. Create a string where byte 64 falls mid-character.
1145        let name = "a".repeat(63) + "é"; // 63 + 2 = 65 bytes
1146        let result = truncate_tool_name(&name, 64);
1147        // Should truncate to 63 bytes (before the multi-byte char)
1148        assert_eq!(result.len(), 63);
1149        assert!(result.is_char_boundary(result.len()));
1150    }
1151
1152    #[test]
1153    fn test_truncate_tool_name_emoji() {
1154        // "🎯" is 4 bytes. Create a string where byte 64 falls mid-emoji.
1155        let name = "a".repeat(62) + "🎯"; // 62 + 4 = 66 bytes
1156        let result = truncate_tool_name(&name, 64);
1157        // Should truncate to 62 bytes (before the emoji)
1158        assert_eq!(result.len(), 62);
1159    }
1160
1161    #[test]
1162    fn test_truncate_tool_name_empty() {
1163        let result = truncate_tool_name("", 64);
1164        assert_eq!(result, "");
1165        assert!(matches!(result, Cow::Borrowed(_)));
1166    }
1167
1168    // --- strip_null_from_enum tests ---
1169
1170    #[test]
1171    fn test_strip_null_from_enum() {
1172        let mut schema = json!({
1173            "type": "string",
1174            "enum": ["a", null, "b"]
1175        });
1176        strip_null_from_enum(&mut schema);
1177        assert_eq!(schema["enum"], json!(["a", "b"]));
1178    }
1179
1180    #[test]
1181    fn test_strip_null_from_enum_all_null() {
1182        let mut schema = json!({
1183            "type": "string",
1184            "enum": [null]
1185        });
1186        strip_null_from_enum(&mut schema);
1187        assert!(schema.get("enum").is_none());
1188    }
1189
1190    #[test]
1191    fn test_strip_null_from_enum_no_null() {
1192        let mut schema = json!({
1193            "type": "string",
1194            "enum": ["a", "b"]
1195        });
1196        let expected = schema.clone();
1197        strip_null_from_enum(&mut schema);
1198        assert_eq!(schema, expected);
1199    }
1200
1201    #[test]
1202    fn test_strip_null_from_enum_nested() {
1203        let mut schema = json!({
1204            "type": "object",
1205            "properties": {
1206                "status": {
1207                    "type": "string",
1208                    "enum": ["active", null, "inactive"]
1209                }
1210            }
1211        });
1212        strip_null_from_enum(&mut schema);
1213        assert_eq!(schema["properties"]["status"]["enum"], json!(["active", "inactive"]));
1214    }
1215
1216    // --- Recursion into combiners tests ---
1217
1218    #[test]
1219    fn test_recursion_into_any_of() {
1220        let mut schema = json!({
1221            "anyOf": [
1222                { "$schema": "draft-07", "type": "string" },
1223                { "$schema": "draft-07", "type": "number" }
1224            ]
1225        });
1226        strip_schema_keyword(&mut schema);
1227        assert!(schema["anyOf"][0].get("$schema").is_none());
1228        assert!(schema["anyOf"][1].get("$schema").is_none());
1229    }
1230
1231    #[test]
1232    fn test_recursion_into_all_of() {
1233        let mut schema = json!({
1234            "allOf": [
1235                { "properties": { "a": { "type": "string" } } },
1236                { "properties": { "b": { "type": "number" } } }
1237            ]
1238        });
1239        add_implicit_object_type(&mut schema);
1240        assert_eq!(schema["allOf"][0]["type"], "object");
1241        assert_eq!(schema["allOf"][1]["type"], "object");
1242    }
1243
1244    #[test]
1245    fn test_recursion_into_items() {
1246        let mut schema = json!({
1247            "type": "array",
1248            "items": {
1249                "$schema": "draft-07",
1250                "type": "string",
1251                "format": "hostname"
1252            }
1253        });
1254        strip_schema_keyword(&mut schema);
1255        strip_unsupported_formats(&mut schema, &["date-time"]);
1256        assert!(schema["items"].get("$schema").is_none());
1257        assert!(schema["items"].get("format").is_none());
1258    }
1259
1260    #[test]
1261    fn test_recursion_into_additional_properties() {
1262        let mut schema = json!({
1263            "type": "object",
1264            "additionalProperties": {
1265                "$schema": "draft-07",
1266                "type": "string"
1267            }
1268        });
1269        strip_schema_keyword(&mut schema);
1270        assert!(schema["additionalProperties"].get("$schema").is_none());
1271    }
1272
1273    #[test]
1274    fn test_recursion_into_not() {
1275        let mut schema = json!({
1276            "not": {
1277                "$schema": "draft-07",
1278                "type": "null"
1279            }
1280        });
1281        strip_schema_keyword(&mut schema);
1282        assert!(schema["not"].get("$schema").is_none());
1283    }
1284
1285    #[test]
1286    fn test_deeply_nested_recursion() {
1287        let mut schema = json!({
1288            "type": "object",
1289            "properties": {
1290                "level1": {
1291                    "type": "object",
1292                    "properties": {
1293                        "level2": {
1294                            "type": "object",
1295                            "properties": {
1296                                "level3": {
1297                                    "$schema": "draft-07",
1298                                    "type": "string",
1299                                    "const": "deep"
1300                                }
1301                            }
1302                        }
1303                    }
1304                }
1305            }
1306        });
1307        strip_schema_keyword(&mut schema);
1308        convert_const_to_enum(&mut schema);
1309        let deep = &schema["properties"]["level1"]["properties"]["level2"]["properties"]["level3"];
1310        assert!(deep.get("$schema").is_none());
1311        assert_eq!(deep["enum"], json!(["deep"]));
1312    }
1313
1314    // --- resolve_refs tests ---
1315
1316    #[test]
1317    fn test_resolve_refs_simple_definitions() {
1318        let mut defs = Map::new();
1319        defs.insert(
1320            "Address".to_string(),
1321            json!({"type": "object", "properties": {"street": {"type": "string"}}}),
1322        );
1323
1324        let mut schema = json!({
1325            "type": "object",
1326            "properties": {
1327                "home": { "$ref": "#/definitions/Address" }
1328            }
1329        });
1330
1331        resolve_refs(&mut schema, &defs, 0);
1332        assert_eq!(schema["properties"]["home"]["type"], "object");
1333        assert!(schema["properties"]["home"].get("$ref").is_none());
1334        assert_eq!(schema["properties"]["home"]["properties"]["street"]["type"], "string");
1335    }
1336
1337    #[test]
1338    fn test_resolve_refs_simple_defs_format() {
1339        let mut defs = Map::new();
1340        defs.insert("Name".to_string(), json!({"type": "string", "minLength": 1}));
1341
1342        let mut schema = json!({
1343            "type": "object",
1344            "properties": {
1345                "name": { "$ref": "#/$defs/Name" }
1346            }
1347        });
1348
1349        resolve_refs(&mut schema, &defs, 0);
1350        assert_eq!(schema["properties"]["name"]["type"], "string");
1351        assert_eq!(schema["properties"]["name"]["minLength"], 1);
1352        assert!(schema["properties"]["name"].get("$ref").is_none());
1353    }
1354
1355    #[test]
1356    fn test_resolve_refs_nested_refs() {
1357        let mut defs = Map::new();
1358        defs.insert("Inner".to_string(), json!({"type": "string"}));
1359        defs.insert(
1360            "Outer".to_string(),
1361            json!({
1362                "type": "object",
1363                "properties": {
1364                    "value": { "$ref": "#/definitions/Inner" }
1365                }
1366            }),
1367        );
1368
1369        let mut schema = json!({
1370            "type": "object",
1371            "properties": {
1372                "wrapper": { "$ref": "#/definitions/Outer" }
1373            }
1374        });
1375
1376        resolve_refs(&mut schema, &defs, 0);
1377        // Outer was inlined
1378        assert_eq!(schema["properties"]["wrapper"]["type"], "object");
1379        // Inner was also resolved within Outer
1380        assert_eq!(schema["properties"]["wrapper"]["properties"]["value"]["type"], "string");
1381        assert!(schema["properties"]["wrapper"]["properties"]["value"].get("$ref").is_none());
1382    }
1383
1384    #[test]
1385    fn test_resolve_refs_unresolvable_ref() {
1386        let defs = Map::new(); // empty definitions
1387
1388        let mut schema = json!({
1389            "type": "object",
1390            "properties": {
1391                "missing": { "$ref": "#/definitions/DoesNotExist" }
1392            }
1393        });
1394
1395        resolve_refs(&mut schema, &defs, 0);
1396        // Unresolvable ref replaced with {"type": "object"}
1397        assert_eq!(schema["properties"]["missing"], json!({"type": "object"}));
1398    }
1399
1400    #[test]
1401    fn test_resolve_refs_unsupported_ref_format() {
1402        let defs = Map::new();
1403
1404        let mut schema = json!({
1405            "type": "object",
1406            "properties": {
1407                "external": { "$ref": "https://example.com/schema.json" }
1408            }
1409        });
1410
1411        resolve_refs(&mut schema, &defs, 0);
1412        // Unsupported ref format replaced with {"type": "object"}
1413        assert_eq!(schema["properties"]["external"], json!({"type": "object"}));
1414    }
1415
1416    #[test]
1417    fn test_resolve_refs_circular_self_reference() {
1418        let mut defs = Map::new();
1419        defs.insert(
1420            "Node".to_string(),
1421            json!({
1422                "type": "object",
1423                "properties": {
1424                    "child": { "$ref": "#/definitions/Node" }
1425                }
1426            }),
1427        );
1428
1429        let mut schema = json!({ "$ref": "#/definitions/Node" });
1430
1431        resolve_refs(&mut schema, &defs, 0);
1432        // The schema should resolve but eventually hit depth limit
1433        assert_eq!(schema["type"], "object");
1434        // At some nesting level, the circular ref should be broken
1435        // Walk down the chain to verify termination
1436        let mut current = &schema;
1437        let mut found_termination = false;
1438        for _ in 0..15 {
1439            if let Some(child) = current.get("properties").and_then(|p| p.get("child")) {
1440                if child == &json!({"type": "object"}) {
1441                    found_termination = true;
1442                    break;
1443                }
1444                current = child;
1445            } else {
1446                found_termination = true;
1447                break;
1448            }
1449        }
1450        assert!(found_termination, "circular ref chain should terminate within depth limit");
1451    }
1452
1453    #[test]
1454    fn test_resolve_refs_mutual_circular_reference() {
1455        let mut defs = Map::new();
1456        defs.insert(
1457            "A".to_string(),
1458            json!({
1459                "type": "object",
1460                "properties": {
1461                    "b": { "$ref": "#/definitions/B" }
1462                }
1463            }),
1464        );
1465        defs.insert(
1466            "B".to_string(),
1467            json!({
1468                "type": "object",
1469                "properties": {
1470                    "a": { "$ref": "#/definitions/A" }
1471                }
1472            }),
1473        );
1474
1475        let mut schema = json!({ "$ref": "#/definitions/A" });
1476
1477        resolve_refs(&mut schema, &defs, 0);
1478        // Should terminate without stack overflow
1479        assert_eq!(schema["type"], "object");
1480    }
1481
1482    #[test]
1483    fn test_resolve_refs_depth_limit_exact() {
1484        // Starting at depth 11 with a $ref should replace with fallback
1485        let mut defs = Map::new();
1486        defs.insert("Foo".to_string(), json!({"type": "number"}));
1487
1488        let mut schema = json!({ "$ref": "#/definitions/Foo" });
1489
1490        resolve_refs(&mut schema, &defs, 11);
1491        // At depth > 10 with a $ref, it should be replaced with fallback
1492        assert_eq!(schema, json!({"type": "object"}));
1493    }
1494
1495    #[test]
1496    fn test_resolve_refs_depth_limit_no_ref_passthrough() {
1497        // Starting at depth 11 without a $ref should leave schema unchanged
1498        let defs = Map::new();
1499        let mut schema = json!({"type": "string", "minLength": 5});
1500        let expected = schema.clone();
1501
1502        resolve_refs(&mut schema, &defs, 11);
1503        assert_eq!(schema, expected);
1504    }
1505
1506    #[test]
1507    fn test_resolve_refs_depth_10_still_resolves() {
1508        let mut defs = Map::new();
1509        defs.insert("Foo".to_string(), json!({"type": "number"}));
1510
1511        let mut schema = json!({ "$ref": "#/definitions/Foo" });
1512
1513        // At depth 10, should still resolve (limit is > 10)
1514        resolve_refs(&mut schema, &defs, 10);
1515        assert_eq!(schema, json!({"type": "number"}));
1516    }
1517
1518    #[test]
1519    fn test_resolve_refs_in_array_items() {
1520        let mut defs = Map::new();
1521        defs.insert("Item".to_string(), json!({"type": "string"}));
1522
1523        let mut schema = json!({
1524            "type": "array",
1525            "items": { "$ref": "#/definitions/Item" }
1526        });
1527
1528        resolve_refs(&mut schema, &defs, 0);
1529        assert_eq!(schema["items"]["type"], "string");
1530        assert!(schema["items"].get("$ref").is_none());
1531    }
1532
1533    #[test]
1534    fn test_resolve_refs_in_any_of() {
1535        let mut defs = Map::new();
1536        defs.insert("Str".to_string(), json!({"type": "string"}));
1537        defs.insert("Num".to_string(), json!({"type": "number"}));
1538
1539        let mut schema = json!({
1540            "anyOf": [
1541                { "$ref": "#/definitions/Str" },
1542                { "$ref": "#/$defs/Num" }
1543            ]
1544        });
1545
1546        resolve_refs(&mut schema, &defs, 0);
1547        assert_eq!(schema["anyOf"][0], json!({"type": "string"}));
1548        assert_eq!(schema["anyOf"][1], json!({"type": "number"}));
1549    }
1550
1551    #[test]
1552    fn test_resolve_refs_no_ref_passthrough() {
1553        let defs = Map::new();
1554        let mut schema = json!({
1555            "type": "object",
1556            "properties": {
1557                "name": { "type": "string" },
1558                "age": { "type": "integer" }
1559            }
1560        });
1561        let expected = schema.clone();
1562
1563        resolve_refs(&mut schema, &defs, 0);
1564        assert_eq!(schema, expected);
1565    }
1566
1567    #[test]
1568    fn test_resolve_refs_both_definitions_and_defs() {
1569        // The function uses a single definitions map; both formats are looked up
1570        let mut defs = Map::new();
1571        defs.insert("FromDefs".to_string(), json!({"type": "boolean"}));
1572        defs.insert("FromDefinitions".to_string(), json!({"type": "integer"}));
1573
1574        let mut schema = json!({
1575            "type": "object",
1576            "properties": {
1577                "a": { "$ref": "#/$defs/FromDefs" },
1578                "b": { "$ref": "#/definitions/FromDefinitions" }
1579            }
1580        });
1581
1582        resolve_refs(&mut schema, &defs, 0);
1583        assert_eq!(schema["properties"]["a"], json!({"type": "boolean"}));
1584        assert_eq!(schema["properties"]["b"], json!({"type": "integer"}));
1585    }
1586
1587    // --- collapse_combiners tests ---
1588
1589    #[test]
1590    fn test_collapse_combiners_any_of_picks_first_non_null() {
1591        let mut schema = json!({
1592            "anyOf": [
1593                {"type": "null"},
1594                {"type": "string", "minLength": 1}
1595            ]
1596        });
1597        collapse_combiners(&mut schema);
1598        assert_eq!(schema["type"], "string");
1599        assert_eq!(schema["minLength"], 1);
1600        assert!(schema.get("anyOf").is_none());
1601    }
1602
1603    #[test]
1604    fn test_collapse_combiners_one_of_picks_first_non_null() {
1605        let mut schema = json!({
1606            "oneOf": [
1607                {"type": "null"},
1608                {"type": "integer", "minimum": 0}
1609            ]
1610        });
1611        collapse_combiners(&mut schema);
1612        assert_eq!(schema["type"], "integer");
1613        assert_eq!(schema["minimum"], 0);
1614        assert!(schema.get("oneOf").is_none());
1615    }
1616
1617    #[test]
1618    fn test_collapse_combiners_all_null_uses_first() {
1619        let mut schema = json!({
1620            "anyOf": [
1621                {"type": "null"},
1622                {"type": "null"}
1623            ]
1624        });
1625        collapse_combiners(&mut schema);
1626        assert_eq!(schema["type"], "null");
1627        assert!(schema.get("anyOf").is_none());
1628    }
1629
1630    #[test]
1631    fn test_collapse_combiners_no_null() {
1632        let mut schema = json!({
1633            "anyOf": [
1634                {"type": "string"},
1635                {"type": "number"}
1636            ]
1637        });
1638        collapse_combiners(&mut schema);
1639        assert_eq!(schema["type"], "string");
1640        assert!(schema.get("anyOf").is_none());
1641    }
1642
1643    #[test]
1644    fn test_collapse_combiners_nested() {
1645        let mut schema = json!({
1646            "type": "object",
1647            "properties": {
1648                "field": {
1649                    "oneOf": [
1650                        {"type": "null"},
1651                        {"type": "boolean"}
1652                    ]
1653                }
1654            }
1655        });
1656        collapse_combiners(&mut schema);
1657        assert_eq!(schema["properties"]["field"]["type"], "boolean");
1658        assert!(schema["properties"]["field"].get("oneOf").is_none());
1659    }
1660
1661    #[test]
1662    fn test_collapse_combiners_preserves_existing_fields() {
1663        let mut schema = json!({
1664            "description": "A nullable string",
1665            "anyOf": [
1666                {"type": "null"},
1667                {"type": "string", "maxLength": 100}
1668            ]
1669        });
1670        collapse_combiners(&mut schema);
1671        assert_eq!(schema["description"], "A nullable string");
1672        assert_eq!(schema["type"], "string");
1673        assert_eq!(schema["maxLength"], 100);
1674    }
1675
1676    // --- merge_all_of tests ---
1677
1678    #[test]
1679    fn test_merge_all_of_combines_properties() {
1680        let mut schema = json!({
1681            "allOf": [
1682                {"type": "object", "properties": {"a": {"type": "string"}}},
1683                {"properties": {"b": {"type": "number"}}}
1684            ]
1685        });
1686        merge_all_of(&mut schema);
1687        assert!(schema.get("allOf").is_none());
1688        assert_eq!(schema["properties"]["a"]["type"], "string");
1689        assert_eq!(schema["properties"]["b"]["type"], "number");
1690    }
1691
1692    #[test]
1693    fn test_merge_all_of_combines_required() {
1694        let mut schema = json!({
1695            "allOf": [
1696                {"required": ["a", "b"]},
1697                {"required": ["b", "c"]}
1698            ]
1699        });
1700        merge_all_of(&mut schema);
1701        let required = schema["required"].as_array().unwrap();
1702        assert!(required.contains(&json!("a")));
1703        assert!(required.contains(&json!("b")));
1704        assert!(required.contains(&json!("c")));
1705        // No duplicates
1706        assert_eq!(required.len(), 3);
1707    }
1708
1709    #[test]
1710    fn test_merge_all_of_conflicting_type_prefers_object() {
1711        let mut schema = json!({
1712            "allOf": [
1713                {"type": "string"},
1714                {"type": "number"}
1715            ]
1716        });
1717        merge_all_of(&mut schema);
1718        assert_eq!(schema["type"], "object");
1719    }
1720
1721    #[test]
1722    fn test_merge_all_of_same_type_no_conflict() {
1723        let mut schema = json!({
1724            "allOf": [
1725                {"type": "object", "properties": {"a": {"type": "string"}}},
1726                {"type": "object", "properties": {"b": {"type": "number"}}}
1727            ]
1728        });
1729        merge_all_of(&mut schema);
1730        assert_eq!(schema["type"], "object");
1731    }
1732
1733    #[test]
1734    fn test_merge_all_of_nested() {
1735        let mut schema = json!({
1736            "type": "object",
1737            "properties": {
1738                "nested": {
1739                    "allOf": [
1740                        {"properties": {"x": {"type": "integer"}}},
1741                        {"properties": {"y": {"type": "integer"}}}
1742                    ]
1743                }
1744            }
1745        });
1746        merge_all_of(&mut schema);
1747        assert!(schema["properties"]["nested"].get("allOf").is_none());
1748        assert_eq!(schema["properties"]["nested"]["properties"]["x"]["type"], "integer");
1749        assert_eq!(schema["properties"]["nested"]["properties"]["y"]["type"], "integer");
1750    }
1751
1752    #[test]
1753    fn test_merge_all_of_other_fields() {
1754        let mut schema = json!({
1755            "allOf": [
1756                {"type": "object", "description": "First"},
1757                {"title": "Second"}
1758            ]
1759        });
1760        merge_all_of(&mut schema);
1761        assert_eq!(schema["description"], "First");
1762        assert_eq!(schema["title"], "Second");
1763    }
1764
1765    // --- collapse_type_arrays tests ---
1766
1767    #[test]
1768    fn test_collapse_type_arrays_string_null() {
1769        let mut schema = json!({"type": ["string", "null"]});
1770        collapse_type_arrays(&mut schema);
1771        assert_eq!(schema["type"], "string");
1772    }
1773
1774    #[test]
1775    fn test_collapse_type_arrays_null_first() {
1776        let mut schema = json!({"type": ["null", "integer"]});
1777        collapse_type_arrays(&mut schema);
1778        assert_eq!(schema["type"], "integer");
1779    }
1780
1781    #[test]
1782    fn test_collapse_type_arrays_all_null() {
1783        let mut schema = json!({"type": ["null"]});
1784        collapse_type_arrays(&mut schema);
1785        assert_eq!(schema["type"], "null");
1786    }
1787
1788    #[test]
1789    fn test_collapse_type_arrays_single_non_null() {
1790        let mut schema = json!({"type": ["boolean"]});
1791        collapse_type_arrays(&mut schema);
1792        assert_eq!(schema["type"], "boolean");
1793    }
1794
1795    #[test]
1796    fn test_collapse_type_arrays_already_string() {
1797        let mut schema = json!({"type": "string"});
1798        let expected = schema.clone();
1799        collapse_type_arrays(&mut schema);
1800        assert_eq!(schema, expected);
1801    }
1802
1803    #[test]
1804    fn test_collapse_type_arrays_nested() {
1805        let mut schema = json!({
1806            "type": "object",
1807            "properties": {
1808                "field": {"type": ["number", "null"]}
1809            }
1810        });
1811        collapse_type_arrays(&mut schema);
1812        assert_eq!(schema["properties"]["field"]["type"], "number");
1813    }
1814
1815    #[test]
1816    fn test_collapse_type_arrays_multiple_non_null() {
1817        let mut schema = json!({"type": ["string", "number", "null"]});
1818        collapse_type_arrays(&mut schema);
1819        // Picks the first non-null
1820        assert_eq!(schema["type"], "string");
1821    }
1822
1823    // --- enforce_nesting_depth tests ---
1824
1825    #[test]
1826    fn test_enforce_nesting_depth_within_limit() {
1827        let mut schema = json!({
1828            "type": "object",
1829            "properties": {
1830                "name": {"type": "string"}
1831            }
1832        });
1833        let expected = schema.clone();
1834        enforce_nesting_depth(&mut schema, 5, 0);
1835        assert_eq!(schema, expected);
1836    }
1837
1838    #[test]
1839    fn test_enforce_nesting_depth_at_limit() {
1840        let mut schema = json!({
1841            "type": "object",
1842            "properties": {
1843                "deep": {
1844                    "type": "object",
1845                    "properties": {
1846                        "deeper": {"type": "string"}
1847                    }
1848                }
1849            }
1850        });
1851        enforce_nesting_depth(&mut schema, 1, 0);
1852        // The root is at depth 0 (object), so next_depth = 1
1853        // "deep" is an object at depth 1 which equals max_depth, so it gets truncated
1854        assert_eq!(schema["properties"]["deep"], json!({"type": "object"}));
1855    }
1856
1857    #[test]
1858    fn test_enforce_nesting_depth_exceeds_limit() {
1859        let mut schema = json!({
1860            "type": "object",
1861            "properties": {
1862                "level1": {
1863                    "type": "object",
1864                    "properties": {
1865                        "level2": {
1866                            "type": "object",
1867                            "properties": {
1868                                "level3": {"type": "string"}
1869                            }
1870                        }
1871                    }
1872                }
1873            }
1874        });
1875        enforce_nesting_depth(&mut schema, 2, 0);
1876        // Root at depth 0, level1 at depth 1, level2 at depth 2 (== max_depth) → truncated
1877        assert_eq!(
1878            schema["properties"]["level1"]["properties"]["level2"],
1879            json!({"type": "object"})
1880        );
1881    }
1882
1883    #[test]
1884    fn test_enforce_nesting_depth_non_object_not_counted() {
1885        let mut schema = json!({
1886            "type": "object",
1887            "properties": {
1888                "arr": {
1889                    "type": "array",
1890                    "items": {
1891                        "type": "object",
1892                        "properties": {
1893                            "name": {"type": "string"}
1894                        }
1895                    }
1896                }
1897            }
1898        });
1899        enforce_nesting_depth(&mut schema, 2, 0);
1900        // Root at depth 0 (object, next=1), arr is array (not object, next stays 1),
1901        // items is object at depth 1 (next=2), name is string — no truncation at depth 2
1902        assert_eq!(schema["properties"]["arr"]["items"]["properties"]["name"]["type"], "string");
1903    }
1904
1905    #[test]
1906    fn test_enforce_nesting_depth_gemini_5_levels() {
1907        // Simulate Gemini's 5-level limit
1908        let mut schema = json!({
1909            "type": "object",
1910            "properties": {
1911                "l1": {
1912                    "type": "object",
1913                    "properties": {
1914                        "l2": {
1915                            "type": "object",
1916                            "properties": {
1917                                "l3": {
1918                                    "type": "object",
1919                                    "properties": {
1920                                        "l4": {
1921                                            "type": "object",
1922                                            "properties": {
1923                                                "l5": {
1924                                                    "type": "object",
1925                                                    "properties": {
1926                                                        "deep": {"type": "string"}
1927                                                    }
1928                                                }
1929                                            }
1930                                        }
1931                                    }
1932                                }
1933                            }
1934                        }
1935                    }
1936                }
1937            }
1938        });
1939        enforce_nesting_depth(&mut schema, 5, 0);
1940        // l5 is at depth 5 (root=0, l1=1, l2=2, l3=3, l4=4, l5=5) → truncated
1941        assert_eq!(
1942            schema["properties"]["l1"]["properties"]["l2"]["properties"]["l3"]["properties"]["l4"]
1943                ["properties"]["l5"],
1944            json!({"type": "object"})
1945        );
1946        // l4 should still have its properties
1947        assert!(
1948            schema["properties"]["l1"]["properties"]["l2"]["properties"]["l3"]["properties"]["l4"]
1949                .get("properties")
1950                .is_some()
1951        );
1952    }
1953
1954    #[test]
1955    fn test_enforce_nesting_depth_zero_truncates_root_object() {
1956        let mut schema = json!({
1957            "type": "object",
1958            "properties": {"a": {"type": "string"}}
1959        });
1960        enforce_nesting_depth(&mut schema, 0, 0);
1961        assert_eq!(schema, json!({"type": "object"}));
1962    }
1963
1964    // --- is_null_schema tests ---
1965
1966    #[test]
1967    fn test_is_null_schema_true() {
1968        assert!(is_null_schema(&json!({"type": "null"})));
1969    }
1970
1971    #[test]
1972    fn test_is_null_schema_false_for_string() {
1973        assert!(!is_null_schema(&json!({"type": "string"})));
1974    }
1975
1976    #[test]
1977    fn test_is_null_schema_false_for_non_object() {
1978        assert!(!is_null_schema(&json!("null")));
1979        assert!(!is_null_schema(&Value::Null));
1980    }
1981}