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    // Handle `items`:
314    // 1. If type is NOT "array", remove items entirely (meaningless on non-array types).
315    // 2. If type IS "array" and items is a JSON array (tuple validation syntax),
316    //    convert to single schema using the first element. Gemini requires items
317    //    on array types but only supports a single schema, not tuple validation.
318    // 3. If type IS "array" and items is already an object, keep it (valid).
319    // 4. If type IS "array" and items is missing, add a default items schema.
320    let is_array_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "array");
321    if !is_array_type {
322        obj.remove("items");
323    } else if obj.get("items").is_some_and(|v| v.is_array()) {
324        // Convert tuple items [schema1, schema2, ...] → first schema as single items
325        let first_schema = obj
326            .get("items")
327            .and_then(|v| v.as_array())
328            .and_then(|arr| arr.first())
329            .cloned()
330            .unwrap_or_else(|| serde_json::json!({"type": "string"}));
331        obj.insert("items".to_string(), first_schema);
332    } else if !obj.contains_key("items") {
333        // Gemini requires items on array types — add default if missing
334        obj.insert("items".to_string(), serde_json::json!({"type": "string"}));
335    }
336
337    // Recurse into properties
338    if let Some(props) = obj.get_mut("properties")
339        && let Some(props_obj) = props.as_object_mut()
340    {
341        for value in props_obj.values_mut() {
342            remove_unsupported_keywords(value);
343        }
344    }
345
346    // Recurse into items (now guaranteed to be a single schema object if present)
347    if let Some(items) = obj.get_mut("items")
348        && items.is_object()
349    {
350        remove_unsupported_keywords(items);
351    }
352
353    // Recurse into allOf, anyOf, oneOf (may still exist if not collapsed)
354    for keyword in &["allOf", "anyOf", "oneOf"] {
355        if let Some(arr_val) = obj.get_mut(*keyword)
356            && let Some(arr) = arr_val.as_array_mut()
357        {
358            for sub in arr.iter_mut() {
359                remove_unsupported_keywords(sub);
360            }
361        }
362    }
363}
364
365/// Recursively removes unsupported keywords for the Vertex AI surface.
366///
367/// Unlike the standard surface, Vertex AI requires `additionalProperties: false`
368/// on object schemas. This function:
369/// - Sets `additionalProperties` to `false` on object schemas (instead of removing it)
370/// - Removes all other unsupported keywords the same as the standard surface
371fn remove_unsupported_keywords_vertex(schema: &mut Value) {
372    let Some(obj) = schema.as_object_mut() else {
373        return;
374    };
375
376    // Remove Vertex-specific unsupported keywords (does NOT include additionalProperties)
377    for keyword in UNSUPPORTED_KEYWORDS_VERTEX {
378        obj.remove(*keyword);
379    }
380
381    // For object schemas, set additionalProperties to false
382    let is_object_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "object");
383    if is_object_type {
384        obj.insert("additionalProperties".to_string(), Value::Bool(false));
385    } else {
386        // For non-object schemas, remove additionalProperties if present
387        obj.remove("additionalProperties");
388    }
389
390    // Handle `items`:
391    // 1. If type is NOT "array", remove items entirely (meaningless on non-array types).
392    // 2. If type IS "array" and items is a JSON array (tuple validation syntax),
393    //    convert to single schema using the first element. Gemini requires items
394    //    on array types but only supports a single schema, not tuple validation.
395    // 3. If type IS "array" and items is already an object, keep it (valid).
396    // 4. If type IS "array" and items is missing, add a default items schema.
397    let is_array_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "array");
398    if !is_array_type {
399        obj.remove("items");
400    } else if obj.get("items").is_some_and(|v| v.is_array()) {
401        let first_schema = obj
402            .get("items")
403            .and_then(|v| v.as_array())
404            .and_then(|arr| arr.first())
405            .cloned()
406            .unwrap_or_else(|| serde_json::json!({"type": "string"}));
407        obj.insert("items".to_string(), first_schema);
408    } else if !obj.contains_key("items") {
409        obj.insert("items".to_string(), serde_json::json!({"type": "string"}));
410    }
411
412    // Recurse into properties
413    if let Some(props) = obj.get_mut("properties")
414        && let Some(props_obj) = props.as_object_mut()
415    {
416        for value in props_obj.values_mut() {
417            remove_unsupported_keywords_vertex(value);
418        }
419    }
420
421    // Recurse into items (now guaranteed to be a single schema object if present)
422    if let Some(items) = obj.get_mut("items")
423        && items.is_object()
424    {
425        remove_unsupported_keywords_vertex(items);
426    }
427
428    // Recurse into allOf, anyOf, oneOf (may still exist if not collapsed)
429    for keyword in &["allOf", "anyOf", "oneOf"] {
430        if let Some(arr_val) = obj.get_mut(*keyword)
431            && let Some(arr) = arr_val.as_array_mut()
432        {
433            for sub in arr.iter_mut() {
434                remove_unsupported_keywords_vertex(sub);
435            }
436        }
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use serde_json::json;
444
445    #[test]
446    fn test_strips_schema_keyword() {
447        let adapter = GeminiSchemaAdapter::new();
448        let schema = json!({
449            "$schema": "http://json-schema.org/draft-07/schema#",
450            "type": "object",
451            "properties": { "name": { "type": "string" } }
452        });
453        let result = adapter.normalize_schema(schema);
454        assert!(result.get("$schema").is_none());
455    }
456
457    #[test]
458    fn test_removes_additional_properties() {
459        let adapter = GeminiSchemaAdapter::new();
460        let schema = json!({
461            "type": "object",
462            "properties": { "name": { "type": "string" } },
463            "additionalProperties": true
464        });
465        let result = adapter.normalize_schema(schema);
466        assert!(result.get("additionalProperties").is_none());
467    }
468
469    #[test]
470    fn test_removes_exclusive_min_max() {
471        let adapter = GeminiSchemaAdapter::new();
472        let schema = json!({
473            "type": "number",
474            "exclusiveMinimum": 0,
475            "exclusiveMaximum": 100
476        });
477        let result = adapter.normalize_schema(schema);
478        assert!(result.get("exclusiveMinimum").is_none());
479        assert!(result.get("exclusiveMaximum").is_none());
480    }
481
482    #[test]
483    fn test_removes_items_when_not_array() {
484        let adapter = GeminiSchemaAdapter::new();
485        let schema = json!({
486            "type": "object",
487            "items": { "type": "string" }
488        });
489        let result = adapter.normalize_schema(schema);
490        assert!(result.get("items").is_none());
491    }
492
493    #[test]
494    fn test_preserves_items_when_array() {
495        let adapter = GeminiSchemaAdapter::new();
496        let schema = json!({
497            "type": "array",
498            "items": { "type": "string" }
499        });
500        let result = adapter.normalize_schema(schema);
501        assert!(result.get("items").is_some());
502        assert_eq!(result["items"]["type"], "string");
503    }
504
505    #[test]
506    fn test_converts_items_tuple_validation_to_single_schema() {
507        // Gemini's proto doesn't support tuple validation (items as JSON array).
508        // Convert to single schema using first element so arrays still have items.
509        let adapter = GeminiSchemaAdapter::new();
510        let schema = json!({
511            "type": "array",
512            "items": [
513                { "type": "number" },
514                { "type": "number" }
515            ]
516        });
517        let result = adapter.normalize_schema(schema);
518        // items should be converted to the first element schema, not removed
519        assert_eq!(result["items"], json!({"type": "number"}));
520        assert_eq!(result["type"], "array");
521    }
522
523    #[test]
524    fn test_vertex_ai_converts_items_tuple_validation() {
525        let adapter = GeminiSchemaAdapter::vertex_ai();
526        let schema = json!({
527            "type": "array",
528            "items": [
529                { "type": "integer" },
530                { "type": "boolean" }
531            ]
532        });
533        let result = adapter.normalize_schema(schema);
534        // Converts to first element schema
535        assert_eq!(result["items"], json!({"type": "integer"}));
536    }
537
538    #[test]
539    fn test_removes_not_keyword() {
540        let adapter = GeminiSchemaAdapter::new();
541        let schema = json!({
542            "type": "string",
543            "not": { "enum": ["bad"] }
544        });
545        let result = adapter.normalize_schema(schema);
546        assert!(result.get("not").is_none());
547    }
548
549    #[test]
550    fn test_removes_property_names() {
551        let adapter = GeminiSchemaAdapter::new();
552        let schema = json!({
553            "type": "object",
554            "propertyNames": { "pattern": "^[a-z]+$" }
555        });
556        let result = adapter.normalize_schema(schema);
557        assert!(result.get("propertyNames").is_none());
558    }
559
560    #[test]
561    fn test_removes_pattern_properties() {
562        let adapter = GeminiSchemaAdapter::new();
563        let schema = json!({
564            "type": "object",
565            "patternProperties": { "^S_": { "type": "string" } }
566        });
567        let result = adapter.normalize_schema(schema);
568        assert!(result.get("patternProperties").is_none());
569    }
570
571    #[test]
572    fn test_removes_unevaluated_properties() {
573        let adapter = GeminiSchemaAdapter::new();
574        let schema = json!({
575            "type": "object",
576            "unevaluatedProperties": false
577        });
578        let result = adapter.normalize_schema(schema);
579        assert!(result.get("unevaluatedProperties").is_none());
580    }
581
582    #[test]
583    fn test_collapses_any_of() {
584        let adapter = GeminiSchemaAdapter::new();
585        let schema = json!({
586            "anyOf": [
587                { "type": "null" },
588                { "type": "string", "description": "A non-empty string" }
589            ]
590        });
591        let result = adapter.normalize_schema(schema);
592        assert!(result.get("anyOf").is_none());
593        assert_eq!(result["type"], "string");
594        assert_eq!(result["description"], "A non-empty string");
595    }
596
597    #[test]
598    fn test_collapses_one_of() {
599        let adapter = GeminiSchemaAdapter::new();
600        let schema = json!({
601            "oneOf": [
602                { "type": "null" },
603                { "type": "integer", "minimum": 0 }
604            ]
605        });
606        let result = adapter.normalize_schema(schema);
607        assert!(result.get("oneOf").is_none());
608        assert_eq!(result["type"], "integer");
609    }
610
611    #[test]
612    fn test_merges_all_of() {
613        let adapter = GeminiSchemaAdapter::new();
614        let schema = json!({
615            "allOf": [
616                { "type": "object", "properties": { "a": { "type": "string" } } },
617                { "properties": { "b": { "type": "number" } }, "required": ["b"] }
618            ]
619        });
620        let result = adapter.normalize_schema(schema);
621        assert!(result.get("allOf").is_none());
622        assert_eq!(result["properties"]["a"]["type"], "string");
623        assert_eq!(result["properties"]["b"]["type"], "number");
624        assert_eq!(result["required"], json!(["b"]));
625    }
626
627    #[test]
628    fn test_collapses_type_arrays() {
629        let adapter = GeminiSchemaAdapter::new();
630        let schema = json!({
631            "type": ["string", "null"],
632            "minLength": 1
633        });
634        let result = adapter.normalize_schema(schema);
635        assert_eq!(result["type"], "string");
636    }
637
638    #[test]
639    fn test_strips_conditional_keywords() {
640        let adapter = GeminiSchemaAdapter::new();
641        let schema = json!({
642            "type": "object",
643            "if": { "properties": { "kind": { "const": "a" } } },
644            "then": { "required": ["extra"] },
645            "else": { "required": [] }
646        });
647        let result = adapter.normalize_schema(schema);
648        assert!(result.get("if").is_none());
649        assert!(result.get("then").is_none());
650        assert!(result.get("else").is_none());
651    }
652
653    #[test]
654    fn test_converts_const_to_enum() {
655        let adapter = GeminiSchemaAdapter::new();
656        let schema = json!({
657            "type": "string",
658            "const": "fixed"
659        });
660        let result = adapter.normalize_schema(schema);
661        assert!(result.get("const").is_none());
662        assert_eq!(result["enum"], json!(["fixed"]));
663    }
664
665    #[test]
666    fn test_strips_null_from_enum() {
667        let adapter = GeminiSchemaAdapter::new();
668        let schema = json!({
669            "type": "string",
670            "enum": ["a", null, "b"]
671        });
672        let result = adapter.normalize_schema(schema);
673        assert_eq!(result["enum"], json!(["a", "b"]));
674    }
675
676    #[test]
677    fn test_removes_empty_enum_after_null_strip() {
678        let adapter = GeminiSchemaAdapter::new();
679        let schema = json!({
680            "type": "string",
681            "enum": [null]
682        });
683        let result = adapter.normalize_schema(schema);
684        assert!(result.get("enum").is_none());
685    }
686
687    #[test]
688    fn test_adds_implicit_object_type() {
689        let adapter = GeminiSchemaAdapter::new();
690        let schema = json!({
691            "properties": { "name": { "type": "string" } }
692        });
693        let result = adapter.normalize_schema(schema);
694        assert_eq!(result["type"], "object");
695    }
696
697    #[test]
698    fn test_strips_unsupported_formats() {
699        let adapter = GeminiSchemaAdapter::new();
700        let schema = json!({
701            "type": "object",
702            "properties": {
703                "created": { "type": "string", "format": "date-time" },
704                "hostname": { "type": "string", "format": "hostname" },
705                "id": { "type": "string", "format": "uuid" }
706            }
707        });
708        let result = adapter.normalize_schema(schema);
709        assert_eq!(result["properties"]["created"]["format"], "date-time");
710        assert!(result["properties"]["hostname"].get("format").is_none());
711        assert_eq!(result["properties"]["id"]["format"], "uuid");
712    }
713
714    #[test]
715    fn test_preserves_all_allowed_formats() {
716        let adapter = GeminiSchemaAdapter::new();
717        for format in GEMINI_ALLOWED_FORMATS {
718            let schema = json!({ "type": "string", "format": format });
719            let result = adapter.normalize_schema(schema);
720            assert_eq!(result["format"], *format, "format '{format}' should be preserved");
721        }
722    }
723
724    #[test]
725    fn test_enforces_nesting_depth() {
726        let adapter = GeminiSchemaAdapter::new();
727        // Create a schema nested 7 levels deep
728        let schema = json!({
729            "type": "object",
730            "properties": {
731                "l1": {
732                    "type": "object",
733                    "properties": {
734                        "l2": {
735                            "type": "object",
736                            "properties": {
737                                "l3": {
738                                    "type": "object",
739                                    "properties": {
740                                        "l4": {
741                                            "type": "object",
742                                            "properties": {
743                                                "l5": {
744                                                    "type": "object",
745                                                    "properties": {
746                                                        "l6": { "type": "string" }
747                                                    }
748                                                }
749                                            }
750                                        }
751                                    }
752                                }
753                            }
754                        }
755                    }
756                }
757            }
758        });
759        let result = adapter.normalize_schema(schema);
760        // At depth 5, the schema should be truncated to {"type": "object"}
761        let l5 = &result["properties"]["l1"]["properties"]["l2"]["properties"]["l3"]["properties"]
762            ["l4"]["properties"]["l5"];
763        assert_eq!(l5, &json!({"type": "object"}));
764    }
765
766    #[test]
767    fn test_resolves_refs() {
768        let adapter = GeminiSchemaAdapter::new();
769        let schema = json!({
770            "type": "object",
771            "properties": {
772                "address": { "$ref": "#/definitions/Address" }
773            },
774            "definitions": {
775                "Address": {
776                    "type": "object",
777                    "properties": {
778                        "street": { "type": "string" }
779                    }
780                }
781            }
782        });
783        let result = adapter.normalize_schema(schema);
784        // $ref should be resolved
785        assert!(result["properties"]["address"].get("$ref").is_none());
786        assert_eq!(result["properties"]["address"]["type"], "object");
787        assert_eq!(result["properties"]["address"]["properties"]["street"]["type"], "string");
788        // definitions should be removed
789        assert!(result.get("definitions").is_none());
790    }
791
792    #[test]
793    fn test_resolves_dollar_defs() {
794        let adapter = GeminiSchemaAdapter::new();
795        let schema = json!({
796            "type": "object",
797            "properties": {
798                "item": { "$ref": "#/$defs/Item" }
799            },
800            "$defs": {
801                "Item": {
802                    "type": "object",
803                    "properties": {
804                        "name": { "type": "string" }
805                    }
806                }
807            }
808        });
809        let result = adapter.normalize_schema(schema);
810        assert!(result["properties"]["item"].get("$ref").is_none());
811        assert_eq!(result["properties"]["item"]["type"], "object");
812        assert!(result.get("$defs").is_none());
813    }
814
815    #[test]
816    fn test_unresolvable_ref_becomes_object() {
817        let adapter = GeminiSchemaAdapter::new();
818        let schema = json!({
819            "type": "object",
820            "properties": {
821                "unknown": { "$ref": "#/definitions/DoesNotExist" }
822            }
823        });
824        let result = adapter.normalize_schema(schema);
825        assert_eq!(result["properties"]["unknown"], json!({"type": "object"}));
826    }
827
828    #[test]
829    fn test_circular_ref_breaks() {
830        let adapter = GeminiSchemaAdapter::new();
831        let schema = json!({
832            "type": "object",
833            "properties": {
834                "self_ref": { "$ref": "#/definitions/Node" }
835            },
836            "definitions": {
837                "Node": {
838                    "type": "object",
839                    "properties": {
840                        "child": { "$ref": "#/definitions/Node" }
841                    }
842                }
843            }
844        });
845        let result = adapter.normalize_schema(schema);
846        // Should not panic and should terminate
847        assert_eq!(result["properties"]["self_ref"]["type"], "object");
848        assert!(result.get("definitions").is_none());
849    }
850
851    #[test]
852    fn test_removes_definitions_and_defs() {
853        let adapter = GeminiSchemaAdapter::new();
854        let schema = json!({
855            "type": "object",
856            "definitions": { "Foo": { "type": "string" } },
857            "$defs": { "Bar": { "type": "number" } }
858        });
859        let result = adapter.normalize_schema(schema);
860        assert!(result.get("definitions").is_none());
861        assert!(result.get("$defs").is_none());
862    }
863
864    #[test]
865    fn test_nested_unsupported_keywords_removed() {
866        let adapter = GeminiSchemaAdapter::new();
867        let schema = json!({
868            "type": "object",
869            "properties": {
870                "inner": {
871                    "type": "object",
872                    "additionalProperties": false,
873                    "exclusiveMinimum": 5,
874                    "properties": {
875                        "deep": {
876                            "type": "number",
877                            "exclusiveMaximum": 100
878                        }
879                    }
880                }
881            }
882        });
883        let result = adapter.normalize_schema(schema);
884        let inner = &result["properties"]["inner"];
885        assert!(inner.get("additionalProperties").is_none());
886        assert!(inner.get("exclusiveMinimum").is_none());
887        assert!(inner["properties"]["deep"].get("exclusiveMaximum").is_none());
888    }
889
890    #[test]
891    fn test_full_transform_pipeline() {
892        let adapter = GeminiSchemaAdapter::new();
893        let schema = json!({
894            "$schema": "http://json-schema.org/draft-07/schema#",
895            "definitions": {
896                "Status": { "type": "string", "enum": ["active", null, "inactive"] }
897            },
898            "properties": {
899                "name": { "type": ["string", "null"], "format": "hostname" },
900                "status": { "$ref": "#/definitions/Status" },
901                "config": {
902                    "type": "object",
903                    "additionalProperties": true,
904                    "properties": {
905                        "value": { "const": "fixed" }
906                    }
907                }
908            },
909            "if": { "properties": { "name": { "type": "string" } } },
910            "then": { "required": ["status"] },
911            "additionalProperties": false
912        });
913        let result = adapter.normalize_schema(schema);
914
915        // $schema removed
916        assert!(result.get("$schema").is_none());
917        // definitions removed
918        assert!(result.get("definitions").is_none());
919        // conditional keywords removed
920        assert!(result.get("if").is_none());
921        assert!(result.get("then").is_none());
922        // additionalProperties removed
923        assert!(result.get("additionalProperties").is_none());
924        // type array collapsed
925        assert_eq!(result["properties"]["name"]["type"], "string");
926        // unsupported format stripped
927        assert!(result["properties"]["name"].get("format").is_none());
928        // $ref resolved and null stripped from enum
929        assert_eq!(result["properties"]["status"]["enum"], json!(["active", "inactive"]));
930        // const converted to enum
931        assert_eq!(result["properties"]["config"]["properties"]["value"]["enum"], json!(["fixed"]));
932        // nested additionalProperties removed
933        assert!(result["properties"]["config"].get("additionalProperties").is_none());
934        // implicit type added
935        assert_eq!(result["type"], "object");
936    }
937
938    #[test]
939    fn test_idempotent() {
940        let adapter = GeminiSchemaAdapter::new();
941        let schema = json!({
942            "$schema": "http://json-schema.org/draft-07/schema#",
943            "type": "object",
944            "properties": {
945                "name": { "type": ["string", "null"], "format": "hostname" },
946                "items": { "type": "array", "items": { "type": "string" } }
947            },
948            "additionalProperties": true,
949            "if": { "const": true },
950            "then": { "required": ["name"] }
951        });
952        let first = adapter.normalize_schema(schema);
953        let second = adapter.normalize_schema(first.clone());
954        assert_eq!(first, second);
955    }
956
957    #[test]
958    fn test_empty_schema() {
959        let adapter = GeminiSchemaAdapter::new();
960        let schema = json!({});
961        let result = adapter.normalize_schema(schema);
962        assert_eq!(result, json!({}));
963    }
964
965    #[test]
966    fn test_array_items_nested_cleanup() {
967        let adapter = GeminiSchemaAdapter::new();
968        let schema = json!({
969            "type": "array",
970            "items": {
971                "type": "object",
972                "additionalProperties": true,
973                "properties": {
974                    "id": { "type": "integer", "exclusiveMinimum": 0 }
975                }
976            }
977        });
978        let result = adapter.normalize_schema(schema);
979        assert!(result["items"].get("additionalProperties").is_none());
980        assert!(result["items"]["properties"]["id"].get("exclusiveMinimum").is_none());
981    }
982
983    // --- Task 4.2: Vertex AI surface variant tests ---
984
985    #[test]
986    fn test_vertex_ai_sets_additional_properties_false() {
987        let adapter = GeminiSchemaAdapter::vertex_ai();
988        let schema = json!({
989            "type": "object",
990            "properties": { "name": { "type": "string" } },
991            "additionalProperties": true
992        });
993        let result = adapter.normalize_schema(schema);
994        assert_eq!(result["additionalProperties"], json!(false));
995    }
996
997    #[test]
998    fn test_vertex_ai_sets_additional_properties_false_on_nested_objects() {
999        let adapter = GeminiSchemaAdapter::vertex_ai();
1000        let schema = json!({
1001            "type": "object",
1002            "properties": {
1003                "inner": {
1004                    "type": "object",
1005                    "properties": {
1006                        "value": { "type": "string" }
1007                    }
1008                }
1009            }
1010        });
1011        let result = adapter.normalize_schema(schema);
1012        assert_eq!(result["additionalProperties"], json!(false));
1013        assert_eq!(result["properties"]["inner"]["additionalProperties"], json!(false));
1014    }
1015
1016    #[test]
1017    fn test_vertex_ai_does_not_set_additional_properties_on_non_object() {
1018        let adapter = GeminiSchemaAdapter::vertex_ai();
1019        let schema = json!({
1020            "type": "string",
1021            "additionalProperties": true
1022        });
1023        let result = adapter.normalize_schema(schema);
1024        // Non-object schemas should have additionalProperties removed
1025        assert!(result.get("additionalProperties").is_none());
1026    }
1027
1028    #[test]
1029    fn test_standard_mode_removes_additional_properties() {
1030        let adapter = GeminiSchemaAdapter::new();
1031        let schema = json!({
1032            "type": "object",
1033            "properties": { "name": { "type": "string" } },
1034            "additionalProperties": true
1035        });
1036        let result = adapter.normalize_schema(schema);
1037        assert!(result.get("additionalProperties").is_none());
1038    }
1039
1040    #[test]
1041    fn test_vertex_ai_still_removes_other_unsupported_keywords() {
1042        let adapter = GeminiSchemaAdapter::vertex_ai();
1043        let schema = json!({
1044            "type": "object",
1045            "properties": { "x": { "type": "number" } },
1046            "exclusiveMinimum": 0,
1047            "exclusiveMaximum": 100,
1048            "not": { "type": "null" },
1049            "propertyNames": { "pattern": "^[a-z]" },
1050            "patternProperties": { "^S_": { "type": "string" } },
1051            "unevaluatedProperties": false
1052        });
1053        let result = adapter.normalize_schema(schema);
1054        assert!(result.get("exclusiveMinimum").is_none());
1055        assert!(result.get("exclusiveMaximum").is_none());
1056        assert!(result.get("not").is_none());
1057        assert!(result.get("propertyNames").is_none());
1058        assert!(result.get("patternProperties").is_none());
1059        assert!(result.get("unevaluatedProperties").is_none());
1060        // But additionalProperties: false is set
1061        assert_eq!(result["additionalProperties"], json!(false));
1062    }
1063
1064    // --- Task 4.3: normalize_tool_name tests ---
1065
1066    #[test]
1067    fn test_normalize_tool_name_short_name_unchanged() {
1068        let adapter = GeminiSchemaAdapter::new();
1069        let name = "get_weather";
1070        let result = adapter.normalize_tool_name(name);
1071        assert_eq!(result, "get_weather");
1072        assert!(matches!(result, Cow::Borrowed(_)));
1073    }
1074
1075    #[test]
1076    fn test_normalize_tool_name_exactly_64_bytes() {
1077        let adapter = GeminiSchemaAdapter::new();
1078        let name = "a".repeat(64);
1079        let result = adapter.normalize_tool_name(&name);
1080        assert_eq!(result.len(), 64);
1081        assert!(matches!(result, Cow::Borrowed(_)));
1082    }
1083
1084    #[test]
1085    fn test_normalize_tool_name_truncates_at_64_bytes() {
1086        let adapter = GeminiSchemaAdapter::new();
1087        let name = "a".repeat(100);
1088        let result = adapter.normalize_tool_name(&name);
1089        assert_eq!(result.len(), 64);
1090        assert_eq!(result.as_ref(), "a".repeat(64));
1091    }
1092
1093    #[test]
1094    fn test_normalize_tool_name_multibyte_boundary() {
1095        let adapter = GeminiSchemaAdapter::new();
1096        // Each '日' is 3 bytes in UTF-8. 21 chars = 63 bytes.
1097        // Adding one more '日' would be 66 bytes, so truncation should stop at 63.
1098        let name = "日".repeat(22); // 66 bytes
1099        let result = adapter.normalize_tool_name(&name);
1100        assert!(result.len() <= 64);
1101        // Should be 63 bytes (21 chars × 3 bytes)
1102        assert_eq!(result.len(), 63);
1103        assert_eq!(result.as_ref(), "日".repeat(21));
1104        // Verify it's valid UTF-8
1105        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
1106    }
1107
1108    #[test]
1109    fn test_normalize_tool_name_emoji_boundary() {
1110        let adapter = GeminiSchemaAdapter::new();
1111        // '🎯' is 4 bytes. 16 emojis = 64 bytes exactly.
1112        let name = "🎯".repeat(16);
1113        assert_eq!(name.len(), 64);
1114        let result = adapter.normalize_tool_name(&name);
1115        assert_eq!(result.len(), 64);
1116
1117        // 17 emojis = 68 bytes, should truncate to 16 emojis = 64 bytes
1118        let name = "🎯".repeat(17);
1119        let result = adapter.normalize_tool_name(&name);
1120        assert_eq!(result.len(), 64);
1121        assert_eq!(result.as_ref(), "🎯".repeat(16));
1122    }
1123
1124    // --- Task 4.4: empty_schema tests ---
1125
1126    #[test]
1127    fn test_empty_schema_returns_object_with_properties() {
1128        let adapter = GeminiSchemaAdapter::new();
1129        let result = adapter.empty_schema();
1130        assert_eq!(result, json!({"type": "object", "properties": {}}));
1131    }
1132
1133    #[test]
1134    fn test_empty_schema_vertex_ai_same_as_standard() {
1135        let adapter = GeminiSchemaAdapter::vertex_ai();
1136        let result = adapter.empty_schema();
1137        assert_eq!(result, json!({"type": "object", "properties": {}}));
1138    }
1139
1140    // --- Comprehensive unsupported keyword stripping tests ---
1141    // These validate that ALL keywords not in Gemini's Schema proto are removed.
1142
1143    #[test]
1144    fn test_removes_all_validation_keywords() {
1145        let adapter = GeminiSchemaAdapter::new();
1146        let schema = json!({
1147            "type": "object",
1148            "title": "MySchema",
1149            "$id": "https://example.com/schema",
1150            "default": {},
1151            "deprecated": true,
1152            "readOnly": true,
1153            "writeOnly": false,
1154            "examples": [{"name": "test"}],
1155            "minProperties": 1,
1156            "maxProperties": 10,
1157            "properties": {
1158                "name": {
1159                    "type": "string",
1160                    "title": "Name",
1161                    "default": "",
1162                    "minLength": 1,
1163                    "maxLength": 100,
1164                    "pattern": "^[a-z]+$"
1165                },
1166                "age": {
1167                    "type": "integer",
1168                    "minimum": 0,
1169                    "maximum": 150,
1170                    "multipleOf": 1
1171                },
1172                "tags": {
1173                    "type": "array",
1174                    "items": { "type": "string" },
1175                    "minItems": 1,
1176                    "maxItems": 10,
1177                    "uniqueItems": true,
1178                    "contains": { "type": "string" }
1179                }
1180            }
1181        });
1182        let result = adapter.normalize_schema(schema);
1183
1184        // Top-level annotation/validation keywords removed
1185        assert!(result.get("title").is_none());
1186        assert!(result.get("$id").is_none());
1187        assert!(result.get("default").is_none());
1188        assert!(result.get("deprecated").is_none());
1189        assert!(result.get("readOnly").is_none());
1190        assert!(result.get("writeOnly").is_none());
1191        assert!(result.get("examples").is_none());
1192        assert!(result.get("minProperties").is_none());
1193        assert!(result.get("maxProperties").is_none());
1194
1195        // String property: validation keywords removed, type/description preserved
1196        let name = &result["properties"]["name"];
1197        assert!(name.get("title").is_none());
1198        assert!(name.get("default").is_none());
1199        assert!(name.get("minLength").is_none());
1200        assert!(name.get("maxLength").is_none());
1201        assert!(name.get("pattern").is_none());
1202        assert_eq!(name["type"], "string");
1203
1204        // Integer property: numeric constraints removed
1205        let age = &result["properties"]["age"];
1206        assert!(age.get("minimum").is_none());
1207        assert!(age.get("maximum").is_none());
1208        assert!(age.get("multipleOf").is_none());
1209        assert_eq!(age["type"], "integer");
1210
1211        // Array property: array constraints removed, items preserved
1212        let tags = &result["properties"]["tags"];
1213        assert!(tags.get("minItems").is_none());
1214        assert!(tags.get("maxItems").is_none());
1215        assert!(tags.get("uniqueItems").is_none());
1216        assert!(tags.get("contains").is_none());
1217        assert_eq!(tags["type"], "array");
1218        assert_eq!(tags["items"]["type"], "string");
1219    }
1220
1221    #[test]
1222    fn test_removes_prefix_items() {
1223        let adapter = GeminiSchemaAdapter::new();
1224        let schema = json!({
1225            "type": "array",
1226            "prefixItems": [
1227                { "type": "string" },
1228                { "type": "integer" }
1229            ]
1230        });
1231        let result = adapter.normalize_schema(schema);
1232        assert!(result.get("prefixItems").is_none());
1233    }
1234
1235    #[test]
1236    fn test_removes_dependent_keywords() {
1237        let adapter = GeminiSchemaAdapter::new();
1238        let schema = json!({
1239            "type": "object",
1240            "properties": {
1241                "name": { "type": "string" },
1242                "credit_card": { "type": "string" }
1243            },
1244            "dependentRequired": {
1245                "credit_card": ["billing_address"]
1246            },
1247            "dependentSchemas": {
1248                "credit_card": {
1249                    "properties": {
1250                        "billing_address": { "type": "string" }
1251                    }
1252                }
1253            }
1254        });
1255        let result = adapter.normalize_schema(schema);
1256        assert!(result.get("dependentRequired").is_none());
1257        assert!(result.get("dependentSchemas").is_none());
1258    }
1259
1260    #[test]
1261    fn test_removes_content_keywords() {
1262        let adapter = GeminiSchemaAdapter::new();
1263        let schema = json!({
1264            "type": "string",
1265            "contentMediaType": "application/json",
1266            "contentEncoding": "base64"
1267        });
1268        let result = adapter.normalize_schema(schema);
1269        assert!(result.get("contentMediaType").is_none());
1270        assert!(result.get("contentEncoding").is_none());
1271    }
1272}