Skip to main content

adk_gemini/
schema_adapter.rs

1//! Gemini-specific schema normalization adapter.
2//!
3//! The [`GeminiSchemaAdapter`] applies all destructive transforms required by
4//! Gemini's function-calling API. It composes shared utilities from
5//! [`adk_core::schema_utils`] with Gemini-specific keyword removal to produce
6//! schemas that Gemini accepts.
7//!
8//! # Transform Order
9//!
10//! 1. Resolve `$ref` references (inline from definitions/$defs, break cycles at depth 10)
11//! 2. Strip `$schema` keyword
12//! 3. Collapse `anyOf`/`oneOf` combiners (select first non-null sub-schema)
13//! 4. Merge `allOf` sub-schemas
14//! 5. Collapse type arrays (`["string", "null"]` → `"string"`)
15//! 6. Strip conditional keywords (`if`/`then`/`else`)
16//! 7. Convert `const` to single-element `enum`
17//! 8. Strip null values from `enum` arrays
18//! 9. Add implicit `"type": "object"` when `properties` exists
19//! 10. Remove unsupported keywords recursively
20//! 11. Strip unsupported `format` values
21//! 12. Enforce nesting depth limit (5 levels)
22//! 13. Remove `definitions`/`$defs` blocks
23//!
24//! # Example
25//!
26//! ```rust
27//! use adk_gemini::schema_adapter::GeminiSchemaAdapter;
28//! use adk_core::SchemaAdapter;
29//! use serde_json::json;
30//!
31//! let adapter = GeminiSchemaAdapter::new();
32//! let schema = json!({
33//!     "$schema": "http://json-schema.org/draft-07/schema#",
34//!     "type": "object",
35//!     "properties": {
36//!         "name": { "type": "string", "format": "hostname" }
37//!     },
38//!     "additionalProperties": true
39//! });
40//!
41//! let normalized = adapter.normalize_schema(schema);
42//! assert!(normalized.get("$schema").is_none());
43//! assert!(normalized.get("additionalProperties").is_none());
44//! assert!(normalized["properties"]["name"].get("format").is_none());
45//! ```
46
47use adk_core::SchemaAdapter;
48use adk_core::schema_utils;
49use serde_json::{Map, Value};
50use std::borrow::Cow;
51
52/// Allowed `format` values for the Gemini API.
53const GEMINI_ALLOWED_FORMATS: &[&str] =
54    &["date-time", "date", "time", "email", "uri", "uuid", "int32", "int64", "float", "double"];
55
56/// Keywords that Gemini does not support and must be removed from all schema nodes
57/// (standard API surface — removes `additionalProperties` entirely).
58///
59/// Per the official Gemini API docs, the Schema proto for function declarations
60/// only supports: `type`, `description`, `enum`, `items` (single schema for arrays),
61/// `properties`, `required`, `nullable`, and `format` (limited values).
62/// Everything else must be stripped to avoid 400 errors from the proto parser.
63///
64/// Reference: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/function-calling
65const UNSUPPORTED_KEYWORDS: &[&str] = &[
66    "$id",
67    "additionalProperties",
68    "contains",
69    "contentEncoding",
70    "contentMediaType",
71    "default",
72    "dependentRequired",
73    "dependentSchemas",
74    "deprecated",
75    "examples",
76    "exclusiveMaximum",
77    "exclusiveMinimum",
78    "maxItems",
79    "maxLength",
80    "maxProperties",
81    "maximum",
82    "minItems",
83    "minLength",
84    "minProperties",
85    "minimum",
86    "multipleOf",
87    "not",
88    "pattern",
89    "patternProperties",
90    "prefixItems",
91    "propertyNames",
92    "readOnly",
93    "title",
94    "unevaluatedProperties",
95    "uniqueItems",
96    "writeOnly",
97];
98
99/// Keywords that Gemini does not support on the Vertex AI surface.
100/// Unlike the standard surface, Vertex AI requires `additionalProperties: false`
101/// on object schemas rather than removing it.
102///
103/// Same comprehensive list as [`UNSUPPORTED_KEYWORDS`] but without
104/// `additionalProperties` (which is handled separately for Vertex AI).
105const UNSUPPORTED_KEYWORDS_VERTEX: &[&str] = &[
106    "$id",
107    "contains",
108    "contentEncoding",
109    "contentMediaType",
110    "default",
111    "dependentRequired",
112    "dependentSchemas",
113    "deprecated",
114    "examples",
115    "exclusiveMaximum",
116    "exclusiveMinimum",
117    "maxItems",
118    "maxLength",
119    "maxProperties",
120    "maximum",
121    "minItems",
122    "minLength",
123    "minProperties",
124    "minimum",
125    "multipleOf",
126    "not",
127    "pattern",
128    "patternProperties",
129    "prefixItems",
130    "propertyNames",
131    "readOnly",
132    "title",
133    "unevaluatedProperties",
134    "uniqueItems",
135    "writeOnly",
136];
137
138/// Schema adapter for the Gemini API surface.
139///
140/// Applies all destructive transforms required by Gemini's function-calling API.
141///
142/// Two variants are supported:
143/// - **Standard** (`GeminiSchemaAdapter::new()`): Removes `additionalProperties` entirely.
144/// - **Vertex AI** (`GeminiSchemaAdapter::vertex_ai()`): Sets `additionalProperties: false`
145///   on object schemas instead of removing it.
146///
147/// # Example
148///
149/// ```rust
150/// use adk_gemini::schema_adapter::GeminiSchemaAdapter;
151/// use adk_core::SchemaAdapter;
152/// use serde_json::json;
153///
154/// let adapter = GeminiSchemaAdapter::new();
155/// let schema = json!({
156///     "anyOf": [
157///         {"type": "null"},
158///         {"type": "string", "minLength": 1}
159///     ]
160/// });
161///
162/// let normalized = adapter.normalize_schema(schema);
163/// assert_eq!(normalized["type"], "string");
164/// assert!(normalized.get("anyOf").is_none());
165/// ```
166#[derive(Debug)]
167pub struct GeminiSchemaAdapter {
168    /// When `true`, targets the Vertex AI surface which requires
169    /// `additionalProperties: false` on object schemas.
170    vertex_ai: bool,
171}
172
173impl GeminiSchemaAdapter {
174    /// Creates a new `GeminiSchemaAdapter` for the standard Gemini API surface.
175    ///
176    /// This variant removes `additionalProperties` from all schema nodes.
177    pub fn new() -> Self {
178        Self { vertex_ai: false }
179    }
180
181    /// Creates a new `GeminiSchemaAdapter` for the Vertex AI surface.
182    ///
183    /// This variant sets `additionalProperties: false` on object schemas
184    /// instead of removing the keyword entirely.
185    pub fn vertex_ai() -> Self {
186        Self { vertex_ai: true }
187    }
188}
189
190impl Default for GeminiSchemaAdapter {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196impl SchemaAdapter for GeminiSchemaAdapter {
197    fn normalize_schema(&self, mut schema: Value) -> Value {
198        // Step 1: Extract definitions and resolve $ref references.
199        // Always resolve refs — even with empty definitions — so that
200        // unresolvable $ref values are replaced with {"type": "object"}.
201        let definitions = extract_definitions(&schema);
202        schema_utils::resolve_refs(&mut schema, &definitions, 0);
203
204        // Step 2: Strip $schema keyword
205        schema_utils::strip_schema_keyword(&mut schema);
206
207        // Step 3: Collapse anyOf/oneOf combiners
208        schema_utils::collapse_combiners(&mut schema);
209
210        // Step 4: Merge allOf sub-schemas
211        schema_utils::merge_all_of(&mut schema);
212
213        // Step 5: Collapse type arrays
214        schema_utils::collapse_type_arrays(&mut schema);
215
216        // Step 6: Strip conditional keywords (if/then/else)
217        schema_utils::strip_conditional_keywords(&mut schema);
218
219        // Step 7: Convert const to single-element enum
220        schema_utils::convert_const_to_enum(&mut schema);
221
222        // Step 8: Strip null from enum arrays
223        schema_utils::strip_null_from_enum(&mut schema);
224
225        // Step 9: Add implicit object type
226        schema_utils::add_implicit_object_type(&mut schema);
227
228        // Step 10: Remove unsupported keywords recursively
229        if self.vertex_ai {
230            remove_unsupported_keywords_vertex(&mut schema);
231        } else {
232            remove_unsupported_keywords(&mut schema);
233        }
234
235        // Step 11: Strip unsupported format values
236        schema_utils::strip_unsupported_formats(&mut schema, GEMINI_ALLOWED_FORMATS);
237
238        // Step 12: Enforce nesting depth (max 5 levels)
239        schema_utils::enforce_nesting_depth(&mut schema, 5, 0);
240
241        // Step 13: Remove definitions/$defs blocks
242        if let Some(obj) = schema.as_object_mut() {
243            obj.remove("definitions");
244            obj.remove("$defs");
245        }
246
247        schema
248    }
249
250    /// Truncates tool names exceeding 64 bytes at a valid UTF-8 character boundary.
251    ///
252    /// Preserves the prefix of the name, truncating from the end.
253    fn normalize_tool_name<'a>(&self, name: &'a str) -> Cow<'a, str> {
254        if name.len() <= 64 {
255            Cow::Borrowed(name)
256        } else {
257            let mut end = 64;
258            while end > 0 && !name.is_char_boundary(end) {
259                end -= 1;
260            }
261            Cow::Owned(name[..end].to_string())
262        }
263    }
264
265    /// Returns the fallback schema for tools with no `parameters_schema`.
266    ///
267    /// Gemini requires `{"type": "object", "properties": {}}` as the minimum
268    /// valid function declaration parameters.
269    fn empty_schema(&self) -> Value {
270        serde_json::json!({"type": "object", "properties": {}})
271    }
272}
273
274/// Extracts and merges `definitions` and `$defs` from the top-level schema
275/// into a single map for reference resolution.
276fn extract_definitions(schema: &Value) -> Map<String, Value> {
277    let mut defs = Map::new();
278
279    if let Some(obj) = schema.as_object() {
280        // Collect from "definitions" (Draft 4-7)
281        if let Some(definitions) = obj.get("definitions").and_then(|v| v.as_object()) {
282            for (key, value) in definitions {
283                defs.insert(key.clone(), value.clone());
284            }
285        }
286
287        // Collect from "$defs" (Draft 2019-09+)
288        if let Some(dollar_defs) = obj.get("$defs").and_then(|v| v.as_object()) {
289            for (key, value) in dollar_defs {
290                defs.insert(key.clone(), value.clone());
291            }
292        }
293    }
294
295    defs
296}
297
298/// Recursively removes unsupported keywords from the schema and all nested sub-schemas.
299///
300/// Removes: `additionalProperties`, `exclusiveMinimum`, `exclusiveMaximum`,
301/// `items` (when type is not "array"), `not`, `propertyNames`, `patternProperties`,
302/// `unevaluatedProperties`.
303fn remove_unsupported_keywords(schema: &mut Value) {
304    let Some(obj) = schema.as_object_mut() else {
305        return;
306    };
307
308    // Remove standard unsupported keywords
309    for keyword in UNSUPPORTED_KEYWORDS {
310        obj.remove(*keyword);
311    }
312
313    // Remove `items` when:
314    // 1. The schema type is NOT "array" (items is meaningless on non-array types), OR
315    // 2. The schema IS "array" but `items` is a JSON array (tuple validation syntax) —
316    //    Gemini only supports single-schema items, not tuple validation.
317    let is_array_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "array");
318    let items_is_tuple = obj.get("items").is_some_and(|v| v.is_array());
319    if !is_array_type || items_is_tuple {
320        obj.remove("items");
321    }
322
323    // Recurse into properties
324    if let Some(props) = obj.get_mut("properties")
325        && let Some(props_obj) = props.as_object_mut()
326    {
327        for value in props_obj.values_mut() {
328            remove_unsupported_keywords(value);
329        }
330    }
331
332    // Recurse into items (only present if type is "array" with valid single-schema)
333    if let Some(items) = obj.get_mut("items")
334        && items.is_object()
335    {
336        remove_unsupported_keywords(items);
337    }
338
339    // Recurse into allOf, anyOf, oneOf (may still exist if not collapsed)
340    for keyword in &["allOf", "anyOf", "oneOf"] {
341        if let Some(arr_val) = obj.get_mut(*keyword)
342            && let Some(arr) = arr_val.as_array_mut()
343        {
344            for sub in arr.iter_mut() {
345                remove_unsupported_keywords(sub);
346            }
347        }
348    }
349}
350
351/// Recursively removes unsupported keywords for the Vertex AI surface.
352///
353/// Unlike the standard surface, Vertex AI requires `additionalProperties: false`
354/// on object schemas. This function:
355/// - Sets `additionalProperties` to `false` on object schemas (instead of removing it)
356/// - Removes all other unsupported keywords the same as the standard surface
357fn remove_unsupported_keywords_vertex(schema: &mut Value) {
358    let Some(obj) = schema.as_object_mut() else {
359        return;
360    };
361
362    // Remove Vertex-specific unsupported keywords (does NOT include additionalProperties)
363    for keyword in UNSUPPORTED_KEYWORDS_VERTEX {
364        obj.remove(*keyword);
365    }
366
367    // For object schemas, set additionalProperties to false
368    let is_object_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "object");
369    if is_object_type {
370        obj.insert("additionalProperties".to_string(), Value::Bool(false));
371    } else {
372        // For non-object schemas, remove additionalProperties if present
373        obj.remove("additionalProperties");
374    }
375
376    // Remove `items` when:
377    // 1. The schema type is NOT "array" (items is meaningless on non-array types), OR
378    // 2. The schema IS "array" but `items` is a JSON array (tuple validation syntax) —
379    //    Gemini only supports single-schema items, not tuple validation.
380    let is_array_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "array");
381    let items_is_tuple = obj.get("items").is_some_and(|v| v.is_array());
382    if !is_array_type || items_is_tuple {
383        obj.remove("items");
384    }
385
386    // Recurse into properties
387    if let Some(props) = obj.get_mut("properties")
388        && let Some(props_obj) = props.as_object_mut()
389    {
390        for value in props_obj.values_mut() {
391            remove_unsupported_keywords_vertex(value);
392        }
393    }
394
395    // Recurse into items (only present if type is "array" with valid single-schema)
396    if let Some(items) = obj.get_mut("items")
397        && items.is_object()
398    {
399        remove_unsupported_keywords_vertex(items);
400    }
401
402    // Recurse into allOf, anyOf, oneOf (may still exist if not collapsed)
403    for keyword in &["allOf", "anyOf", "oneOf"] {
404        if let Some(arr_val) = obj.get_mut(*keyword)
405            && let Some(arr) = arr_val.as_array_mut()
406        {
407            for sub in arr.iter_mut() {
408                remove_unsupported_keywords_vertex(sub);
409            }
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use serde_json::json;
418
419    #[test]
420    fn test_strips_schema_keyword() {
421        let adapter = GeminiSchemaAdapter::new();
422        let schema = json!({
423            "$schema": "http://json-schema.org/draft-07/schema#",
424            "type": "object",
425            "properties": { "name": { "type": "string" } }
426        });
427        let result = adapter.normalize_schema(schema);
428        assert!(result.get("$schema").is_none());
429    }
430
431    #[test]
432    fn test_removes_additional_properties() {
433        let adapter = GeminiSchemaAdapter::new();
434        let schema = json!({
435            "type": "object",
436            "properties": { "name": { "type": "string" } },
437            "additionalProperties": true
438        });
439        let result = adapter.normalize_schema(schema);
440        assert!(result.get("additionalProperties").is_none());
441    }
442
443    #[test]
444    fn test_removes_exclusive_min_max() {
445        let adapter = GeminiSchemaAdapter::new();
446        let schema = json!({
447            "type": "number",
448            "exclusiveMinimum": 0,
449            "exclusiveMaximum": 100
450        });
451        let result = adapter.normalize_schema(schema);
452        assert!(result.get("exclusiveMinimum").is_none());
453        assert!(result.get("exclusiveMaximum").is_none());
454    }
455
456    #[test]
457    fn test_removes_items_when_not_array() {
458        let adapter = GeminiSchemaAdapter::new();
459        let schema = json!({
460            "type": "object",
461            "items": { "type": "string" }
462        });
463        let result = adapter.normalize_schema(schema);
464        assert!(result.get("items").is_none());
465    }
466
467    #[test]
468    fn test_preserves_items_when_array() {
469        let adapter = GeminiSchemaAdapter::new();
470        let schema = json!({
471            "type": "array",
472            "items": { "type": "string" }
473        });
474        let result = adapter.normalize_schema(schema);
475        assert!(result.get("items").is_some());
476        assert_eq!(result["items"]["type"], "string");
477    }
478
479    #[test]
480    fn test_removes_items_tuple_validation_on_array() {
481        // Gemini's proto doesn't support tuple validation (items as JSON array).
482        // This caused 400 errors: "Proto field is not repeating, cannot start list"
483        let adapter = GeminiSchemaAdapter::new();
484        let schema = json!({
485            "type": "array",
486            "items": [
487                { "type": "string" },
488                { "type": "number" }
489            ]
490        });
491        let result = adapter.normalize_schema(schema);
492        assert!(result.get("items").is_none(), "tuple validation items should be stripped");
493        assert_eq!(result["type"], "array");
494    }
495
496    #[test]
497    fn test_vertex_ai_removes_items_tuple_validation() {
498        let adapter = GeminiSchemaAdapter::vertex_ai();
499        let schema = json!({
500            "type": "array",
501            "items": [
502                { "type": "integer" },
503                { "type": "boolean" }
504            ]
505        });
506        let result = adapter.normalize_schema(schema);
507        assert!(
508            result.get("items").is_none(),
509            "tuple validation items should be stripped on Vertex AI"
510        );
511    }
512
513    #[test]
514    fn test_removes_not_keyword() {
515        let adapter = GeminiSchemaAdapter::new();
516        let schema = json!({
517            "type": "string",
518            "not": { "enum": ["bad"] }
519        });
520        let result = adapter.normalize_schema(schema);
521        assert!(result.get("not").is_none());
522    }
523
524    #[test]
525    fn test_removes_property_names() {
526        let adapter = GeminiSchemaAdapter::new();
527        let schema = json!({
528            "type": "object",
529            "propertyNames": { "pattern": "^[a-z]+$" }
530        });
531        let result = adapter.normalize_schema(schema);
532        assert!(result.get("propertyNames").is_none());
533    }
534
535    #[test]
536    fn test_removes_pattern_properties() {
537        let adapter = GeminiSchemaAdapter::new();
538        let schema = json!({
539            "type": "object",
540            "patternProperties": { "^S_": { "type": "string" } }
541        });
542        let result = adapter.normalize_schema(schema);
543        assert!(result.get("patternProperties").is_none());
544    }
545
546    #[test]
547    fn test_removes_unevaluated_properties() {
548        let adapter = GeminiSchemaAdapter::new();
549        let schema = json!({
550            "type": "object",
551            "unevaluatedProperties": false
552        });
553        let result = adapter.normalize_schema(schema);
554        assert!(result.get("unevaluatedProperties").is_none());
555    }
556
557    #[test]
558    fn test_collapses_any_of() {
559        let adapter = GeminiSchemaAdapter::new();
560        let schema = json!({
561            "anyOf": [
562                { "type": "null" },
563                { "type": "string", "description": "A non-empty string" }
564            ]
565        });
566        let result = adapter.normalize_schema(schema);
567        assert!(result.get("anyOf").is_none());
568        assert_eq!(result["type"], "string");
569        assert_eq!(result["description"], "A non-empty string");
570    }
571
572    #[test]
573    fn test_collapses_one_of() {
574        let adapter = GeminiSchemaAdapter::new();
575        let schema = json!({
576            "oneOf": [
577                { "type": "null" },
578                { "type": "integer", "minimum": 0 }
579            ]
580        });
581        let result = adapter.normalize_schema(schema);
582        assert!(result.get("oneOf").is_none());
583        assert_eq!(result["type"], "integer");
584    }
585
586    #[test]
587    fn test_merges_all_of() {
588        let adapter = GeminiSchemaAdapter::new();
589        let schema = json!({
590            "allOf": [
591                { "type": "object", "properties": { "a": { "type": "string" } } },
592                { "properties": { "b": { "type": "number" } }, "required": ["b"] }
593            ]
594        });
595        let result = adapter.normalize_schema(schema);
596        assert!(result.get("allOf").is_none());
597        assert_eq!(result["properties"]["a"]["type"], "string");
598        assert_eq!(result["properties"]["b"]["type"], "number");
599        assert_eq!(result["required"], json!(["b"]));
600    }
601
602    #[test]
603    fn test_collapses_type_arrays() {
604        let adapter = GeminiSchemaAdapter::new();
605        let schema = json!({
606            "type": ["string", "null"],
607            "minLength": 1
608        });
609        let result = adapter.normalize_schema(schema);
610        assert_eq!(result["type"], "string");
611    }
612
613    #[test]
614    fn test_strips_conditional_keywords() {
615        let adapter = GeminiSchemaAdapter::new();
616        let schema = json!({
617            "type": "object",
618            "if": { "properties": { "kind": { "const": "a" } } },
619            "then": { "required": ["extra"] },
620            "else": { "required": [] }
621        });
622        let result = adapter.normalize_schema(schema);
623        assert!(result.get("if").is_none());
624        assert!(result.get("then").is_none());
625        assert!(result.get("else").is_none());
626    }
627
628    #[test]
629    fn test_converts_const_to_enum() {
630        let adapter = GeminiSchemaAdapter::new();
631        let schema = json!({
632            "type": "string",
633            "const": "fixed"
634        });
635        let result = adapter.normalize_schema(schema);
636        assert!(result.get("const").is_none());
637        assert_eq!(result["enum"], json!(["fixed"]));
638    }
639
640    #[test]
641    fn test_strips_null_from_enum() {
642        let adapter = GeminiSchemaAdapter::new();
643        let schema = json!({
644            "type": "string",
645            "enum": ["a", null, "b"]
646        });
647        let result = adapter.normalize_schema(schema);
648        assert_eq!(result["enum"], json!(["a", "b"]));
649    }
650
651    #[test]
652    fn test_removes_empty_enum_after_null_strip() {
653        let adapter = GeminiSchemaAdapter::new();
654        let schema = json!({
655            "type": "string",
656            "enum": [null]
657        });
658        let result = adapter.normalize_schema(schema);
659        assert!(result.get("enum").is_none());
660    }
661
662    #[test]
663    fn test_adds_implicit_object_type() {
664        let adapter = GeminiSchemaAdapter::new();
665        let schema = json!({
666            "properties": { "name": { "type": "string" } }
667        });
668        let result = adapter.normalize_schema(schema);
669        assert_eq!(result["type"], "object");
670    }
671
672    #[test]
673    fn test_strips_unsupported_formats() {
674        let adapter = GeminiSchemaAdapter::new();
675        let schema = json!({
676            "type": "object",
677            "properties": {
678                "created": { "type": "string", "format": "date-time" },
679                "hostname": { "type": "string", "format": "hostname" },
680                "id": { "type": "string", "format": "uuid" }
681            }
682        });
683        let result = adapter.normalize_schema(schema);
684        assert_eq!(result["properties"]["created"]["format"], "date-time");
685        assert!(result["properties"]["hostname"].get("format").is_none());
686        assert_eq!(result["properties"]["id"]["format"], "uuid");
687    }
688
689    #[test]
690    fn test_preserves_all_allowed_formats() {
691        let adapter = GeminiSchemaAdapter::new();
692        for format in GEMINI_ALLOWED_FORMATS {
693            let schema = json!({ "type": "string", "format": format });
694            let result = adapter.normalize_schema(schema);
695            assert_eq!(result["format"], *format, "format '{format}' should be preserved");
696        }
697    }
698
699    #[test]
700    fn test_enforces_nesting_depth() {
701        let adapter = GeminiSchemaAdapter::new();
702        // Create a schema nested 7 levels deep
703        let schema = json!({
704            "type": "object",
705            "properties": {
706                "l1": {
707                    "type": "object",
708                    "properties": {
709                        "l2": {
710                            "type": "object",
711                            "properties": {
712                                "l3": {
713                                    "type": "object",
714                                    "properties": {
715                                        "l4": {
716                                            "type": "object",
717                                            "properties": {
718                                                "l5": {
719                                                    "type": "object",
720                                                    "properties": {
721                                                        "l6": { "type": "string" }
722                                                    }
723                                                }
724                                            }
725                                        }
726                                    }
727                                }
728                            }
729                        }
730                    }
731                }
732            }
733        });
734        let result = adapter.normalize_schema(schema);
735        // At depth 5, the schema should be truncated to {"type": "object"}
736        let l5 = &result["properties"]["l1"]["properties"]["l2"]["properties"]["l3"]["properties"]
737            ["l4"]["properties"]["l5"];
738        assert_eq!(l5, &json!({"type": "object"}));
739    }
740
741    #[test]
742    fn test_resolves_refs() {
743        let adapter = GeminiSchemaAdapter::new();
744        let schema = json!({
745            "type": "object",
746            "properties": {
747                "address": { "$ref": "#/definitions/Address" }
748            },
749            "definitions": {
750                "Address": {
751                    "type": "object",
752                    "properties": {
753                        "street": { "type": "string" }
754                    }
755                }
756            }
757        });
758        let result = adapter.normalize_schema(schema);
759        // $ref should be resolved
760        assert!(result["properties"]["address"].get("$ref").is_none());
761        assert_eq!(result["properties"]["address"]["type"], "object");
762        assert_eq!(result["properties"]["address"]["properties"]["street"]["type"], "string");
763        // definitions should be removed
764        assert!(result.get("definitions").is_none());
765    }
766
767    #[test]
768    fn test_resolves_dollar_defs() {
769        let adapter = GeminiSchemaAdapter::new();
770        let schema = json!({
771            "type": "object",
772            "properties": {
773                "item": { "$ref": "#/$defs/Item" }
774            },
775            "$defs": {
776                "Item": {
777                    "type": "object",
778                    "properties": {
779                        "name": { "type": "string" }
780                    }
781                }
782            }
783        });
784        let result = adapter.normalize_schema(schema);
785        assert!(result["properties"]["item"].get("$ref").is_none());
786        assert_eq!(result["properties"]["item"]["type"], "object");
787        assert!(result.get("$defs").is_none());
788    }
789
790    #[test]
791    fn test_unresolvable_ref_becomes_object() {
792        let adapter = GeminiSchemaAdapter::new();
793        let schema = json!({
794            "type": "object",
795            "properties": {
796                "unknown": { "$ref": "#/definitions/DoesNotExist" }
797            }
798        });
799        let result = adapter.normalize_schema(schema);
800        assert_eq!(result["properties"]["unknown"], json!({"type": "object"}));
801    }
802
803    #[test]
804    fn test_circular_ref_breaks() {
805        let adapter = GeminiSchemaAdapter::new();
806        let schema = json!({
807            "type": "object",
808            "properties": {
809                "self_ref": { "$ref": "#/definitions/Node" }
810            },
811            "definitions": {
812                "Node": {
813                    "type": "object",
814                    "properties": {
815                        "child": { "$ref": "#/definitions/Node" }
816                    }
817                }
818            }
819        });
820        let result = adapter.normalize_schema(schema);
821        // Should not panic and should terminate
822        assert_eq!(result["properties"]["self_ref"]["type"], "object");
823        assert!(result.get("definitions").is_none());
824    }
825
826    #[test]
827    fn test_removes_definitions_and_defs() {
828        let adapter = GeminiSchemaAdapter::new();
829        let schema = json!({
830            "type": "object",
831            "definitions": { "Foo": { "type": "string" } },
832            "$defs": { "Bar": { "type": "number" } }
833        });
834        let result = adapter.normalize_schema(schema);
835        assert!(result.get("definitions").is_none());
836        assert!(result.get("$defs").is_none());
837    }
838
839    #[test]
840    fn test_nested_unsupported_keywords_removed() {
841        let adapter = GeminiSchemaAdapter::new();
842        let schema = json!({
843            "type": "object",
844            "properties": {
845                "inner": {
846                    "type": "object",
847                    "additionalProperties": false,
848                    "exclusiveMinimum": 5,
849                    "properties": {
850                        "deep": {
851                            "type": "number",
852                            "exclusiveMaximum": 100
853                        }
854                    }
855                }
856            }
857        });
858        let result = adapter.normalize_schema(schema);
859        let inner = &result["properties"]["inner"];
860        assert!(inner.get("additionalProperties").is_none());
861        assert!(inner.get("exclusiveMinimum").is_none());
862        assert!(inner["properties"]["deep"].get("exclusiveMaximum").is_none());
863    }
864
865    #[test]
866    fn test_full_transform_pipeline() {
867        let adapter = GeminiSchemaAdapter::new();
868        let schema = json!({
869            "$schema": "http://json-schema.org/draft-07/schema#",
870            "definitions": {
871                "Status": { "type": "string", "enum": ["active", null, "inactive"] }
872            },
873            "properties": {
874                "name": { "type": ["string", "null"], "format": "hostname" },
875                "status": { "$ref": "#/definitions/Status" },
876                "config": {
877                    "type": "object",
878                    "additionalProperties": true,
879                    "properties": {
880                        "value": { "const": "fixed" }
881                    }
882                }
883            },
884            "if": { "properties": { "name": { "type": "string" } } },
885            "then": { "required": ["status"] },
886            "additionalProperties": false
887        });
888        let result = adapter.normalize_schema(schema);
889
890        // $schema removed
891        assert!(result.get("$schema").is_none());
892        // definitions removed
893        assert!(result.get("definitions").is_none());
894        // conditional keywords removed
895        assert!(result.get("if").is_none());
896        assert!(result.get("then").is_none());
897        // additionalProperties removed
898        assert!(result.get("additionalProperties").is_none());
899        // type array collapsed
900        assert_eq!(result["properties"]["name"]["type"], "string");
901        // unsupported format stripped
902        assert!(result["properties"]["name"].get("format").is_none());
903        // $ref resolved and null stripped from enum
904        assert_eq!(result["properties"]["status"]["enum"], json!(["active", "inactive"]));
905        // const converted to enum
906        assert_eq!(result["properties"]["config"]["properties"]["value"]["enum"], json!(["fixed"]));
907        // nested additionalProperties removed
908        assert!(result["properties"]["config"].get("additionalProperties").is_none());
909        // implicit type added
910        assert_eq!(result["type"], "object");
911    }
912
913    #[test]
914    fn test_idempotent() {
915        let adapter = GeminiSchemaAdapter::new();
916        let schema = json!({
917            "$schema": "http://json-schema.org/draft-07/schema#",
918            "type": "object",
919            "properties": {
920                "name": { "type": ["string", "null"], "format": "hostname" },
921                "items": { "type": "array", "items": { "type": "string" } }
922            },
923            "additionalProperties": true,
924            "if": { "const": true },
925            "then": { "required": ["name"] }
926        });
927        let first = adapter.normalize_schema(schema);
928        let second = adapter.normalize_schema(first.clone());
929        assert_eq!(first, second);
930    }
931
932    #[test]
933    fn test_empty_schema() {
934        let adapter = GeminiSchemaAdapter::new();
935        let schema = json!({});
936        let result = adapter.normalize_schema(schema);
937        assert_eq!(result, json!({}));
938    }
939
940    #[test]
941    fn test_array_items_nested_cleanup() {
942        let adapter = GeminiSchemaAdapter::new();
943        let schema = json!({
944            "type": "array",
945            "items": {
946                "type": "object",
947                "additionalProperties": true,
948                "properties": {
949                    "id": { "type": "integer", "exclusiveMinimum": 0 }
950                }
951            }
952        });
953        let result = adapter.normalize_schema(schema);
954        assert!(result["items"].get("additionalProperties").is_none());
955        assert!(result["items"]["properties"]["id"].get("exclusiveMinimum").is_none());
956    }
957
958    // --- Task 4.2: Vertex AI surface variant tests ---
959
960    #[test]
961    fn test_vertex_ai_sets_additional_properties_false() {
962        let adapter = GeminiSchemaAdapter::vertex_ai();
963        let schema = json!({
964            "type": "object",
965            "properties": { "name": { "type": "string" } },
966            "additionalProperties": true
967        });
968        let result = adapter.normalize_schema(schema);
969        assert_eq!(result["additionalProperties"], json!(false));
970    }
971
972    #[test]
973    fn test_vertex_ai_sets_additional_properties_false_on_nested_objects() {
974        let adapter = GeminiSchemaAdapter::vertex_ai();
975        let schema = json!({
976            "type": "object",
977            "properties": {
978                "inner": {
979                    "type": "object",
980                    "properties": {
981                        "value": { "type": "string" }
982                    }
983                }
984            }
985        });
986        let result = adapter.normalize_schema(schema);
987        assert_eq!(result["additionalProperties"], json!(false));
988        assert_eq!(result["properties"]["inner"]["additionalProperties"], json!(false));
989    }
990
991    #[test]
992    fn test_vertex_ai_does_not_set_additional_properties_on_non_object() {
993        let adapter = GeminiSchemaAdapter::vertex_ai();
994        let schema = json!({
995            "type": "string",
996            "additionalProperties": true
997        });
998        let result = adapter.normalize_schema(schema);
999        // Non-object schemas should have additionalProperties removed
1000        assert!(result.get("additionalProperties").is_none());
1001    }
1002
1003    #[test]
1004    fn test_standard_mode_removes_additional_properties() {
1005        let adapter = GeminiSchemaAdapter::new();
1006        let schema = json!({
1007            "type": "object",
1008            "properties": { "name": { "type": "string" } },
1009            "additionalProperties": true
1010        });
1011        let result = adapter.normalize_schema(schema);
1012        assert!(result.get("additionalProperties").is_none());
1013    }
1014
1015    #[test]
1016    fn test_vertex_ai_still_removes_other_unsupported_keywords() {
1017        let adapter = GeminiSchemaAdapter::vertex_ai();
1018        let schema = json!({
1019            "type": "object",
1020            "properties": { "x": { "type": "number" } },
1021            "exclusiveMinimum": 0,
1022            "exclusiveMaximum": 100,
1023            "not": { "type": "null" },
1024            "propertyNames": { "pattern": "^[a-z]" },
1025            "patternProperties": { "^S_": { "type": "string" } },
1026            "unevaluatedProperties": false
1027        });
1028        let result = adapter.normalize_schema(schema);
1029        assert!(result.get("exclusiveMinimum").is_none());
1030        assert!(result.get("exclusiveMaximum").is_none());
1031        assert!(result.get("not").is_none());
1032        assert!(result.get("propertyNames").is_none());
1033        assert!(result.get("patternProperties").is_none());
1034        assert!(result.get("unevaluatedProperties").is_none());
1035        // But additionalProperties: false is set
1036        assert_eq!(result["additionalProperties"], json!(false));
1037    }
1038
1039    // --- Task 4.3: normalize_tool_name tests ---
1040
1041    #[test]
1042    fn test_normalize_tool_name_short_name_unchanged() {
1043        let adapter = GeminiSchemaAdapter::new();
1044        let name = "get_weather";
1045        let result = adapter.normalize_tool_name(name);
1046        assert_eq!(result, "get_weather");
1047        assert!(matches!(result, Cow::Borrowed(_)));
1048    }
1049
1050    #[test]
1051    fn test_normalize_tool_name_exactly_64_bytes() {
1052        let adapter = GeminiSchemaAdapter::new();
1053        let name = "a".repeat(64);
1054        let result = adapter.normalize_tool_name(&name);
1055        assert_eq!(result.len(), 64);
1056        assert!(matches!(result, Cow::Borrowed(_)));
1057    }
1058
1059    #[test]
1060    fn test_normalize_tool_name_truncates_at_64_bytes() {
1061        let adapter = GeminiSchemaAdapter::new();
1062        let name = "a".repeat(100);
1063        let result = adapter.normalize_tool_name(&name);
1064        assert_eq!(result.len(), 64);
1065        assert_eq!(result.as_ref(), "a".repeat(64));
1066    }
1067
1068    #[test]
1069    fn test_normalize_tool_name_multibyte_boundary() {
1070        let adapter = GeminiSchemaAdapter::new();
1071        // Each '日' is 3 bytes in UTF-8. 21 chars = 63 bytes.
1072        // Adding one more '日' would be 66 bytes, so truncation should stop at 63.
1073        let name = "日".repeat(22); // 66 bytes
1074        let result = adapter.normalize_tool_name(&name);
1075        assert!(result.len() <= 64);
1076        // Should be 63 bytes (21 chars × 3 bytes)
1077        assert_eq!(result.len(), 63);
1078        assert_eq!(result.as_ref(), "日".repeat(21));
1079        // Verify it's valid UTF-8
1080        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
1081    }
1082
1083    #[test]
1084    fn test_normalize_tool_name_emoji_boundary() {
1085        let adapter = GeminiSchemaAdapter::new();
1086        // '🎯' is 4 bytes. 16 emojis = 64 bytes exactly.
1087        let name = "🎯".repeat(16);
1088        assert_eq!(name.len(), 64);
1089        let result = adapter.normalize_tool_name(&name);
1090        assert_eq!(result.len(), 64);
1091
1092        // 17 emojis = 68 bytes, should truncate to 16 emojis = 64 bytes
1093        let name = "🎯".repeat(17);
1094        let result = adapter.normalize_tool_name(&name);
1095        assert_eq!(result.len(), 64);
1096        assert_eq!(result.as_ref(), "🎯".repeat(16));
1097    }
1098
1099    // --- Task 4.4: empty_schema tests ---
1100
1101    #[test]
1102    fn test_empty_schema_returns_object_with_properties() {
1103        let adapter = GeminiSchemaAdapter::new();
1104        let result = adapter.empty_schema();
1105        assert_eq!(result, json!({"type": "object", "properties": {}}));
1106    }
1107
1108    #[test]
1109    fn test_empty_schema_vertex_ai_same_as_standard() {
1110        let adapter = GeminiSchemaAdapter::vertex_ai();
1111        let result = adapter.empty_schema();
1112        assert_eq!(result, json!({"type": "object", "properties": {}}));
1113    }
1114
1115    // --- Comprehensive unsupported keyword stripping tests ---
1116    // These validate that ALL keywords not in Gemini's Schema proto are removed.
1117
1118    #[test]
1119    fn test_removes_all_validation_keywords() {
1120        let adapter = GeminiSchemaAdapter::new();
1121        let schema = json!({
1122            "type": "object",
1123            "title": "MySchema",
1124            "$id": "https://example.com/schema",
1125            "default": {},
1126            "deprecated": true,
1127            "readOnly": true,
1128            "writeOnly": false,
1129            "examples": [{"name": "test"}],
1130            "minProperties": 1,
1131            "maxProperties": 10,
1132            "properties": {
1133                "name": {
1134                    "type": "string",
1135                    "title": "Name",
1136                    "default": "",
1137                    "minLength": 1,
1138                    "maxLength": 100,
1139                    "pattern": "^[a-z]+$"
1140                },
1141                "age": {
1142                    "type": "integer",
1143                    "minimum": 0,
1144                    "maximum": 150,
1145                    "multipleOf": 1
1146                },
1147                "tags": {
1148                    "type": "array",
1149                    "items": { "type": "string" },
1150                    "minItems": 1,
1151                    "maxItems": 10,
1152                    "uniqueItems": true,
1153                    "contains": { "type": "string" }
1154                }
1155            }
1156        });
1157        let result = adapter.normalize_schema(schema);
1158
1159        // Top-level annotation/validation keywords removed
1160        assert!(result.get("title").is_none());
1161        assert!(result.get("$id").is_none());
1162        assert!(result.get("default").is_none());
1163        assert!(result.get("deprecated").is_none());
1164        assert!(result.get("readOnly").is_none());
1165        assert!(result.get("writeOnly").is_none());
1166        assert!(result.get("examples").is_none());
1167        assert!(result.get("minProperties").is_none());
1168        assert!(result.get("maxProperties").is_none());
1169
1170        // String property: validation keywords removed, type/description preserved
1171        let name = &result["properties"]["name"];
1172        assert!(name.get("title").is_none());
1173        assert!(name.get("default").is_none());
1174        assert!(name.get("minLength").is_none());
1175        assert!(name.get("maxLength").is_none());
1176        assert!(name.get("pattern").is_none());
1177        assert_eq!(name["type"], "string");
1178
1179        // Integer property: numeric constraints removed
1180        let age = &result["properties"]["age"];
1181        assert!(age.get("minimum").is_none());
1182        assert!(age.get("maximum").is_none());
1183        assert!(age.get("multipleOf").is_none());
1184        assert_eq!(age["type"], "integer");
1185
1186        // Array property: array constraints removed, items preserved
1187        let tags = &result["properties"]["tags"];
1188        assert!(tags.get("minItems").is_none());
1189        assert!(tags.get("maxItems").is_none());
1190        assert!(tags.get("uniqueItems").is_none());
1191        assert!(tags.get("contains").is_none());
1192        assert_eq!(tags["type"], "array");
1193        assert_eq!(tags["items"]["type"], "string");
1194    }
1195
1196    #[test]
1197    fn test_removes_prefix_items() {
1198        let adapter = GeminiSchemaAdapter::new();
1199        let schema = json!({
1200            "type": "array",
1201            "prefixItems": [
1202                { "type": "string" },
1203                { "type": "integer" }
1204            ]
1205        });
1206        let result = adapter.normalize_schema(schema);
1207        assert!(result.get("prefixItems").is_none());
1208    }
1209
1210    #[test]
1211    fn test_removes_dependent_keywords() {
1212        let adapter = GeminiSchemaAdapter::new();
1213        let schema = json!({
1214            "type": "object",
1215            "properties": {
1216                "name": { "type": "string" },
1217                "credit_card": { "type": "string" }
1218            },
1219            "dependentRequired": {
1220                "credit_card": ["billing_address"]
1221            },
1222            "dependentSchemas": {
1223                "credit_card": {
1224                    "properties": {
1225                        "billing_address": { "type": "string" }
1226                    }
1227                }
1228            }
1229        });
1230        let result = adapter.normalize_schema(schema);
1231        assert!(result.get("dependentRequired").is_none());
1232        assert!(result.get("dependentSchemas").is_none());
1233    }
1234
1235    #[test]
1236    fn test_removes_content_keywords() {
1237        let adapter = GeminiSchemaAdapter::new();
1238        let schema = json!({
1239            "type": "string",
1240            "contentMediaType": "application/json",
1241            "contentEncoding": "base64"
1242        });
1243        let result = adapter.normalize_schema(schema);
1244        assert!(result.get("contentMediaType").is_none());
1245        assert!(result.get("contentEncoding").is_none());
1246    }
1247}