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).
58const UNSUPPORTED_KEYWORDS: &[&str] = &[
59    "additionalProperties",
60    "exclusiveMinimum",
61    "exclusiveMaximum",
62    "not",
63    "propertyNames",
64    "patternProperties",
65    "unevaluatedProperties",
66];
67
68/// Keywords that Gemini does not support on the Vertex AI surface.
69/// Unlike the standard surface, Vertex AI requires `additionalProperties: false`
70/// on object schemas rather than removing it.
71const UNSUPPORTED_KEYWORDS_VERTEX: &[&str] = &[
72    "exclusiveMinimum",
73    "exclusiveMaximum",
74    "not",
75    "propertyNames",
76    "patternProperties",
77    "unevaluatedProperties",
78];
79
80/// Schema adapter for the Gemini API surface.
81///
82/// Applies all destructive transforms required by Gemini's function-calling API.
83///
84/// Two variants are supported:
85/// - **Standard** (`GeminiSchemaAdapter::new()`): Removes `additionalProperties` entirely.
86/// - **Vertex AI** (`GeminiSchemaAdapter::vertex_ai()`): Sets `additionalProperties: false`
87///   on object schemas instead of removing it.
88///
89/// # Example
90///
91/// ```rust
92/// use adk_gemini::schema_adapter::GeminiSchemaAdapter;
93/// use adk_core::SchemaAdapter;
94/// use serde_json::json;
95///
96/// let adapter = GeminiSchemaAdapter::new();
97/// let schema = json!({
98///     "anyOf": [
99///         {"type": "null"},
100///         {"type": "string", "minLength": 1}
101///     ]
102/// });
103///
104/// let normalized = adapter.normalize_schema(schema);
105/// assert_eq!(normalized["type"], "string");
106/// assert!(normalized.get("anyOf").is_none());
107/// ```
108#[derive(Debug)]
109pub struct GeminiSchemaAdapter {
110    /// When `true`, targets the Vertex AI surface which requires
111    /// `additionalProperties: false` on object schemas.
112    vertex_ai: bool,
113}
114
115impl GeminiSchemaAdapter {
116    /// Creates a new `GeminiSchemaAdapter` for the standard Gemini API surface.
117    ///
118    /// This variant removes `additionalProperties` from all schema nodes.
119    pub fn new() -> Self {
120        Self { vertex_ai: false }
121    }
122
123    /// Creates a new `GeminiSchemaAdapter` for the Vertex AI surface.
124    ///
125    /// This variant sets `additionalProperties: false` on object schemas
126    /// instead of removing the keyword entirely.
127    pub fn vertex_ai() -> Self {
128        Self { vertex_ai: true }
129    }
130}
131
132impl Default for GeminiSchemaAdapter {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138impl SchemaAdapter for GeminiSchemaAdapter {
139    fn normalize_schema(&self, mut schema: Value) -> Value {
140        // Step 1: Extract definitions and resolve $ref references.
141        // Always resolve refs — even with empty definitions — so that
142        // unresolvable $ref values are replaced with {"type": "object"}.
143        let definitions = extract_definitions(&schema);
144        schema_utils::resolve_refs(&mut schema, &definitions, 0);
145
146        // Step 2: Strip $schema keyword
147        schema_utils::strip_schema_keyword(&mut schema);
148
149        // Step 3: Collapse anyOf/oneOf combiners
150        schema_utils::collapse_combiners(&mut schema);
151
152        // Step 4: Merge allOf sub-schemas
153        schema_utils::merge_all_of(&mut schema);
154
155        // Step 5: Collapse type arrays
156        schema_utils::collapse_type_arrays(&mut schema);
157
158        // Step 6: Strip conditional keywords (if/then/else)
159        schema_utils::strip_conditional_keywords(&mut schema);
160
161        // Step 7: Convert const to single-element enum
162        schema_utils::convert_const_to_enum(&mut schema);
163
164        // Step 8: Strip null from enum arrays
165        schema_utils::strip_null_from_enum(&mut schema);
166
167        // Step 9: Add implicit object type
168        schema_utils::add_implicit_object_type(&mut schema);
169
170        // Step 10: Remove unsupported keywords recursively
171        if self.vertex_ai {
172            remove_unsupported_keywords_vertex(&mut schema);
173        } else {
174            remove_unsupported_keywords(&mut schema);
175        }
176
177        // Step 11: Strip unsupported format values
178        schema_utils::strip_unsupported_formats(&mut schema, GEMINI_ALLOWED_FORMATS);
179
180        // Step 12: Enforce nesting depth (max 5 levels)
181        schema_utils::enforce_nesting_depth(&mut schema, 5, 0);
182
183        // Step 13: Remove definitions/$defs blocks
184        if let Some(obj) = schema.as_object_mut() {
185            obj.remove("definitions");
186            obj.remove("$defs");
187        }
188
189        schema
190    }
191
192    /// Truncates tool names exceeding 64 bytes at a valid UTF-8 character boundary.
193    ///
194    /// Preserves the prefix of the name, truncating from the end.
195    fn normalize_tool_name<'a>(&self, name: &'a str) -> Cow<'a, str> {
196        if name.len() <= 64 {
197            Cow::Borrowed(name)
198        } else {
199            let mut end = 64;
200            while end > 0 && !name.is_char_boundary(end) {
201                end -= 1;
202            }
203            Cow::Owned(name[..end].to_string())
204        }
205    }
206
207    /// Returns the fallback schema for tools with no `parameters_schema`.
208    ///
209    /// Gemini requires `{"type": "object", "properties": {}}` as the minimum
210    /// valid function declaration parameters.
211    fn empty_schema(&self) -> Value {
212        serde_json::json!({"type": "object", "properties": {}})
213    }
214}
215
216/// Extracts and merges `definitions` and `$defs` from the top-level schema
217/// into a single map for reference resolution.
218fn extract_definitions(schema: &Value) -> Map<String, Value> {
219    let mut defs = Map::new();
220
221    if let Some(obj) = schema.as_object() {
222        // Collect from "definitions" (Draft 4-7)
223        if let Some(definitions) = obj.get("definitions").and_then(|v| v.as_object()) {
224            for (key, value) in definitions {
225                defs.insert(key.clone(), value.clone());
226            }
227        }
228
229        // Collect from "$defs" (Draft 2019-09+)
230        if let Some(dollar_defs) = obj.get("$defs").and_then(|v| v.as_object()) {
231            for (key, value) in dollar_defs {
232                defs.insert(key.clone(), value.clone());
233            }
234        }
235    }
236
237    defs
238}
239
240/// Recursively removes unsupported keywords from the schema and all nested sub-schemas.
241///
242/// Removes: `additionalProperties`, `exclusiveMinimum`, `exclusiveMaximum`,
243/// `items` (when type is not "array"), `not`, `propertyNames`, `patternProperties`,
244/// `unevaluatedProperties`.
245fn remove_unsupported_keywords(schema: &mut Value) {
246    let Some(obj) = schema.as_object_mut() else {
247        return;
248    };
249
250    // Remove standard unsupported keywords
251    for keyword in UNSUPPORTED_KEYWORDS {
252        obj.remove(*keyword);
253    }
254
255    // Remove `items` only when the schema type is NOT "array"
256    let is_array_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "array");
257    if !is_array_type {
258        obj.remove("items");
259    }
260
261    // Recurse into properties
262    if let Some(props) = obj.get_mut("properties")
263        && let Some(props_obj) = props.as_object_mut()
264    {
265        for value in props_obj.values_mut() {
266            remove_unsupported_keywords(value);
267        }
268    }
269
270    // Recurse into items (only present if type is "array")
271    if let Some(items) = obj.get_mut("items") {
272        if items.is_object() {
273            remove_unsupported_keywords(items);
274        } else if let Some(arr) = items.as_array_mut() {
275            for item in arr.iter_mut() {
276                remove_unsupported_keywords(item);
277            }
278        }
279    }
280
281    // Recurse into allOf, anyOf, oneOf (may still exist if not collapsed)
282    for keyword in &["allOf", "anyOf", "oneOf"] {
283        if let Some(arr_val) = obj.get_mut(*keyword)
284            && let Some(arr) = arr_val.as_array_mut()
285        {
286            for sub in arr.iter_mut() {
287                remove_unsupported_keywords(sub);
288            }
289        }
290    }
291
292    // Recurse into prefixItems
293    if let Some(prefix_items) = obj.get_mut("prefixItems")
294        && let Some(arr) = prefix_items.as_array_mut()
295    {
296        for item in arr.iter_mut() {
297            remove_unsupported_keywords(item);
298        }
299    }
300}
301
302/// Recursively removes unsupported keywords for the Vertex AI surface.
303///
304/// Unlike the standard surface, Vertex AI requires `additionalProperties: false`
305/// on object schemas. This function:
306/// - Sets `additionalProperties` to `false` on object schemas (instead of removing it)
307/// - Removes all other unsupported keywords the same as the standard surface
308fn remove_unsupported_keywords_vertex(schema: &mut Value) {
309    let Some(obj) = schema.as_object_mut() else {
310        return;
311    };
312
313    // Remove Vertex-specific unsupported keywords (does NOT include additionalProperties)
314    for keyword in UNSUPPORTED_KEYWORDS_VERTEX {
315        obj.remove(*keyword);
316    }
317
318    // For object schemas, set additionalProperties to false
319    let is_object_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "object");
320    if is_object_type {
321        obj.insert("additionalProperties".to_string(), Value::Bool(false));
322    } else {
323        // For non-object schemas, remove additionalProperties if present
324        obj.remove("additionalProperties");
325    }
326
327    // Remove `items` only when the schema type is NOT "array"
328    let is_array_type = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "array");
329    if !is_array_type {
330        obj.remove("items");
331    }
332
333    // Recurse into properties
334    if let Some(props) = obj.get_mut("properties")
335        && let Some(props_obj) = props.as_object_mut()
336    {
337        for value in props_obj.values_mut() {
338            remove_unsupported_keywords_vertex(value);
339        }
340    }
341
342    // Recurse into items (only present if type is "array")
343    if let Some(items) = obj.get_mut("items") {
344        if items.is_object() {
345            remove_unsupported_keywords_vertex(items);
346        } else if let Some(arr) = items.as_array_mut() {
347            for item in arr.iter_mut() {
348                remove_unsupported_keywords_vertex(item);
349            }
350        }
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_vertex(sub);
360            }
361        }
362    }
363
364    // Recurse into prefixItems
365    if let Some(prefix_items) = obj.get_mut("prefixItems")
366        && let Some(arr) = prefix_items.as_array_mut()
367    {
368        for item in arr.iter_mut() {
369            remove_unsupported_keywords_vertex(item);
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use serde_json::json;
378
379    #[test]
380    fn test_strips_schema_keyword() {
381        let adapter = GeminiSchemaAdapter::new();
382        let schema = json!({
383            "$schema": "http://json-schema.org/draft-07/schema#",
384            "type": "object",
385            "properties": { "name": { "type": "string" } }
386        });
387        let result = adapter.normalize_schema(schema);
388        assert!(result.get("$schema").is_none());
389    }
390
391    #[test]
392    fn test_removes_additional_properties() {
393        let adapter = GeminiSchemaAdapter::new();
394        let schema = json!({
395            "type": "object",
396            "properties": { "name": { "type": "string" } },
397            "additionalProperties": true
398        });
399        let result = adapter.normalize_schema(schema);
400        assert!(result.get("additionalProperties").is_none());
401    }
402
403    #[test]
404    fn test_removes_exclusive_min_max() {
405        let adapter = GeminiSchemaAdapter::new();
406        let schema = json!({
407            "type": "number",
408            "exclusiveMinimum": 0,
409            "exclusiveMaximum": 100
410        });
411        let result = adapter.normalize_schema(schema);
412        assert!(result.get("exclusiveMinimum").is_none());
413        assert!(result.get("exclusiveMaximum").is_none());
414    }
415
416    #[test]
417    fn test_removes_items_when_not_array() {
418        let adapter = GeminiSchemaAdapter::new();
419        let schema = json!({
420            "type": "object",
421            "items": { "type": "string" }
422        });
423        let result = adapter.normalize_schema(schema);
424        assert!(result.get("items").is_none());
425    }
426
427    #[test]
428    fn test_preserves_items_when_array() {
429        let adapter = GeminiSchemaAdapter::new();
430        let schema = json!({
431            "type": "array",
432            "items": { "type": "string" }
433        });
434        let result = adapter.normalize_schema(schema);
435        assert!(result.get("items").is_some());
436        assert_eq!(result["items"]["type"], "string");
437    }
438
439    #[test]
440    fn test_removes_not_keyword() {
441        let adapter = GeminiSchemaAdapter::new();
442        let schema = json!({
443            "type": "string",
444            "not": { "enum": ["bad"] }
445        });
446        let result = adapter.normalize_schema(schema);
447        assert!(result.get("not").is_none());
448    }
449
450    #[test]
451    fn test_removes_property_names() {
452        let adapter = GeminiSchemaAdapter::new();
453        let schema = json!({
454            "type": "object",
455            "propertyNames": { "pattern": "^[a-z]+$" }
456        });
457        let result = adapter.normalize_schema(schema);
458        assert!(result.get("propertyNames").is_none());
459    }
460
461    #[test]
462    fn test_removes_pattern_properties() {
463        let adapter = GeminiSchemaAdapter::new();
464        let schema = json!({
465            "type": "object",
466            "patternProperties": { "^S_": { "type": "string" } }
467        });
468        let result = adapter.normalize_schema(schema);
469        assert!(result.get("patternProperties").is_none());
470    }
471
472    #[test]
473    fn test_removes_unevaluated_properties() {
474        let adapter = GeminiSchemaAdapter::new();
475        let schema = json!({
476            "type": "object",
477            "unevaluatedProperties": false
478        });
479        let result = adapter.normalize_schema(schema);
480        assert!(result.get("unevaluatedProperties").is_none());
481    }
482
483    #[test]
484    fn test_collapses_any_of() {
485        let adapter = GeminiSchemaAdapter::new();
486        let schema = json!({
487            "anyOf": [
488                { "type": "null" },
489                { "type": "string", "minLength": 1 }
490            ]
491        });
492        let result = adapter.normalize_schema(schema);
493        assert!(result.get("anyOf").is_none());
494        assert_eq!(result["type"], "string");
495        assert_eq!(result["minLength"], 1);
496    }
497
498    #[test]
499    fn test_collapses_one_of() {
500        let adapter = GeminiSchemaAdapter::new();
501        let schema = json!({
502            "oneOf": [
503                { "type": "null" },
504                { "type": "integer", "minimum": 0 }
505            ]
506        });
507        let result = adapter.normalize_schema(schema);
508        assert!(result.get("oneOf").is_none());
509        assert_eq!(result["type"], "integer");
510    }
511
512    #[test]
513    fn test_merges_all_of() {
514        let adapter = GeminiSchemaAdapter::new();
515        let schema = json!({
516            "allOf": [
517                { "type": "object", "properties": { "a": { "type": "string" } } },
518                { "properties": { "b": { "type": "number" } }, "required": ["b"] }
519            ]
520        });
521        let result = adapter.normalize_schema(schema);
522        assert!(result.get("allOf").is_none());
523        assert_eq!(result["properties"]["a"]["type"], "string");
524        assert_eq!(result["properties"]["b"]["type"], "number");
525        assert_eq!(result["required"], json!(["b"]));
526    }
527
528    #[test]
529    fn test_collapses_type_arrays() {
530        let adapter = GeminiSchemaAdapter::new();
531        let schema = json!({
532            "type": ["string", "null"],
533            "minLength": 1
534        });
535        let result = adapter.normalize_schema(schema);
536        assert_eq!(result["type"], "string");
537    }
538
539    #[test]
540    fn test_strips_conditional_keywords() {
541        let adapter = GeminiSchemaAdapter::new();
542        let schema = json!({
543            "type": "object",
544            "if": { "properties": { "kind": { "const": "a" } } },
545            "then": { "required": ["extra"] },
546            "else": { "required": [] }
547        });
548        let result = adapter.normalize_schema(schema);
549        assert!(result.get("if").is_none());
550        assert!(result.get("then").is_none());
551        assert!(result.get("else").is_none());
552    }
553
554    #[test]
555    fn test_converts_const_to_enum() {
556        let adapter = GeminiSchemaAdapter::new();
557        let schema = json!({
558            "type": "string",
559            "const": "fixed"
560        });
561        let result = adapter.normalize_schema(schema);
562        assert!(result.get("const").is_none());
563        assert_eq!(result["enum"], json!(["fixed"]));
564    }
565
566    #[test]
567    fn test_strips_null_from_enum() {
568        let adapter = GeminiSchemaAdapter::new();
569        let schema = json!({
570            "type": "string",
571            "enum": ["a", null, "b"]
572        });
573        let result = adapter.normalize_schema(schema);
574        assert_eq!(result["enum"], json!(["a", "b"]));
575    }
576
577    #[test]
578    fn test_removes_empty_enum_after_null_strip() {
579        let adapter = GeminiSchemaAdapter::new();
580        let schema = json!({
581            "type": "string",
582            "enum": [null]
583        });
584        let result = adapter.normalize_schema(schema);
585        assert!(result.get("enum").is_none());
586    }
587
588    #[test]
589    fn test_adds_implicit_object_type() {
590        let adapter = GeminiSchemaAdapter::new();
591        let schema = json!({
592            "properties": { "name": { "type": "string" } }
593        });
594        let result = adapter.normalize_schema(schema);
595        assert_eq!(result["type"], "object");
596    }
597
598    #[test]
599    fn test_strips_unsupported_formats() {
600        let adapter = GeminiSchemaAdapter::new();
601        let schema = json!({
602            "type": "object",
603            "properties": {
604                "created": { "type": "string", "format": "date-time" },
605                "hostname": { "type": "string", "format": "hostname" },
606                "id": { "type": "string", "format": "uuid" }
607            }
608        });
609        let result = adapter.normalize_schema(schema);
610        assert_eq!(result["properties"]["created"]["format"], "date-time");
611        assert!(result["properties"]["hostname"].get("format").is_none());
612        assert_eq!(result["properties"]["id"]["format"], "uuid");
613    }
614
615    #[test]
616    fn test_preserves_all_allowed_formats() {
617        let adapter = GeminiSchemaAdapter::new();
618        for format in GEMINI_ALLOWED_FORMATS {
619            let schema = json!({ "type": "string", "format": format });
620            let result = adapter.normalize_schema(schema);
621            assert_eq!(result["format"], *format, "format '{format}' should be preserved");
622        }
623    }
624
625    #[test]
626    fn test_enforces_nesting_depth() {
627        let adapter = GeminiSchemaAdapter::new();
628        // Create a schema nested 7 levels deep
629        let schema = json!({
630            "type": "object",
631            "properties": {
632                "l1": {
633                    "type": "object",
634                    "properties": {
635                        "l2": {
636                            "type": "object",
637                            "properties": {
638                                "l3": {
639                                    "type": "object",
640                                    "properties": {
641                                        "l4": {
642                                            "type": "object",
643                                            "properties": {
644                                                "l5": {
645                                                    "type": "object",
646                                                    "properties": {
647                                                        "l6": { "type": "string" }
648                                                    }
649                                                }
650                                            }
651                                        }
652                                    }
653                                }
654                            }
655                        }
656                    }
657                }
658            }
659        });
660        let result = adapter.normalize_schema(schema);
661        // At depth 5, the schema should be truncated to {"type": "object"}
662        let l5 = &result["properties"]["l1"]["properties"]["l2"]["properties"]["l3"]["properties"]
663            ["l4"]["properties"]["l5"];
664        assert_eq!(l5, &json!({"type": "object"}));
665    }
666
667    #[test]
668    fn test_resolves_refs() {
669        let adapter = GeminiSchemaAdapter::new();
670        let schema = json!({
671            "type": "object",
672            "properties": {
673                "address": { "$ref": "#/definitions/Address" }
674            },
675            "definitions": {
676                "Address": {
677                    "type": "object",
678                    "properties": {
679                        "street": { "type": "string" }
680                    }
681                }
682            }
683        });
684        let result = adapter.normalize_schema(schema);
685        // $ref should be resolved
686        assert!(result["properties"]["address"].get("$ref").is_none());
687        assert_eq!(result["properties"]["address"]["type"], "object");
688        assert_eq!(result["properties"]["address"]["properties"]["street"]["type"], "string");
689        // definitions should be removed
690        assert!(result.get("definitions").is_none());
691    }
692
693    #[test]
694    fn test_resolves_dollar_defs() {
695        let adapter = GeminiSchemaAdapter::new();
696        let schema = json!({
697            "type": "object",
698            "properties": {
699                "item": { "$ref": "#/$defs/Item" }
700            },
701            "$defs": {
702                "Item": {
703                    "type": "object",
704                    "properties": {
705                        "name": { "type": "string" }
706                    }
707                }
708            }
709        });
710        let result = adapter.normalize_schema(schema);
711        assert!(result["properties"]["item"].get("$ref").is_none());
712        assert_eq!(result["properties"]["item"]["type"], "object");
713        assert!(result.get("$defs").is_none());
714    }
715
716    #[test]
717    fn test_unresolvable_ref_becomes_object() {
718        let adapter = GeminiSchemaAdapter::new();
719        let schema = json!({
720            "type": "object",
721            "properties": {
722                "unknown": { "$ref": "#/definitions/DoesNotExist" }
723            }
724        });
725        let result = adapter.normalize_schema(schema);
726        assert_eq!(result["properties"]["unknown"], json!({"type": "object"}));
727    }
728
729    #[test]
730    fn test_circular_ref_breaks() {
731        let adapter = GeminiSchemaAdapter::new();
732        let schema = json!({
733            "type": "object",
734            "properties": {
735                "self_ref": { "$ref": "#/definitions/Node" }
736            },
737            "definitions": {
738                "Node": {
739                    "type": "object",
740                    "properties": {
741                        "child": { "$ref": "#/definitions/Node" }
742                    }
743                }
744            }
745        });
746        let result = adapter.normalize_schema(schema);
747        // Should not panic and should terminate
748        assert_eq!(result["properties"]["self_ref"]["type"], "object");
749        assert!(result.get("definitions").is_none());
750    }
751
752    #[test]
753    fn test_removes_definitions_and_defs() {
754        let adapter = GeminiSchemaAdapter::new();
755        let schema = json!({
756            "type": "object",
757            "definitions": { "Foo": { "type": "string" } },
758            "$defs": { "Bar": { "type": "number" } }
759        });
760        let result = adapter.normalize_schema(schema);
761        assert!(result.get("definitions").is_none());
762        assert!(result.get("$defs").is_none());
763    }
764
765    #[test]
766    fn test_nested_unsupported_keywords_removed() {
767        let adapter = GeminiSchemaAdapter::new();
768        let schema = json!({
769            "type": "object",
770            "properties": {
771                "inner": {
772                    "type": "object",
773                    "additionalProperties": false,
774                    "exclusiveMinimum": 5,
775                    "properties": {
776                        "deep": {
777                            "type": "number",
778                            "exclusiveMaximum": 100
779                        }
780                    }
781                }
782            }
783        });
784        let result = adapter.normalize_schema(schema);
785        let inner = &result["properties"]["inner"];
786        assert!(inner.get("additionalProperties").is_none());
787        assert!(inner.get("exclusiveMinimum").is_none());
788        assert!(inner["properties"]["deep"].get("exclusiveMaximum").is_none());
789    }
790
791    #[test]
792    fn test_full_transform_pipeline() {
793        let adapter = GeminiSchemaAdapter::new();
794        let schema = json!({
795            "$schema": "http://json-schema.org/draft-07/schema#",
796            "definitions": {
797                "Status": { "type": "string", "enum": ["active", null, "inactive"] }
798            },
799            "properties": {
800                "name": { "type": ["string", "null"], "format": "hostname" },
801                "status": { "$ref": "#/definitions/Status" },
802                "config": {
803                    "type": "object",
804                    "additionalProperties": true,
805                    "properties": {
806                        "value": { "const": "fixed" }
807                    }
808                }
809            },
810            "if": { "properties": { "name": { "type": "string" } } },
811            "then": { "required": ["status"] },
812            "additionalProperties": false
813        });
814        let result = adapter.normalize_schema(schema);
815
816        // $schema removed
817        assert!(result.get("$schema").is_none());
818        // definitions removed
819        assert!(result.get("definitions").is_none());
820        // conditional keywords removed
821        assert!(result.get("if").is_none());
822        assert!(result.get("then").is_none());
823        // additionalProperties removed
824        assert!(result.get("additionalProperties").is_none());
825        // type array collapsed
826        assert_eq!(result["properties"]["name"]["type"], "string");
827        // unsupported format stripped
828        assert!(result["properties"]["name"].get("format").is_none());
829        // $ref resolved and null stripped from enum
830        assert_eq!(result["properties"]["status"]["enum"], json!(["active", "inactive"]));
831        // const converted to enum
832        assert_eq!(result["properties"]["config"]["properties"]["value"]["enum"], json!(["fixed"]));
833        // nested additionalProperties removed
834        assert!(result["properties"]["config"].get("additionalProperties").is_none());
835        // implicit type added
836        assert_eq!(result["type"], "object");
837    }
838
839    #[test]
840    fn test_idempotent() {
841        let adapter = GeminiSchemaAdapter::new();
842        let schema = json!({
843            "$schema": "http://json-schema.org/draft-07/schema#",
844            "type": "object",
845            "properties": {
846                "name": { "type": ["string", "null"], "format": "hostname" },
847                "items": { "type": "array", "items": { "type": "string" } }
848            },
849            "additionalProperties": true,
850            "if": { "const": true },
851            "then": { "required": ["name"] }
852        });
853        let first = adapter.normalize_schema(schema);
854        let second = adapter.normalize_schema(first.clone());
855        assert_eq!(first, second);
856    }
857
858    #[test]
859    fn test_empty_schema() {
860        let adapter = GeminiSchemaAdapter::new();
861        let schema = json!({});
862        let result = adapter.normalize_schema(schema);
863        assert_eq!(result, json!({}));
864    }
865
866    #[test]
867    fn test_array_items_nested_cleanup() {
868        let adapter = GeminiSchemaAdapter::new();
869        let schema = json!({
870            "type": "array",
871            "items": {
872                "type": "object",
873                "additionalProperties": true,
874                "properties": {
875                    "id": { "type": "integer", "exclusiveMinimum": 0 }
876                }
877            }
878        });
879        let result = adapter.normalize_schema(schema);
880        assert!(result["items"].get("additionalProperties").is_none());
881        assert!(result["items"]["properties"]["id"].get("exclusiveMinimum").is_none());
882    }
883
884    // --- Task 4.2: Vertex AI surface variant tests ---
885
886    #[test]
887    fn test_vertex_ai_sets_additional_properties_false() {
888        let adapter = GeminiSchemaAdapter::vertex_ai();
889        let schema = json!({
890            "type": "object",
891            "properties": { "name": { "type": "string" } },
892            "additionalProperties": true
893        });
894        let result = adapter.normalize_schema(schema);
895        assert_eq!(result["additionalProperties"], json!(false));
896    }
897
898    #[test]
899    fn test_vertex_ai_sets_additional_properties_false_on_nested_objects() {
900        let adapter = GeminiSchemaAdapter::vertex_ai();
901        let schema = json!({
902            "type": "object",
903            "properties": {
904                "inner": {
905                    "type": "object",
906                    "properties": {
907                        "value": { "type": "string" }
908                    }
909                }
910            }
911        });
912        let result = adapter.normalize_schema(schema);
913        assert_eq!(result["additionalProperties"], json!(false));
914        assert_eq!(result["properties"]["inner"]["additionalProperties"], json!(false));
915    }
916
917    #[test]
918    fn test_vertex_ai_does_not_set_additional_properties_on_non_object() {
919        let adapter = GeminiSchemaAdapter::vertex_ai();
920        let schema = json!({
921            "type": "string",
922            "additionalProperties": true
923        });
924        let result = adapter.normalize_schema(schema);
925        // Non-object schemas should have additionalProperties removed
926        assert!(result.get("additionalProperties").is_none());
927    }
928
929    #[test]
930    fn test_standard_mode_removes_additional_properties() {
931        let adapter = GeminiSchemaAdapter::new();
932        let schema = json!({
933            "type": "object",
934            "properties": { "name": { "type": "string" } },
935            "additionalProperties": true
936        });
937        let result = adapter.normalize_schema(schema);
938        assert!(result.get("additionalProperties").is_none());
939    }
940
941    #[test]
942    fn test_vertex_ai_still_removes_other_unsupported_keywords() {
943        let adapter = GeminiSchemaAdapter::vertex_ai();
944        let schema = json!({
945            "type": "object",
946            "properties": { "x": { "type": "number" } },
947            "exclusiveMinimum": 0,
948            "exclusiveMaximum": 100,
949            "not": { "type": "null" },
950            "propertyNames": { "pattern": "^[a-z]" },
951            "patternProperties": { "^S_": { "type": "string" } },
952            "unevaluatedProperties": false
953        });
954        let result = adapter.normalize_schema(schema);
955        assert!(result.get("exclusiveMinimum").is_none());
956        assert!(result.get("exclusiveMaximum").is_none());
957        assert!(result.get("not").is_none());
958        assert!(result.get("propertyNames").is_none());
959        assert!(result.get("patternProperties").is_none());
960        assert!(result.get("unevaluatedProperties").is_none());
961        // But additionalProperties: false is set
962        assert_eq!(result["additionalProperties"], json!(false));
963    }
964
965    // --- Task 4.3: normalize_tool_name tests ---
966
967    #[test]
968    fn test_normalize_tool_name_short_name_unchanged() {
969        let adapter = GeminiSchemaAdapter::new();
970        let name = "get_weather";
971        let result = adapter.normalize_tool_name(name);
972        assert_eq!(result, "get_weather");
973        assert!(matches!(result, Cow::Borrowed(_)));
974    }
975
976    #[test]
977    fn test_normalize_tool_name_exactly_64_bytes() {
978        let adapter = GeminiSchemaAdapter::new();
979        let name = "a".repeat(64);
980        let result = adapter.normalize_tool_name(&name);
981        assert_eq!(result.len(), 64);
982        assert!(matches!(result, Cow::Borrowed(_)));
983    }
984
985    #[test]
986    fn test_normalize_tool_name_truncates_at_64_bytes() {
987        let adapter = GeminiSchemaAdapter::new();
988        let name = "a".repeat(100);
989        let result = adapter.normalize_tool_name(&name);
990        assert_eq!(result.len(), 64);
991        assert_eq!(result.as_ref(), "a".repeat(64));
992    }
993
994    #[test]
995    fn test_normalize_tool_name_multibyte_boundary() {
996        let adapter = GeminiSchemaAdapter::new();
997        // Each '日' is 3 bytes in UTF-8. 21 chars = 63 bytes.
998        // Adding one more '日' would be 66 bytes, so truncation should stop at 63.
999        let name = "日".repeat(22); // 66 bytes
1000        let result = adapter.normalize_tool_name(&name);
1001        assert!(result.len() <= 64);
1002        // Should be 63 bytes (21 chars × 3 bytes)
1003        assert_eq!(result.len(), 63);
1004        assert_eq!(result.as_ref(), "日".repeat(21));
1005        // Verify it's valid UTF-8
1006        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
1007    }
1008
1009    #[test]
1010    fn test_normalize_tool_name_emoji_boundary() {
1011        let adapter = GeminiSchemaAdapter::new();
1012        // '🎯' is 4 bytes. 16 emojis = 64 bytes exactly.
1013        let name = "🎯".repeat(16);
1014        assert_eq!(name.len(), 64);
1015        let result = adapter.normalize_tool_name(&name);
1016        assert_eq!(result.len(), 64);
1017
1018        // 17 emojis = 68 bytes, should truncate to 16 emojis = 64 bytes
1019        let name = "🎯".repeat(17);
1020        let result = adapter.normalize_tool_name(&name);
1021        assert_eq!(result.len(), 64);
1022        assert_eq!(result.as_ref(), "🎯".repeat(16));
1023    }
1024
1025    // --- Task 4.4: empty_schema tests ---
1026
1027    #[test]
1028    fn test_empty_schema_returns_object_with_properties() {
1029        let adapter = GeminiSchemaAdapter::new();
1030        let result = adapter.empty_schema();
1031        assert_eq!(result, json!({"type": "object", "properties": {}}));
1032    }
1033
1034    #[test]
1035    fn test_empty_schema_vertex_ai_same_as_standard() {
1036        let adapter = GeminiSchemaAdapter::vertex_ai();
1037        let result = adapter.empty_schema();
1038        assert_eq!(result, json!({"type": "object", "properties": {}}));
1039    }
1040}