Download crates/forge_app/src/utils.rs from SaylorTwift/forgecode: direct link, hf CLI and curl.
- Browser
- Download file 96 kB
-
https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_app/src/utils.rs
- Command line
-
hf download hf://SaylorTwift/forgecode/crates/forge_app/src/utils.rs
-
curl -L -o utils.rs https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_app/src/utils.rs
96 kB
| use std::path::Path; | |
| use crate::{Match, MatchResult}; | |
| /// Formats a path for display, converting absolute paths to relative when | |
| /// possible | |
| /// | |
| /// If the path starts with the current working directory, returns a | |
| /// relative path. Otherwise, returns the original absolute path. | |
| /// | |
| /// # Arguments | |
| /// * `path` - The path to format | |
| /// * `cwd` - The current working directory path | |
| /// | |
| /// # Returns | |
| /// * A formatted path string | |
| pub fn format_display_path(path: &Path, cwd: &Path) -> String { | |
| // Try to create a relative path for display if possible | |
| let display_path = if path.starts_with(cwd) { | |
| match path.strip_prefix(cwd) { | |
| Ok(rel_path) => rel_path.display().to_string(), | |
| Err(_) => path.display().to_string(), | |
| } | |
| } else { | |
| path.display().to_string() | |
| }; | |
| if display_path.is_empty() { | |
| ".".to_string() | |
| } else { | |
| display_path | |
| } | |
| } | |
| /// Truncates a key string for display purposes | |
| /// | |
| /// If the key length is 20 characters or less, returns it unchanged. | |
| /// Otherwise, shows the first 13 characters and last 4 characters with "..." in | |
| /// between. | |
| /// | |
| /// # Arguments | |
| /// * `key` - The key string to truncate | |
| /// | |
| /// # Returns | |
| /// * A truncated version of the key for safe display | |
| pub use forge_domain::truncate_key; | |
| pub fn format_match(matched: &Match, base_dir: &Path) -> String { | |
| match &matched.result { | |
| Some(MatchResult::Error(err)) => format!("Error reading {}: {}", matched.path, err), | |
| Some(MatchResult::Found { line_number, line }) => { | |
| let path = format_display_path(Path::new(&matched.path), base_dir); | |
| match line_number { | |
| Some(num) => format!("{}:{}:{}", path, num, line), | |
| None => format!("{}:{}", path, line), | |
| } | |
| } | |
| Some(MatchResult::Count { count }) => { | |
| format!( | |
| "{}:{}", | |
| format_display_path(Path::new(&matched.path), base_dir), | |
| count | |
| ) | |
| } | |
| Some(MatchResult::FileMatch) => format_display_path(Path::new(&matched.path), base_dir), | |
| Some(MatchResult::ContextMatch { line_number, line, before_context, after_context }) => { | |
| let path = format_display_path(Path::new(&matched.path), base_dir); | |
| let mut output = String::new(); | |
| // Add before context lines | |
| for ctx_line in before_context { | |
| output.push_str(&format!("{}-{}\n", path, ctx_line)); | |
| } | |
| // Add the match line | |
| match line_number { | |
| Some(num) => output.push_str(&format!("{}:{}:{}", path, num, line)), | |
| None => output.push_str(&format!("{}:{}", path, line)), | |
| } | |
| // Add after context lines | |
| for ctx_line in after_context { | |
| output.push_str(&format!("\n{}-{}", path, ctx_line)); | |
| } | |
| output | |
| } | |
| None => format_display_path(Path::new(&matched.path), base_dir), | |
| } | |
| } | |
| /// Computes SHA-256 hash of the given content | |
| /// | |
| /// General-purpose utility function that computes a SHA-256 hash of string | |
| /// content. Returns a consistent hexadecimal representation that can be used | |
| /// for content comparison, caching, or change detection. | |
| /// | |
| /// # Arguments | |
| /// * `content` - The content string to hash | |
| /// | |
| /// # Returns | |
| /// * A hexadecimal string representation of the SHA-256 hash | |
| pub fn compute_hash(content: &str) -> String { | |
| use sha2::{Digest, Sha256}; | |
| let mut hasher = Sha256::new(); | |
| hasher.update(content.as_bytes()); | |
| hex::encode(hasher.finalize()) | |
| } | |
| // Merges strict-mode incompatible `allOf` branches into a single schema object. | |
| fn flatten_all_of_schema(map: &mut serde_json::Map<String, serde_json::Value>) { | |
| let Some(serde_json::Value::Array(all_of)) = map.remove("allOf") else { | |
| return; | |
| }; | |
| for sub_schema in all_of { | |
| let serde_json::Value::Object(source) = sub_schema else { | |
| continue; | |
| }; | |
| merge_schema_object(map, source); | |
| } | |
| } | |
| fn merge_schema_object( | |
| target: &mut serde_json::Map<String, serde_json::Value>, | |
| mut source: serde_json::Map<String, serde_json::Value>, | |
| ) { | |
| flatten_all_of_schema(&mut source); | |
| for (key, value) in source { | |
| match target.get_mut(&key) { | |
| Some(existing) => merge_schema_keyword(existing, value, &key), | |
| None => { | |
| target.insert(key, value); | |
| } | |
| } | |
| } | |
| } | |
| fn merge_schema_keyword(target: &mut serde_json::Value, source: serde_json::Value, key: &str) { | |
| match (key, target, source) { | |
| ( | |
| "properties" | "$defs" | "definitions" | "patternProperties", | |
| serde_json::Value::Object(target_map), | |
| serde_json::Value::Object(source_map), | |
| ) => merge_named_schema_map(target_map, source_map), | |
| ( | |
| "required", | |
| serde_json::Value::Array(target_values), | |
| serde_json::Value::Array(source_values), | |
| ) => merge_required_arrays(target_values, source_values), | |
| ( | |
| "enum", | |
| serde_json::Value::Array(target_values), | |
| serde_json::Value::Array(source_values), | |
| ) => merge_enum_arrays(target_values, source_values), | |
| (_, serde_json::Value::Object(target_map), serde_json::Value::Object(source_map)) => { | |
| merge_schema_object(target_map, source_map); | |
| } | |
| ("description" | "title", _, _) => {} | |
| (_, target_value, source_value) if *target_value == source_value => {} | |
| _ => {} | |
| } | |
| } | |
| fn merge_named_schema_map( | |
| target: &mut serde_json::Map<String, serde_json::Value>, | |
| source: serde_json::Map<String, serde_json::Value>, | |
| ) { | |
| for (key, value) in source { | |
| match target.get_mut(&key) { | |
| Some(existing) => merge_schema_keyword(existing, value, "schema"), | |
| None => { | |
| target.insert(key, value); | |
| } | |
| } | |
| } | |
| } | |
| fn merge_required_arrays(target: &mut Vec<serde_json::Value>, source: Vec<serde_json::Value>) { | |
| for value in source { | |
| if !target.contains(&value) { | |
| target.push(value); | |
| } | |
| } | |
| if target.iter().all(|value| value.as_str().is_some()) { | |
| target.sort_by(|left, right| left.as_str().cmp(&right.as_str())); | |
| } | |
| } | |
| fn merge_enum_arrays(target: &mut Vec<serde_json::Value>, source: Vec<serde_json::Value>) { | |
| target.retain(|value| source.contains(value)); | |
| } | |
| fn normalize_named_schema_keyword( | |
| map: &mut serde_json::Map<String, serde_json::Value>, | |
| key: &str, | |
| strict_mode: bool, | |
| ) { | |
| let Some(serde_json::Value::Object(named_schemas)) = map.get_mut(key) else { | |
| return; | |
| }; | |
| for schema in named_schemas.values_mut() { | |
| enforce_strict_schema(schema, strict_mode); | |
| } | |
| } | |
| fn normalize_schema_keyword( | |
| map: &mut serde_json::Map<String, serde_json::Value>, | |
| key: &str, | |
| strict_mode: bool, | |
| ) { | |
| let Some(schema) = map.get_mut(key) else { | |
| return; | |
| }; | |
| match schema { | |
| serde_json::Value::Object(_) | serde_json::Value::Array(_) => { | |
| enforce_strict_schema(schema, strict_mode); | |
| } | |
| serde_json::Value::Bool(_) => {} | |
| _ => {} | |
| } | |
| } | |
| fn normalize_schema_keywords( | |
| map: &mut serde_json::Map<String, serde_json::Value>, | |
| strict_mode: bool, | |
| ) { | |
| normalize_named_schema_keyword(map, "properties", strict_mode); | |
| for key in ["items", "additionalProperties", "allOf", "anyOf"] { | |
| normalize_schema_keyword(map, key, strict_mode); | |
| } | |
| if !strict_mode { | |
| for key in ["$defs", "definitions", "patternProperties"] { | |
| normalize_named_schema_keyword(map, key, strict_mode); | |
| } | |
| for key in [ | |
| "oneOf", | |
| "prefixItems", | |
| "contains", | |
| "not", | |
| "if", | |
| "then", | |
| "else", | |
| ] { | |
| normalize_schema_keyword(map, key, strict_mode); | |
| } | |
| } | |
| } | |
| fn normalize_openai_schema_subset_keywords(map: &mut serde_json::Map<String, serde_json::Value>) { | |
| for key in [ | |
| "$schema", | |
| "$id", | |
| "$anchor", | |
| "$comment", | |
| "$defs", | |
| "$ref", | |
| "additionalItems", | |
| "contains", | |
| "definitions", | |
| "dependentRequired", | |
| "dependentSchemas", | |
| "deprecated", | |
| "else", | |
| "examples", | |
| "exclusiveMaximum", | |
| "exclusiveMinimum", | |
| "if", | |
| "maxContains", | |
| "maxItems", | |
| "maxLength", | |
| "maxProperties", | |
| "maximum", | |
| "minContains", | |
| "minItems", | |
| "minLength", | |
| "minProperties", | |
| "multipleOf", | |
| "not", | |
| "pattern", | |
| "patternProperties", | |
| "prefixItems", | |
| "propertyNames", | |
| "readOnly", | |
| "then", | |
| "title", | |
| "unevaluatedItems", | |
| "unevaluatedProperties", | |
| "uniqueItems", | |
| "writeOnly", | |
| ] { | |
| map.remove(key); | |
| } | |
| if let Some(const_value) = map.remove("const") | |
| && !map.contains_key("enum") | |
| { | |
| map.insert( | |
| "enum".to_string(), | |
| serde_json::Value::Array(vec![const_value]), | |
| ); | |
| } | |
| } | |
| fn normalize_one_of_keyword( | |
| map: &mut serde_json::Map<String, serde_json::Value>, | |
| strict_mode: bool, | |
| ) { | |
| if !strict_mode { | |
| return; | |
| } | |
| let Some(one_of) = map.remove("oneOf") else { | |
| return; | |
| }; | |
| match map.get_mut("anyOf") { | |
| Some(serde_json::Value::Array(any_of)) => match one_of { | |
| serde_json::Value::Array(mut one_of) => any_of.append(&mut one_of), | |
| value => any_of.push(value), | |
| }, | |
| _ => { | |
| map.insert("anyOf".to_string(), one_of); | |
| } | |
| } | |
| } | |
| fn is_supported_openai_string_format(format: &str) -> bool { | |
| matches!( | |
| format, | |
| "date-time" | |
| | "time" | |
| | "date" | |
| | "duration" | |
| | "email" | |
| | "hostname" | |
| | "ipv4" | |
| | "ipv6" | |
| | "uuid" | |
| ) | |
| } | |
| fn normalize_string_format_keyword( | |
| map: &mut serde_json::Map<String, serde_json::Value>, | |
| strict_mode: bool, | |
| ) { | |
| if !strict_mode { | |
| return; | |
| } | |
| let Some(format) = map.get("format").and_then(|value| value.as_str()) else { | |
| return; | |
| }; | |
| if !is_supported_openai_string_format(format) { | |
| map.remove("format"); | |
| } | |
| } | |
| fn is_object_schema(map: &serde_json::Map<String, serde_json::Value>) -> bool { | |
| map.get("type") | |
| .and_then(|value| value.as_str()) | |
| .is_some_and(|ty| ty == "object") | |
| || map.contains_key("properties") | |
| || map.contains_key("required") | |
| || map.contains_key("additionalProperties") | |
| } | |
| fn is_array_schema(map: &serde_json::Map<String, serde_json::Value>) -> bool { | |
| map.get("type") | |
| .and_then(|value| value.as_str()) | |
| .is_some_and(|ty| ty == "array") | |
| || map.contains_key("items") | |
| } | |
| fn normalize_array_items(map: &mut serde_json::Map<String, serde_json::Value>, strict_mode: bool) { | |
| if strict_mode && is_array_schema(map) && !map.contains_key("items") { | |
| map.insert("items".to_string(), serde_json::json!({ "type": "string" })); | |
| } | |
| } | |
| fn normalize_additional_properties( | |
| map: &mut serde_json::Map<String, serde_json::Value>, | |
| strict_mode: bool, | |
| ) { | |
| match map.get_mut("additionalProperties") { | |
| Some(serde_json::Value::Object(additional_props_map)) => { | |
| let has_combiners = additional_props_map.contains_key("anyOf") | |
| || additional_props_map.contains_key("oneOf") | |
| || additional_props_map.contains_key("allOf"); | |
| if !additional_props_map.contains_key("type") && !has_combiners { | |
| additional_props_map.insert( | |
| "type".to_string(), | |
| serde_json::Value::String("object".to_string()), | |
| ); | |
| } | |
| let mut additional_props = | |
| serde_json::Value::Object(std::mem::take(additional_props_map)); | |
| enforce_strict_schema(&mut additional_props, strict_mode); | |
| map.insert("additionalProperties".to_string(), additional_props); | |
| } | |
| Some(serde_json::Value::Bool(_)) => {} | |
| Some(_) => { | |
| map.insert( | |
| "additionalProperties".to_string(), | |
| serde_json::Value::Bool(false), | |
| ); | |
| } | |
| None => { | |
| map.insert( | |
| "additionalProperties".to_string(), | |
| serde_json::Value::Bool(false), | |
| ); | |
| } | |
| } | |
| } | |
| /// Normalizes a JSON schema to meet LLM provider requirements | |
| /// | |
| /// Many LLM providers (OpenAI, Anthropic) require that all object types in JSON | |
| /// schemas explicitly set `additionalProperties: false`. This function | |
| /// recursively processes the schema to add this requirement. | |
| /// | |
| /// Additionally, for OpenAI compatibility, it ensures: | |
| /// - All objects have a `properties` field (even if empty) | |
| /// - All objects have a `required` array with all property keys | |
| /// - `allOf` branches are merged into a single schema object when strict mode | |
| /// is enabled | |
| /// - unsupported JSON Schema keywords are removed, matching Codex's limited | |
| /// Responses API schema subset, while preserving `default` and `minimum` | |
| /// values | |
| /// - `const` is converted to a single-value `enum` | |
| /// | |
| /// # Arguments | |
| /// * `schema` - The JSON schema to normalize (will be modified in place) | |
| /// * `strict_mode` - If true, adds `properties`, `required`, and `allOf` | |
| /// flattening for OpenAI compatibility | |
| pub fn enforce_strict_schema(schema: &mut serde_json::Value, strict_mode: bool) { | |
| match schema { | |
| serde_json::Value::Object(map) => { | |
| if strict_mode { | |
| flatten_all_of_schema(map); | |
| // Match Codex's Responses API schema subset. Codex parses MCP | |
| // schemas into a typed representation that only serializes the | |
| // supported OpenAI fields; Forge keeps raw JSON schemas, so we | |
| // explicitly remove unsupported validation/meta keywords here. | |
| normalize_openai_schema_subset_keywords(map); | |
| // Convert oneOf to anyOf because the Responses API rejects oneOf | |
| // in tool parameter schemas while accepting equivalent anyOf | |
| // unions. | |
| normalize_one_of_keyword(map, strict_mode); | |
| } | |
| normalize_string_format_keyword(map, strict_mode); | |
| let is_object = is_object_schema(map); | |
| // If this looks like an object schema but has no explicit type, add it | |
| // OpenAI requires all schemas to have a type when they represent objects | |
| if is_object && !map.contains_key("type") { | |
| map.insert( | |
| "type".to_string(), | |
| serde_json::Value::String("object".to_string()), | |
| ); | |
| } | |
| if is_object { | |
| if strict_mode && !map.contains_key("properties") { | |
| map.insert( | |
| "properties".to_string(), | |
| serde_json::Value::Object(serde_json::Map::new()), | |
| ); | |
| } | |
| normalize_additional_properties(map, strict_mode); | |
| if strict_mode { | |
| let required_keys = map | |
| .get("properties") | |
| .and_then(|value| value.as_object()) | |
| .map(|props| { | |
| let mut keys = props.keys().cloned().collect::<Vec<_>>(); | |
| keys.sort(); | |
| keys | |
| }) | |
| .unwrap_or_default(); | |
| let required_values = required_keys | |
| .into_iter() | |
| .map(serde_json::Value::String) | |
| .collect::<Vec<_>>(); | |
| map.insert( | |
| "required".to_string(), | |
| serde_json::Value::Array(required_values), | |
| ); | |
| } | |
| } else if strict_mode | |
| && !map.contains_key("type") | |
| && !map.contains_key("anyOf") | |
| && !map.contains_key("allOf") | |
| { | |
| // In strict mode, OpenAI/Codex requires all property schemas to have a | |
| // 'type' key. External MCP tool schemas may define properties with only a | |
| // description and no type. Default such typeless leaf schemas to "string" | |
| // so the request is not rejected with "schema must have a 'type' key". | |
| map.insert( | |
| "type".to_string(), | |
| serde_json::Value::String("string".to_string()), | |
| ); | |
| } | |
| normalize_array_items(map, strict_mode); | |
| if strict_mode | |
| && map | |
| .get("nullable") | |
| .and_then(|v| v.as_bool()) | |
| .unwrap_or(false) | |
| { | |
| map.remove("nullable"); | |
| if let Some(serde_json::Value::Array(enum_values)) = map.get_mut("enum") { | |
| enum_values.retain(|v| !v.is_null()); | |
| } | |
| let description = map.remove("description"); | |
| let non_null_branch = serde_json::Value::Object(std::mem::take(map)); | |
| let null_branch = serde_json::json!({"type": "null"}); | |
| if let Some(desc) = description { | |
| map.insert("description".to_string(), desc); | |
| } | |
| map.insert( | |
| "anyOf".to_string(), | |
| serde_json::Value::Array(vec![non_null_branch, null_branch]), | |
| ); | |
| } | |
| normalize_schema_keywords(map, strict_mode); | |
| } | |
| serde_json::Value::Array(items) => { | |
| for value in items { | |
| enforce_strict_schema(value, strict_mode); | |
| } | |
| } | |
| _ => {} | |
| } | |
| } | |
| fn normalize_gemini_schema_subset_keywords(map: &mut serde_json::Map<String, serde_json::Value>) { | |
| if let Some(exclusive_minimum) = map.remove("exclusiveMinimum") { | |
| map.entry("minimum".to_string()) | |
| .or_insert(exclusive_minimum); | |
| } | |
| if let Some(exclusive_maximum) = map.remove("exclusiveMaximum") { | |
| map.entry("maximum".to_string()) | |
| .or_insert(exclusive_maximum); | |
| } | |
| for key in [ | |
| "$schema", | |
| "$id", | |
| "$anchor", | |
| "$comment", | |
| "$defs", | |
| "$ref", | |
| "additionalItems", | |
| "additionalProperties", | |
| "definitions", | |
| "deprecated", | |
| "examples", | |
| "propertyNames", | |
| "title", | |
| "unevaluatedItems", | |
| "unevaluatedProperties", | |
| "writeOnly", | |
| "readOnly", | |
| ] { | |
| map.remove(key); | |
| } | |
| } | |
| /// Sanitizes a JSON schema for Google/Gemini API compatibility. | |
| /// | |
| /// The Gemini API uses OpenAPI 3.0-style function declarations rather than raw | |
| /// JSON Schema, and has several restrictions that differ from standard JSON | |
| /// Schema: | |
| /// | |
| /// - **Integer/number enums are rejected**: Gemini requires all enum values to | |
| /// be strings. Integer and number type enums are converted to string enums. | |
| /// - **Arrays require `items`**: Gemini rejects array schemas without an | |
| /// `items` field. A default `{ "type": "string" }` is added if missing, | |
| /// unless the array has a combiner (`anyOf`/`oneOf`/`allOf`). | |
| /// - **Non-object types must not have `properties`/`required`**: Gemini rejects | |
| /// `properties` and `required` fields on non-object schemas (e.g., strings | |
| /// with properties). These are stripped. | |
| /// - **`required` must reference existing `properties`**: Gemini rejects | |
| /// `required` entries that don't have corresponding entries in `properties`. | |
| /// The `required` array is filtered to only include fields present in | |
| /// `properties`. | |
| /// - **Unsupported JSON Schema metadata and references are rejected**: | |
| /// `$schema`, `$defs`, `$ref`, `title`, `additionalProperties`, and | |
| /// `propertyNames` are removed. | |
| /// - **Exclusive bounds are rejected**: `exclusiveMinimum` and | |
| /// `exclusiveMaximum` are converted to `minimum` and `maximum` when the | |
| /// inclusive bound is not already present. | |
| /// - **`const` is rejected**: Converted to single-value `enum` (OpenAPI 3.0 | |
| /// style). | |
| /// - **Nullable types**: `{ "type": ["string", "null"] }` is converted to `{ | |
| /// "type": "string", "nullable": true }` (OpenAPI 3.0 style). | |
| pub fn sanitize_gemini_schema(schema: &mut serde_json::Value) { | |
| match schema { | |
| serde_json::Value::Object(map) => { | |
| normalize_gemini_schema_subset_keywords(map); | |
| // Convert const to enum | |
| if let Some(const_value) = map.remove("const") | |
| && !map.contains_key("enum") | |
| { | |
| map.insert( | |
| "enum".to_string(), | |
| serde_json::Value::Array(vec![const_value]), | |
| ); | |
| } | |
| // Handle type arrays — convert to OpenAPI 3.0 compatible format. | |
| // OpenAPI 3.0 doesn't support type arrays, so we convert them: | |
| // - ["string", "null"] -> type: "string", nullable: true | |
| // - ["string", "number"] -> anyOf: [{type: string}, {type: number}] | |
| // - ["string", "number", "null"] -> anyOf: [{type: string}, {type: number}], | |
| // nullable: true | |
| if map.contains_key("type") && map["type"].is_array() { | |
| let types = map.remove("type").unwrap(); | |
| if let serde_json::Value::Array(type_arr) = types { | |
| let has_null = type_arr.iter().any(|t| t == "null"); | |
| let non_null_types: Vec<serde_json::Value> = | |
| type_arr.into_iter().filter(|t| *t != "null").collect(); | |
| if non_null_types.is_empty() { | |
| // Only null type | |
| map.insert( | |
| "type".to_string(), | |
| serde_json::Value::String("null".to_string()), | |
| ); | |
| } else if non_null_types.len() == 1 { | |
| // Single non-null type: ["string", "null"] -> type: "string", nullable: | |
| // true | |
| map.insert( | |
| "type".to_string(), | |
| non_null_types.into_iter().next().unwrap(), | |
| ); | |
| if has_null { | |
| map.insert("nullable".to_string(), serde_json::Value::Bool(true)); | |
| } | |
| } else { | |
| // Multiple non-null types: convert to anyOf | |
| let any_of_items: Vec<serde_json::Value> = non_null_types | |
| .into_iter() | |
| .map(|t| serde_json::json!({ "type": t })) | |
| .collect(); | |
| map.insert("anyOf".to_string(), serde_json::Value::Array(any_of_items)); | |
| if has_null { | |
| map.insert("nullable".to_string(), serde_json::Value::Bool(true)); | |
| } | |
| } | |
| } | |
| } | |
| // Handle anyOf with null type — elevate null to nullable. | |
| // { anyOf: [{type: string, ...}, {type: null}] } -> { type: string, nullable: | |
| // true, ... } { anyOf: [{type: string, ...}, {type: number, ...}, | |
| // {type: null}] } -> { anyOf: [{type: string}, {type: number}], nullable: true | |
| // } | |
| if let Some(serde_json::Value::Array(any_of)) = map.remove("anyOf") { | |
| let (null_schemas, non_null_schemas): (Vec<_>, Vec<_>) = | |
| any_of.into_iter().partition(|s| { | |
| s.as_object().is_some_and(|o| { | |
| o.len() == 1 && o.get("type").is_some_and(|t| t == "null") | |
| }) | |
| }); | |
| if !null_schemas.is_empty() && non_null_schemas.len() == 1 { | |
| // Single non-null branch with nullable: merge into this schema | |
| let mut merged = non_null_schemas.into_iter().next().unwrap(); | |
| if let serde_json::Value::Object(merged_map) = &mut merged { | |
| // Copy current schema's keys into the merged branch | |
| // (anyOf was already removed, so we copy everything else) | |
| let current_keys: Vec<(String, serde_json::Value)> = | |
| map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); | |
| for (key, value) in current_keys { | |
| merged_map.entry(key).or_insert(value); | |
| } | |
| } | |
| map.insert("nullable".to_string(), serde_json::Value::Bool(true)); | |
| // Put the merged schema back into map | |
| if let serde_json::Value::Object(merged_map) = merged { | |
| for (key, value) in merged_map { | |
| map.insert(key, value); | |
| } | |
| } | |
| } else { | |
| // Either no null schemas, or multiple non-null schemas: | |
| // put anyOf back, possibly with nullable | |
| if !null_schemas.is_empty() { | |
| map.insert("nullable".to_string(), serde_json::Value::Bool(true)); | |
| } | |
| map.insert( | |
| "anyOf".to_string(), | |
| serde_json::Value::Array(non_null_schemas), | |
| ); | |
| } | |
| } | |
| // Convert integer/number enum values to strings (Gemini rejects integer | |
| // enums). Only change the type when there's an enum — a bare integer/number | |
| // type without enum is valid for Gemini. | |
| let has_numeric_type_with_enum = map | |
| .get("type") | |
| .and_then(|v| v.as_str()) | |
| .is_some_and(|t| t == "integer" || t == "number") | |
| && map.contains_key("enum"); | |
| if has_numeric_type_with_enum { | |
| map.insert( | |
| "type".to_string(), | |
| serde_json::Value::String("string".to_string()), | |
| ); | |
| } | |
| // Convert any numeric enum values to strings | |
| if let Some(serde_json::Value::Array(enum_values)) = map.get_mut("enum") { | |
| *enum_values = enum_values | |
| .iter() | |
| .map(|v| match v { | |
| serde_json::Value::Number(n) => serde_json::Value::String(n.to_string()), | |
| other => other.clone(), | |
| }) | |
| .collect(); | |
| } | |
| // Handle array schemas: ensure items field is present | |
| let is_array_type = map | |
| .get("type") | |
| .and_then(|v| v.as_str()) | |
| .is_some_and(|t| t == "array"); | |
| let has_combiner = | |
| map.contains_key("anyOf") || map.contains_key("oneOf") || map.contains_key("allOf"); | |
| if is_array_type && !has_combiner { | |
| match map.get_mut("items") { | |
| None => { | |
| // No items at all — add a default string items schema | |
| map.insert("items".to_string(), serde_json::json!({ "type": "string" })); | |
| } | |
| Some(serde_json::Value::Object(items_map)) => { | |
| // Items exists but may be empty — ensure it has at least a | |
| // type if it has no schema-defining keywords | |
| let has_schema_intent = items_map.contains_key("type") | |
| || items_map.contains_key("$ref") | |
| || items_map.contains_key("enum") | |
| || items_map.contains_key("const") | |
| || items_map.contains_key("anyOf") | |
| || items_map.contains_key("oneOf") | |
| || items_map.contains_key("allOf") | |
| || items_map.contains_key("properties") | |
| || items_map.contains_key("additionalProperties") | |
| || items_map.contains_key("patternProperties") | |
| || items_map.contains_key("required") | |
| || items_map.contains_key("not") | |
| || items_map.contains_key("if") | |
| || items_map.contains_key("then") | |
| || items_map.contains_key("else"); | |
| if !has_schema_intent { | |
| items_map.insert( | |
| "type".to_string(), | |
| serde_json::Value::String("string".to_string()), | |
| ); | |
| } | |
| } | |
| _ => {} // items is an array or other — leave as-is | |
| } | |
| } | |
| // Remove properties/required from non-object types (unless it has a | |
| // combiner, which overrides the type) | |
| let has_explicit_type = map.contains_key("type"); | |
| let type_is_not_object = map | |
| .get("type") | |
| .and_then(|v| v.as_str()) | |
| .is_some_and(|t| t != "object"); | |
| if has_explicit_type && type_is_not_object && !has_combiner { | |
| map.remove("properties"); | |
| map.remove("required"); | |
| } | |
| // Filter required array to only include fields present in properties | |
| let property_keys: Option<Vec<String>> = map | |
| .get("properties") | |
| .and_then(|v| v.as_object()) | |
| .map(|props| props.keys().cloned().collect()); | |
| if let (Some(property_keys), Some(serde_json::Value::Array(required))) = | |
| (property_keys, map.get_mut("required")) | |
| { | |
| required.retain(|v| { | |
| v.as_str() | |
| .is_some_and(|field| property_keys.iter().any(|k| k == field)) | |
| }); | |
| } | |
| // Recursively sanitize all nested schemas | |
| for key in ["properties", "$defs", "definitions", "patternProperties"] { | |
| if let Some(serde_json::Value::Object(named_schemas)) = map.get_mut(key) { | |
| for value in named_schemas.values_mut() { | |
| sanitize_gemini_schema(value); | |
| } | |
| } | |
| } | |
| for key in [ | |
| "items", | |
| "contains", | |
| "not", | |
| "if", | |
| "then", | |
| "else", | |
| "additionalItems", | |
| "unevaluatedProperties", | |
| ] { | |
| if let Some(value) = map.get_mut(key) { | |
| sanitize_gemini_schema(value); | |
| } | |
| } | |
| for key in ["allOf", "anyOf", "oneOf", "prefixItems"] { | |
| if let Some(serde_json::Value::Array(items)) = map.get_mut(key) { | |
| for value in items.iter_mut() { | |
| sanitize_gemini_schema(value); | |
| } | |
| } | |
| } | |
| } | |
| serde_json::Value::Array(items) => { | |
| for value in items.iter_mut() { | |
| sanitize_gemini_schema(value); | |
| } | |
| } | |
| _ => {} | |
| } | |
| } | |
| /// Returns true if the Content-Type header indicates binary (non-text) content. | |
| /// | |
| /// This utility helps detect binary content types commonly returned by HTTP | |
| /// responses. It's useful for tools that handle text content but need to detect | |
| /// and reject binary data. | |
| /// | |
| /// # Arguments | |
| /// * `content_type` - The Content-Type header value (e.g., "text/html", | |
| /// "application/octet-stream") | |
| /// | |
| /// # Examples | |
| /// | |
| /// ``` | |
| /// use forge_app::utils::is_binary_content_type; | |
| /// | |
| /// // Text content types are not binary | |
| /// assert!(!is_binary_content_type("text/html")); | |
| /// assert!(!is_binary_content_type("application/json")); | |
| /// | |
| /// // Binary content types are detected | |
| /// assert!(is_binary_content_type("image/png")); | |
| /// assert!(is_binary_content_type("application/octet-stream")); | |
| /// ``` | |
| pub fn is_binary_content_type(content_type: &str) -> bool { | |
| let ct = content_type.to_lowercase(); | |
| // Allow text/* and common text-based types | |
| if ct.starts_with("text/") | |
| || ct.contains("json") | |
| || ct.contains("xml") | |
| || ct.contains("javascript") | |
| || ct.contains("ecmascript") | |
| || ct.contains("yaml") | |
| || ct.contains("toml") | |
| || ct.contains("csv") | |
| || ct.contains("html") | |
| || ct.contains("svg") | |
| || ct.contains("markdown") | |
| || ct.is_empty() | |
| { | |
| return false; | |
| } | |
| // Everything else (application/gzip, application/octet-stream, image/*, | |
| // audio/*, video/*, etc.) | |
| true | |
| } | |
| mod tests { | |
| use pretty_assertions::assert_eq; | |
| use serde_json::json; | |
| use super::*; | |
| fn test_normalize_json_schema_anthropic_mode() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "name": { "type": "string" } | |
| } | |
| }); | |
| enforce_strict_schema(&mut schema, false); | |
| assert_eq!(schema["additionalProperties"], json!(false)); | |
| // In non-strict mode, required field is not added | |
| assert_eq!(schema.get("required"), None); | |
| } | |
| fn test_normalize_json_schema_openai_strict_mode() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "name": { "type": "string" }, | |
| "age": { "type": "number" } | |
| } | |
| }); | |
| enforce_strict_schema(&mut schema, true); | |
| assert_eq!(schema["additionalProperties"], json!(false)); | |
| assert_eq!(schema["required"], json!(["age", "name"])); | |
| } | |
| fn test_strict_schema_preserves_default_values() { | |
| let mut fixture = json!({ | |
| "type": "object", | |
| "properties": { | |
| "limit": { | |
| "type": "integer", | |
| "description": "Maximum number of records", | |
| "default": 10, | |
| "minimum": 0 | |
| }, | |
| "output_mode": { | |
| "type": "string", | |
| "enum": ["content", "files_with_matches"], | |
| "default": "content" | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut fixture, true); | |
| let actual = fixture; | |
| let expected = json!({ | |
| "type": "object", | |
| "properties": { | |
| "limit": { | |
| "type": "integer", | |
| "description": "Maximum number of records", | |
| "default": 10, | |
| "minimum": 0 | |
| }, | |
| "output_mode": { | |
| "type": "string", | |
| "enum": ["content", "files_with_matches"], | |
| "default": "content" | |
| } | |
| }, | |
| "additionalProperties": false, | |
| "required": ["limit", "output_mode"] | |
| }); | |
| assert_eq!(actual, expected); | |
| } | |
| fn test_typeless_property_gets_string_type_in_strict_mode() { | |
| // MCP tool schemas from external servers (e.g. Affine) may define properties | |
| // with only a description and no type key. The OpenAI/Codex endpoint rejects | |
| // such schemas with "schema must have a 'type' key". This test verifies that | |
| // enforce_strict_schema defaults typeless leaf properties to "string". | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "content": { | |
| "description": "The content of the comment" | |
| }, | |
| "author": { | |
| "description": "The author name" | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut schema, true); | |
| let actual = schema.clone(); | |
| let expected = json!({ | |
| "type": "object", | |
| "properties": { | |
| "content": { | |
| "description": "The content of the comment", | |
| "type": "string" | |
| }, | |
| "author": { | |
| "description": "The author name", | |
| "type": "string" | |
| } | |
| }, | |
| "additionalProperties": false, | |
| "required": ["author", "content"] | |
| }); | |
| assert_eq!(actual, expected); | |
| } | |
| fn test_typeless_property_not_modified_in_non_strict_mode() { | |
| // In non-strict mode, typeless properties should not be modified. | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "content": { | |
| "description": "The content of the comment" | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut schema, false); | |
| // In non-strict mode, no type should be injected | |
| assert_eq!(schema["properties"]["content"]["type"], json!(null)); | |
| assert_eq!( | |
| schema["properties"]["content"]["description"], | |
| json!("The content of the comment") | |
| ); | |
| } | |
| fn test_normalize_json_schema_adds_empty_properties_in_strict_mode() { | |
| let mut schema = json!({ | |
| "type": "object" | |
| }); | |
| enforce_strict_schema(&mut schema, true); | |
| assert_eq!(schema["properties"], json!({})); | |
| assert_eq!(schema["additionalProperties"], json!(false)); | |
| assert_eq!(schema["required"], json!([])); | |
| } | |
| fn test_normalize_json_schema_nested_objects() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "user": { | |
| "type": "object", | |
| "properties": { | |
| "name": { "type": "string" } | |
| } | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut schema, false); | |
| assert_eq!(schema["additionalProperties"], json!(false)); | |
| assert_eq!( | |
| schema["properties"]["user"]["additionalProperties"], | |
| json!(false) | |
| ); | |
| } | |
| fn test_dynamic_properties_schema_is_preserved_in_strict_mode() { | |
| let mut fixture = json!({ | |
| "type": "object", | |
| "properties": { | |
| "pages": { | |
| "type": "array", | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "properties": { | |
| "description": "Dynamic page properties", | |
| "type": "object", | |
| "additionalProperties": { | |
| "anyOf": [ | |
| { "type": "string" }, | |
| { "type": "number" }, | |
| { "type": "null" } | |
| ] | |
| }, | |
| "propertyNames": { | |
| "type": "string" | |
| } | |
| } | |
| }, | |
| "additionalProperties": false | |
| } | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut fixture, true); | |
| let expected = json!({ | |
| "type": "object", | |
| "properties": { | |
| "pages": { | |
| "type": "array", | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "properties": { | |
| "description": "Dynamic page properties", | |
| "type": "object", | |
| "properties": {}, | |
| "additionalProperties": { | |
| "anyOf": [ | |
| { "type": "string" }, | |
| { "type": "number" }, | |
| { "type": "null" } | |
| ] | |
| }, | |
| "required": [] | |
| } | |
| }, | |
| "additionalProperties": false, | |
| "required": ["properties"] | |
| } | |
| } | |
| }, | |
| "additionalProperties": false, | |
| "required": ["pages"] | |
| }); | |
| assert_eq!(fixture, expected); | |
| } | |
| fn test_all_of_is_flattened_in_strict_mode() { | |
| let mut fixture = json!({ | |
| "type": "object", | |
| "properties": { | |
| "rich_text": { | |
| "type": "array", | |
| "items": { | |
| "allOf": [ | |
| { | |
| "type": "object", | |
| "properties": { | |
| "text": { "type": "string" } | |
| } | |
| }, | |
| { | |
| "description": "Rich text item" | |
| } | |
| ] | |
| } | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut fixture, true); | |
| let expected = json!({ | |
| "type": "object", | |
| "properties": { | |
| "rich_text": { | |
| "type": "array", | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "text": { "type": "string" } | |
| }, | |
| "description": "Rich text item", | |
| "additionalProperties": false, | |
| "required": ["text"] | |
| } | |
| } | |
| }, | |
| "additionalProperties": false, | |
| "required": ["rich_text"] | |
| }); | |
| assert_eq!(fixture, expected); | |
| } | |
| fn test_all_of_is_preserved_in_non_strict_mode() { | |
| let mut fixture = json!({ | |
| "type": "object", | |
| "properties": { | |
| "value": { | |
| "allOf": [ | |
| { "type": "string" }, | |
| { "description": "A value" } | |
| ] | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut fixture, false); | |
| let expected = json!({ | |
| "type": "object", | |
| "properties": { | |
| "value": { | |
| "allOf": [ | |
| { "type": "string" }, | |
| { "description": "A value" } | |
| ] | |
| } | |
| }, | |
| "additionalProperties": false | |
| }); | |
| assert_eq!(fixture, expected); | |
| } | |
| fn test_nullable_enum_converted_to_any_of_in_strict_mode() { | |
| // This matches what schemars AddNullable produces: nullable=true AND | |
| // null added to enum values array | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "output_mode": { | |
| "description": "Output mode", | |
| "nullable": true, | |
| "type": "string", | |
| "enum": ["content", "files_with_matches", "count", null] | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut schema, true); | |
| let expected = json!({ | |
| "type": "object", | |
| "properties": { | |
| "output_mode": { | |
| "description": "Output mode", | |
| "anyOf": [ | |
| { "type": "string", "enum": ["content", "files_with_matches", "count"] }, | |
| { "type": "null" } | |
| ] | |
| } | |
| }, | |
| "additionalProperties": false, | |
| "required": ["output_mode"] | |
| }); | |
| assert_eq!(schema, expected); | |
| } | |
| fn test_nullable_string_converted_to_any_of_in_strict_mode() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "name": { | |
| "description": "A name", | |
| "nullable": true, | |
| "type": "string" | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut schema, true); | |
| let expected = json!({ | |
| "type": "object", | |
| "properties": { | |
| "name": { | |
| "description": "A name", | |
| "anyOf": [ | |
| { "type": "string" }, | |
| { "type": "null" } | |
| ] | |
| } | |
| }, | |
| "additionalProperties": false, | |
| "required": ["name"] | |
| }); | |
| assert_eq!(schema, expected); | |
| } | |
| fn test_nullable_not_converted_in_non_strict_mode() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "output_mode": { | |
| "nullable": true, | |
| "type": "string", | |
| "enum": ["content", "files_with_matches", "count"] | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut schema, false); | |
| // In non-strict mode, nullable should be preserved as-is | |
| assert_eq!(schema["properties"]["output_mode"]["nullable"], json!(true)); | |
| assert!(schema["properties"]["output_mode"].get("anyOf").is_none()); | |
| } | |
| fn test_schema_valued_additional_properties_is_normalized() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "metadata": { | |
| "type": "object", | |
| "additionalProperties": { | |
| "type": "object", | |
| "properties": { | |
| "value": { "type": "string" } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut schema, true); | |
| // The additionalProperties schema should have been normalized | |
| // (additionalProperties: false added to nested schema) | |
| assert_eq!( | |
| schema["properties"]["metadata"]["additionalProperties"], | |
| json!({ | |
| "type": "object", | |
| "properties": { | |
| "value": { "type": "string" } | |
| }, | |
| "additionalProperties": false, | |
| "required": ["value"] | |
| }) | |
| ); | |
| } | |
| fn test_notion_mcp_create_comment_schema() { | |
| // Simulates the actual Notion MCP create_comment schema that was failing | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "rich_text": { | |
| "type": "array", | |
| "items": { | |
| "anyOf": [ | |
| { | |
| "type": "object", | |
| "description": "Text content", | |
| "properties": { | |
| "text": { | |
| "type": "object", | |
| "properties": { | |
| "content": { "type": "string" } | |
| } | |
| } | |
| } | |
| }, | |
| { | |
| "type": "object", | |
| "description": "Mention content", | |
| "properties": { | |
| "mention": { | |
| "type": "object", | |
| "properties": { | |
| "user": { | |
| "type": "object", | |
| "properties": { | |
| "id": { "type": "string" } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| ] | |
| } | |
| }, | |
| "page_id": { | |
| "type": "string" | |
| }, | |
| "discussion_id": { | |
| "type": "string" | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut schema, true); | |
| // Verify the schema is now valid for OpenAI | |
| // 1. All objects should have type: "object" | |
| assert_eq!(schema["type"], "object"); | |
| assert_eq!(schema["properties"]["rich_text"]["type"], "array"); | |
| // 2. Check that the anyOf items have proper types and additionalProperties: | |
| // false | |
| let any_of = schema["properties"]["rich_text"]["items"]["anyOf"] | |
| .as_array() | |
| .unwrap(); | |
| for branch in any_of { | |
| assert_eq!(branch["type"], "object"); | |
| assert_eq!(branch["additionalProperties"], false); | |
| // All nested object properties should also have type and additionalProperties | |
| if let Some(props) = branch["properties"].as_object() { | |
| for (_, prop_schema) in props { | |
| if let Some(obj) = prop_schema.as_object() | |
| && obj.contains_key("properties") | |
| { | |
| assert!( | |
| prop_schema["type"] == "object", | |
| "Nested object should have type: object" | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| // 3. Verify additionalProperties: false at root level and for objects | |
| assert_eq!(schema["additionalProperties"], false); | |
| // Note: arrays don't get additionalProperties, only objects do | |
| assert_eq!(schema["properties"]["rich_text"]["type"], "array"); | |
| // 4. Verify required fields are set | |
| let required = schema["required"].as_array().unwrap(); | |
| assert!(required.contains(&json!("rich_text"))); | |
| assert!(required.contains(&json!("page_id"))); | |
| assert!(required.contains(&json!("discussion_id"))); | |
| } | |
| fn test_property_names_is_removed_in_strict_mode() { | |
| // This test ensures we don't regress on propertyNames removal | |
| // propertyNames is a JSON Schema keyword that OpenAI/Codex doesn't support | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "dynamic": { | |
| "type": "object", | |
| "propertyNames": { | |
| "type": "string", | |
| "pattern": "^[a-z]+$" | |
| }, | |
| "additionalProperties": { | |
| "type": "string" | |
| } | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut schema, true); | |
| // propertyNames should be completely removed | |
| assert!( | |
| !schema["properties"]["dynamic"] | |
| .as_object() | |
| .unwrap() | |
| .contains_key("propertyNames"), | |
| "propertyNames must be removed in strict mode for OpenAI/Codex compatibility" | |
| ); | |
| // The rest of the schema should be preserved | |
| assert_eq!(schema["properties"]["dynamic"]["type"], "object"); | |
| assert_eq!( | |
| schema["properties"]["dynamic"]["additionalProperties"]["type"], | |
| "string" | |
| ); | |
| } | |
| fn test_unsupported_format_is_removed_in_strict_mode() { | |
| let mut fixture = json!({ | |
| "type": "object", | |
| "properties": { | |
| "url": { | |
| "type": "string", | |
| "format": "uri" | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut fixture, true); | |
| let expected = json!({ | |
| "type": "object", | |
| "properties": { | |
| "url": { | |
| "type": "string" | |
| } | |
| }, | |
| "additionalProperties": false, | |
| "required": ["url"] | |
| }); | |
| assert_eq!(fixture, expected); | |
| } | |
| fn test_supported_format_is_preserved_in_strict_mode() { | |
| let mut fixture = json!({ | |
| "type": "object", | |
| "properties": { | |
| "timestamp": { | |
| "type": "string", | |
| "format": "date-time" | |
| } | |
| } | |
| }); | |
| enforce_strict_schema(&mut fixture, true); | |
| let expected = json!({ | |
| "type": "object", | |
| "properties": { | |
| "timestamp": { | |
| "type": "string", | |
| "format": "date-time" | |
| } | |
| }, | |
| "additionalProperties": false, | |
| "required": ["timestamp"] | |
| }); | |
| assert_eq!(fixture, expected); | |
| } | |
| /// Integration test that simulates the full Notion MCP workflow: | |
| /// 1. Schema arrives from MCP server (with propertyNames) | |
| /// 2. Gets normalized for OpenAI/Codex (propertyNames removed) | |
| /// 3. Serialized to JSON for API request | |
| fn test_notion_mcp_create_pages_full_schema() { | |
| // This is a realistic subset of the Notion MCP create_pages schema | |
| // that caused the original error | |
| let notion_mcp_schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "pages": { | |
| "type": "array", | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "properties": { | |
| "description": "Dynamic page properties", | |
| "type": "object", | |
| "propertyNames": { | |
| "type": "string" | |
| }, | |
| "additionalProperties": { | |
| "anyOf": [ | |
| { "type": "string" }, | |
| { "type": "number" }, | |
| { "type": "boolean" } | |
| ] | |
| } | |
| } | |
| }, | |
| "required": ["properties"] | |
| } | |
| } | |
| }, | |
| "required": ["pages"] | |
| }); | |
| // Step 1: Convert to Schema (like MCP client does) | |
| let schema_str = serde_json::to_string(¬ion_mcp_schema).unwrap(); | |
| let mut schema: serde_json::Value = serde_json::from_str(&schema_str).unwrap(); | |
| // Step 2: Normalize for OpenAI/Codex strict mode | |
| enforce_strict_schema(&mut schema, true); | |
| // Step 3: Serialize for API request | |
| let api_request_json = serde_json::to_string(&schema).unwrap(); | |
| // Verify: propertyNames should NOT be in the final JSON | |
| assert!( | |
| !api_request_json.contains("propertyNames"), | |
| "Final API request JSON must not contain 'propertyNames'. Schema: {}", | |
| api_request_json | |
| ); | |
| // Verify: Schema structure is preserved | |
| assert_eq!(schema["type"], "object"); | |
| assert_eq!(schema["properties"]["pages"]["type"], "array"); | |
| assert_eq!( | |
| schema["properties"]["pages"]["items"]["properties"]["properties"]["type"], | |
| "object" | |
| ); | |
| // Verify: additionalProperties is normalized | |
| let additional_props = &schema["properties"]["pages"]["items"]["properties"]["properties"] | |
| ["additionalProperties"]; | |
| assert!(additional_props.is_object() || additional_props.is_boolean()); | |
| // Verify: Required fields are set | |
| let required = schema["required"].as_array().unwrap(); | |
| assert!(required.contains(&json!("pages"))); | |
| } | |
| // === sanitize_gemini_schema tests === | |
| fn test_gemini_strips_dollar_schema() { | |
| let mut schema = json!({ | |
| "$schema": "http://json-schema.org/draft-07/schema#", | |
| "type": "object", | |
| "properties": { | |
| "name": { "type": "string" } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| assert!(!schema.as_object().unwrap().contains_key("$schema")); | |
| } | |
| fn test_gemini_removes_additional_properties() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "name": { "type": "string" } | |
| }, | |
| "additionalProperties": false | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| assert!( | |
| !schema | |
| .as_object() | |
| .unwrap() | |
| .contains_key("additionalProperties") | |
| ); | |
| } | |
| fn test_gemini_removes_nested_additional_properties() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "metadata": { | |
| "type": "object", | |
| "additionalProperties": { | |
| "type": "string" | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let metadata = &schema["properties"]["metadata"]; | |
| assert!( | |
| !metadata | |
| .as_object() | |
| .unwrap() | |
| .contains_key("additionalProperties") | |
| ); | |
| } | |
| fn test_gemini_removes_property_names() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "pages": { | |
| "type": "array", | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "metadata": { | |
| "type": "object", | |
| "propertyNames": { | |
| "pattern": "^[a-z]+$" | |
| } | |
| } | |
| } | |
| } | |
| }, | |
| "config": { | |
| "type": "object", | |
| "propertyNames": { | |
| "pattern": "^[a-z]+$" | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let api_request_json = serde_json::to_string(&schema).unwrap(); | |
| assert!(!api_request_json.contains("propertyNames")); | |
| } | |
| fn test_gemini_converts_integer_enum_to_string() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "priority": { | |
| "type": "integer", | |
| "enum": [1, 2, 3] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let priority = &schema["properties"]["priority"]; | |
| assert_eq!(priority["type"], "string"); | |
| assert_eq!(priority["enum"], json!(["1", "2", "3"])); | |
| } | |
| fn test_gemini_converts_number_enum_to_string() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "rate": { | |
| "type": "number", | |
| "enum": [1.5, 2.5, 3.5] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let rate = &schema["properties"]["rate"]; | |
| assert_eq!(rate["type"], "string"); | |
| assert_eq!(rate["enum"], json!(["1.5", "2.5", "3.5"])); | |
| } | |
| fn test_gemini_preserves_string_enum() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "mode": { | |
| "type": "string", | |
| "enum": ["fast", "slow"] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| assert_eq!(schema["properties"]["mode"]["type"], "string"); | |
| assert_eq!( | |
| schema["properties"]["mode"]["enum"], | |
| json!(["fast", "slow"]) | |
| ); | |
| } | |
| fn test_gemini_adds_items_to_array_without_items() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "tags": { | |
| "type": "array" | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| assert_eq!(schema["properties"]["tags"]["items"]["type"], "string"); | |
| } | |
| fn test_gemini_adds_type_to_empty_items() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "items": { | |
| "type": "array", | |
| "items": {} | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // Empty items should get type: "string" | |
| assert_eq!(schema["properties"]["items"]["items"]["type"], "string"); | |
| } | |
| fn test_gemini_preserves_items_with_schema_intent() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "items": { | |
| "type": "array", | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "name": { "type": "string" } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // Items should still have its own type, not replaced with "string" | |
| assert_eq!(schema["properties"]["items"]["items"]["type"], "object"); | |
| } | |
| fn test_gemini_removes_properties_from_non_object_types() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "name": { | |
| "type": "string", | |
| "properties": { | |
| "invalid": { "type": "string" } | |
| }, | |
| "required": ["invalid"] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let name = &schema["properties"]["name"]; | |
| assert!(!name.as_object().unwrap().contains_key("properties")); | |
| assert!(!name.as_object().unwrap().contains_key("required")); | |
| } | |
| fn test_gemini_preserves_properties_on_object_type() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "config": { | |
| "type": "object", | |
| "properties": { | |
| "key": { "type": "string" } | |
| }, | |
| "required": ["key"] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let config = &schema["properties"]["config"]; | |
| assert!(config.as_object().unwrap().contains_key("properties")); | |
| assert!(config.as_object().unwrap().contains_key("required")); | |
| } | |
| fn test_gemini_filters_required_to_existing_properties() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "name": { "type": "string" }, | |
| "age": { "type": "integer" } | |
| }, | |
| "required": ["name", "age", "nonexistent"] | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let required = schema["required"].as_array().unwrap(); | |
| assert!(required.contains(&json!("name"))); | |
| assert!(required.contains(&json!("age"))); | |
| assert!(!required.contains(&json!("nonexistent"))); | |
| } | |
| fn test_gemini_converts_const_to_enum() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "role": { | |
| "const": "admin" | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let role = &schema["properties"]["role"]; | |
| assert!(!role.as_object().unwrap().contains_key("const")); | |
| assert_eq!(role["enum"], json!(["admin"])); | |
| } | |
| fn test_gemini_does_not_override_existing_enum_with_const() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "role": { | |
| "const": "admin", | |
| "enum": ["admin", "user"] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let role = &schema["properties"]["role"]; | |
| // Existing enum should be preserved, const removed | |
| assert!(!role.as_object().unwrap().contains_key("const")); | |
| assert_eq!(role["enum"], json!(["admin", "user"])); | |
| } | |
| fn test_gemini_converts_nullable_type_array() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "name": { | |
| "type": ["string", "null"] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let name = &schema["properties"]["name"]; | |
| assert_eq!(name["type"], "string"); | |
| assert_eq!(name["nullable"], true); | |
| } | |
| fn test_gemini_array_with_anyof_does_not_get_default_items() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "values": { | |
| "type": "array", | |
| "anyOf": [ | |
| { "type": "string" } | |
| ] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // Should NOT add items since it has anyOf | |
| let values = &schema["properties"]["values"]; | |
| assert!(!values.as_object().unwrap().contains_key("items")); | |
| } | |
| fn test_gemini_full_complex_schema() { | |
| let mut schema = json!({ | |
| "$schema": "http://json-schema.org/draft-07/schema#", | |
| "type": "object", | |
| "properties": { | |
| "priority": { | |
| "type": "integer", | |
| "enum": [1, 2, 3] | |
| }, | |
| "tags": { | |
| "type": "array" | |
| }, | |
| "name": { | |
| "type": "string", | |
| "properties": { | |
| "invalid": { "type": "string" } | |
| } | |
| }, | |
| "status": { | |
| "const": "active" | |
| }, | |
| "metadata": { | |
| "type": "object", | |
| "additionalProperties": { | |
| "type": "string" | |
| } | |
| } | |
| }, | |
| "required": ["priority", "tags", "nonexistent_field"], | |
| "additionalProperties": false | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // $schema removed | |
| assert!(!schema.as_object().unwrap().contains_key("$schema")); | |
| // additionalProperties removed at all levels | |
| assert!( | |
| !schema | |
| .as_object() | |
| .unwrap() | |
| .contains_key("additionalProperties") | |
| ); | |
| // metadata's additionalProperties also removed | |
| assert!( | |
| !schema["properties"]["metadata"] | |
| .as_object() | |
| .unwrap() | |
| .contains_key("additionalProperties") | |
| ); | |
| // integer enum converted to string | |
| assert_eq!(schema["properties"]["priority"]["type"], "string"); | |
| assert_eq!( | |
| schema["properties"]["priority"]["enum"], | |
| json!(["1", "2", "3"]) | |
| ); | |
| // array without items gets default items | |
| assert_eq!(schema["properties"]["tags"]["items"]["type"], "string"); | |
| // properties removed from non-object type (string) | |
| assert!( | |
| !schema["properties"]["name"] | |
| .as_object() | |
| .unwrap() | |
| .contains_key("properties") | |
| ); | |
| // const converted to enum | |
| assert!( | |
| !schema["properties"]["status"] | |
| .as_object() | |
| .unwrap() | |
| .contains_key("const") | |
| ); | |
| assert_eq!(schema["properties"]["status"]["enum"], json!(["active"])); | |
| // required filtered to only existing properties | |
| let required = schema["required"].as_array().unwrap(); | |
| assert!(required.contains(&json!("priority"))); | |
| assert!(required.contains(&json!("tags"))); | |
| assert!(!required.contains(&json!("nonexistent_field"))); | |
| } | |
| fn test_gemini_nested_integer_enum_in_array_items() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "items": { | |
| "type": "array", | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "level": { | |
| "type": "integer", | |
| "enum": [1, 2, 3] | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let level = &schema["properties"]["items"]["items"]["properties"]["level"]; | |
| assert_eq!(level["type"], "string"); | |
| assert_eq!(level["enum"], json!(["1", "2", "3"])); | |
| } | |
| fn test_gemini_converts_multi_type_array_with_null() { | |
| // Should become: anyOf: [{type: string}, {type: number}], nullable: true | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "multiTypeField": { | |
| "type": ["string", "number", "null"] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let field = &schema["properties"]["multiTypeField"]; | |
| assert!(field.as_object().unwrap().contains_key("anyOf")); | |
| assert_eq!(field["nullable"], true); | |
| let any_of = field["anyOf"].as_array().unwrap(); | |
| assert_eq!(any_of.len(), 2); | |
| assert_eq!(any_of[0]["type"], "string"); | |
| assert_eq!(any_of[1]["type"], "number"); | |
| } | |
| fn test_gemini_converts_multi_type_array_without_null() { | |
| // Should become: anyOf: [{type: string}, {type: number}] | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "multiTypeField": { | |
| "type": ["string", "number"] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let field = &schema["properties"]["multiTypeField"]; | |
| assert!(field.as_object().unwrap().contains_key("anyOf")); | |
| assert!(!field.as_object().unwrap().contains_key("nullable")); | |
| let any_of = field["anyOf"].as_array().unwrap(); | |
| assert_eq!(any_of.len(), 2); | |
| assert_eq!(any_of[0]["type"], "string"); | |
| assert_eq!(any_of[1]["type"], "number"); | |
| } | |
| fn test_gemini_anyof_null_elevation_single_branch() { | |
| // Should become: type: string, nullable: true, enum: [a,b,c] | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "field": { | |
| "anyOf": [ | |
| { "type": "string", "enum": ["a", "b", "c"] }, | |
| { "type": "null" } | |
| ] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let field = &schema["properties"]["field"]; | |
| assert_eq!(field["type"], "string"); | |
| assert_eq!(field["nullable"], true); | |
| assert_eq!(field["enum"], json!(["a", "b", "c"])); | |
| assert!(!field.as_object().unwrap().contains_key("anyOf")); | |
| } | |
| fn test_gemini_anyof_null_elevation_multiple_branches() { | |
| // Should become: anyOf: [{...non-null...}], nullable: true | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "field": { | |
| "anyOf": [ | |
| { "type": "string" }, | |
| { "type": "number" }, | |
| { "type": "null" } | |
| ] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let field = &schema["properties"]["field"]; | |
| assert!(field.as_object().unwrap().contains_key("anyOf")); | |
| assert_eq!(field["nullable"], true); | |
| let any_of = field["anyOf"].as_array().unwrap(); | |
| assert_eq!(any_of.len(), 2); | |
| assert_eq!(any_of[0]["type"], "string"); | |
| assert_eq!(any_of[1]["type"], "number"); | |
| } | |
| fn test_gemini_deeply_nested_const_in_anyof() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "nested": { | |
| "type": "object", | |
| "properties": { | |
| "deeplyNested": { | |
| "anyOf": [ | |
| { | |
| "type": "object", | |
| "properties": { | |
| "value": { "const": "specific value" } | |
| } | |
| }, | |
| { "type": "string" } | |
| ] | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let deep_value = &schema["properties"]["nested"]["properties"]["deeplyNested"]; | |
| // The anyOf with null should be preserved, and const converted to enum | |
| let first_branch = &deep_value["anyOf"][0]; | |
| assert!(!first_branch.as_object().unwrap().contains_key("const")); | |
| assert_eq!( | |
| first_branch["properties"]["value"]["enum"], | |
| json!(["specific value"]) | |
| ); | |
| } | |
| fn test_gemini_preserves_description_and_format() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "description": "A user object", | |
| "properties": { | |
| "id": { | |
| "type": "number", | |
| "description": "The user ID" | |
| }, | |
| "name": { | |
| "type": "string", | |
| "description": "The user's full name" | |
| }, | |
| "email": { | |
| "type": "string", | |
| "format": "email", | |
| "description": "The user's email address" | |
| } | |
| }, | |
| "required": ["id", "name"] | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| assert_eq!(schema["description"], "A user object"); | |
| assert_eq!(schema["properties"]["id"]["description"], "The user ID"); | |
| assert_eq!( | |
| schema["properties"]["name"]["description"], | |
| "The user's full name" | |
| ); | |
| assert_eq!(schema["properties"]["email"]["format"], "email"); | |
| assert_eq!( | |
| schema["properties"]["email"]["description"], | |
| "The user's email address" | |
| ); | |
| } | |
| fn test_gemini_sanitizes_fetch_schema_exclusive_bounds_from_csv_failure() { | |
| let mut fixture = json!({ | |
| "description": "Parameters for fetching a URL.", | |
| "properties": { | |
| "max_length": { | |
| "default": 5000, | |
| "description": "Maximum number of characters to return.", | |
| "exclusiveMaximum": 1000000, | |
| "exclusiveMinimum": 0, | |
| "title": "Max Length", | |
| "type": "integer" | |
| }, | |
| "start_index": { | |
| "default": 0, | |
| "description": "On return output starting at this character index.", | |
| "minimum": 0, | |
| "title": "Start Index", | |
| "type": "integer" | |
| }, | |
| "url": { | |
| "description": "URL to fetch", | |
| "format": "uri", | |
| "minLength": 1, | |
| "title": "Url", | |
| "type": "string" | |
| } | |
| }, | |
| "required": ["url"], | |
| "title": "Fetch", | |
| "type": "object" | |
| }); | |
| sanitize_gemini_schema(&mut fixture); | |
| let actual = fixture; | |
| let expected = json!({ | |
| "description": "Parameters for fetching a URL.", | |
| "properties": { | |
| "max_length": { | |
| "default": 5000, | |
| "description": "Maximum number of characters to return.", | |
| "maximum": 1000000, | |
| "minimum": 0, | |
| "type": "integer" | |
| }, | |
| "start_index": { | |
| "default": 0, | |
| "description": "On return output starting at this character index.", | |
| "minimum": 0, | |
| "type": "integer" | |
| }, | |
| "url": { | |
| "description": "URL to fetch", | |
| "format": "uri", | |
| "minLength": 1, | |
| "type": "string" | |
| } | |
| }, | |
| "required": ["url"], | |
| "type": "object" | |
| }); | |
| assert_eq!(actual, expected); | |
| } | |
| fn test_gemini_sanitizes_defs_refs_from_notion_schema_csv_failure() { | |
| let mut fixture = json!({ | |
| "$defs": { | |
| "richTextRequest": { | |
| "type": "object", | |
| "additionalProperties": false, | |
| "properties": { | |
| "text": {"type": "string"} | |
| } | |
| } | |
| }, | |
| "type": "object", | |
| "properties": { | |
| "comment": { | |
| "$ref": "#/$defs/richTextRequest" | |
| }, | |
| "children": { | |
| "type": "array", | |
| "items": { | |
| "$ref": "#/$defs/richTextRequest" | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut fixture); | |
| let actual = fixture; | |
| let expected = json!({ | |
| "type": "object", | |
| "properties": { | |
| "comment": {}, | |
| "children": { | |
| "type": "array", | |
| "items": {} | |
| } | |
| } | |
| }); | |
| assert_eq!(actual, expected); | |
| } | |
| fn test_gemini_adds_array_items_for_fibery_where_csv_failure() { | |
| let mut fixture = json!({ | |
| "type": "object", | |
| "properties": { | |
| "q_where": { | |
| "description": "Filter conditions", | |
| "type": "array" | |
| } | |
| }, | |
| "required": ["q_where"] | |
| }); | |
| sanitize_gemini_schema(&mut fixture); | |
| let actual = fixture; | |
| let expected = json!({ | |
| "type": "object", | |
| "properties": { | |
| "q_where": { | |
| "description": "Filter conditions", | |
| "type": "array", | |
| "items": {"type": "string"} | |
| } | |
| }, | |
| "required": ["q_where"] | |
| }); | |
| assert_eq!(actual, expected); | |
| } | |
| fn test_gemini_nested_const_in_anyof_complex() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "name": { "type": "string" }, | |
| "age": { "type": "number" }, | |
| "contact": { | |
| "anyOf": [ | |
| { | |
| "type": "object", | |
| "properties": { | |
| "type": { "type": "string", "const": "email" }, | |
| "value": { "type": "string" } | |
| }, | |
| "required": ["type", "value"], | |
| "additionalProperties": false | |
| }, | |
| { | |
| "type": "object", | |
| "properties": { | |
| "type": { "type": "string", "const": "phone" }, | |
| "value": { "type": "string" } | |
| }, | |
| "required": ["type", "value"], | |
| "additionalProperties": false | |
| } | |
| ] | |
| } | |
| }, | |
| "required": ["name", "age", "contact"], | |
| "additionalProperties": false, | |
| "$schema": "http://json-schema.org/draft-07/schema#" | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // $schema removed | |
| assert!(!schema.as_object().unwrap().contains_key("$schema")); | |
| // Root additionalProperties removed | |
| assert!( | |
| !schema | |
| .as_object() | |
| .unwrap() | |
| .contains_key("additionalProperties") | |
| ); | |
| // const converted to enum inside anyOf | |
| let contact = &schema["properties"]["contact"]; | |
| assert!(contact.as_object().unwrap().contains_key("anyOf")); | |
| // anyOf branch additionalProperties removed | |
| let first_branch = &contact["anyOf"][0]; | |
| assert!( | |
| !first_branch | |
| .as_object() | |
| .unwrap() | |
| .contains_key("additionalProperties") | |
| ); | |
| // const in anyOf branches converted to enum | |
| assert!( | |
| !first_branch["properties"]["type"] | |
| .as_object() | |
| .unwrap() | |
| .contains_key("const") | |
| ); | |
| assert_eq!(first_branch["properties"]["type"]["enum"], json!(["email"])); | |
| } | |
| fn test_gemini_empty_object_preserved_when_nested() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "url": { "type": "string", "description": "URL to navigate to" }, | |
| "launchOptions": { | |
| "type": "object", | |
| "description": "PuppeteerJS LaunchOptions" | |
| }, | |
| "allowDangerous": { | |
| "type": "boolean", | |
| "description": "Allow dangerous options" | |
| } | |
| }, | |
| "required": ["url", "launchOptions"] | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let launch_options = &schema["properties"]["launchOptions"]; | |
| assert_eq!(launch_options["type"], "object"); | |
| assert_eq!(launch_options["description"], "PuppeteerJS LaunchOptions"); | |
| } | |
| fn test_gemini_removes_required_from_non_object_types() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "data": { | |
| "type": "array", | |
| "items": { "type": "string" }, | |
| "required": ["invalid"] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let data = &schema["properties"]["data"]; | |
| assert!(!data.as_object().unwrap().contains_key("required")); | |
| } | |
| fn test_gemini_nested_non_object_removal() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "outer": { | |
| "type": "object", | |
| "properties": { | |
| "inner": { | |
| "type": "number", | |
| "properties": { "bad": { "type": "string" } }, | |
| "required": ["bad"] | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let inner = &schema["properties"]["outer"]["properties"]["inner"]; | |
| assert_eq!(inner["type"], "number"); | |
| assert!(!inner.as_object().unwrap().contains_key("properties")); | |
| assert!(!inner.as_object().unwrap().contains_key("required")); | |
| } | |
| fn test_gemini_2d_array_empty_inner_items() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "values": { | |
| "type": "array", | |
| "items": { | |
| "type": "array", | |
| "items": {} | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // Inner items should get default type: "string" | |
| assert_eq!( | |
| schema["properties"]["values"]["items"]["items"]["type"], | |
| "string" | |
| ); | |
| } | |
| fn test_gemini_2d_array_missing_inner_items() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "data": { | |
| "type": "array", | |
| "items": { | |
| "type": "array" | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // Inner array should get items with default type | |
| assert_eq!( | |
| schema["properties"]["data"]["items"]["items"]["type"], | |
| "string" | |
| ); | |
| } | |
| fn test_gemini_3d_nested_arrays() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "matrix": { | |
| "type": "array", | |
| "items": { | |
| "type": "array", | |
| "items": { | |
| "type": "array" | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // Deepest array should get items with default type | |
| assert_eq!( | |
| schema["properties"]["matrix"]["items"]["items"]["items"]["type"], | |
| "string" | |
| ); | |
| } | |
| fn test_gemini_nested_array_preserves_existing_item_types() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "numbers": { | |
| "type": "array", | |
| "items": { | |
| "type": "array", | |
| "items": { "type": "number" } | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // Should preserve the explicit type | |
| assert_eq!( | |
| schema["properties"]["numbers"]["items"]["items"]["type"], | |
| "number" | |
| ); | |
| } | |
| fn test_gemini_mixed_nested_structures() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "spreadsheetData": { | |
| "type": "object", | |
| "properties": { | |
| "rows": { | |
| "type": "array", | |
| "items": { | |
| "type": "array", | |
| "items": {} | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| assert_eq!( | |
| schema["properties"]["spreadsheetData"]["properties"]["rows"]["items"]["items"]["type"], | |
| "string" | |
| ); | |
| } | |
| fn test_gemini_combiner_nodes_no_sibling_type_or_items() { | |
| // sibling type or items added during sanitize | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "edits": { | |
| "type": "array", | |
| "items": { | |
| "anyOf": [ | |
| { | |
| "type": "object", | |
| "properties": { | |
| "old_string": { "type": "string" }, | |
| "new_string": { "type": "string" } | |
| }, | |
| "required": ["old_string", "new_string"] | |
| }, | |
| { | |
| "type": "object", | |
| "properties": { | |
| "old_string": { "type": "string" }, | |
| "new_string": { "type": "string" }, | |
| "replace_all": { "type": "boolean" } | |
| }, | |
| "required": ["old_string", "new_string"] | |
| } | |
| ] | |
| } | |
| } | |
| }, | |
| "required": ["edits"] | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let edits = &schema["properties"]["edits"]["items"]; | |
| // Items with anyOf should NOT have a type added | |
| assert!(!edits.as_object().unwrap().contains_key("type")); | |
| // The anyOf should still be present | |
| assert!(edits.as_object().unwrap().contains_key("anyOf")); | |
| } | |
| fn test_gemini_combiner_nodes_no_extra_keys() { | |
| // during sanitize beyond what was originally there | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "value": { | |
| "oneOf": [{ "type": "string" }, { "type": "boolean" }] | |
| }, | |
| "meta": { | |
| "allOf": [ | |
| { "type": "object", "properties": { "a": { "type": "string" } } }, | |
| { "type": "object", "properties": { "b": { "type": "string" } } } | |
| ] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let value = &schema["properties"]["value"]; | |
| // oneOf should not have extra type or items added | |
| assert!(!value.as_object().unwrap().contains_key("type")); | |
| assert!(!value.as_object().unwrap().contains_key("items")); | |
| assert!(value.as_object().unwrap().contains_key("oneOf")); | |
| } | |
| fn test_gemini_nested_objects_and_arrays() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "users": { | |
| "type": "array", | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "id": { "type": "number" }, | |
| "name": { "type": "string" } | |
| }, | |
| "additionalProperties": false | |
| } | |
| } | |
| }, | |
| "additionalProperties": false | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // Root additionalProperties removed | |
| assert!( | |
| !schema | |
| .as_object() | |
| .unwrap() | |
| .contains_key("additionalProperties") | |
| ); | |
| // Nested additionalProperties in items removed | |
| let items = &schema["properties"]["users"]["items"]; | |
| assert!( | |
| !items | |
| .as_object() | |
| .unwrap() | |
| .contains_key("additionalProperties") | |
| ); | |
| // But properties should be preserved | |
| assert!(items["properties"]["id"]["type"] == "number"); | |
| assert!(items["properties"]["name"]["type"] == "string"); | |
| } | |
| fn test_gemini_explicit_null_type() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "nullableField": { | |
| "type": ["string", "null"] | |
| }, | |
| "explicitNullField": { | |
| "type": "null" | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // nullableField: ["string", "null"] -> type: "string", nullable: true | |
| assert_eq!(schema["properties"]["nullableField"]["type"], "string"); | |
| assert_eq!(schema["properties"]["nullableField"]["nullable"], true); | |
| // explicitNullField: type "null" should stay as-is | |
| assert_eq!(schema["properties"]["explicitNullField"]["type"], "null"); | |
| } | |
| fn test_gemini_required_filter_on_nested_objects() { | |
| // Test that required filtering works recursively on nested objects | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "outer": { | |
| "type": "object", | |
| "properties": { | |
| "valid": { "type": "string" } | |
| }, | |
| "required": ["valid", "nonexistent"] | |
| } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| let outer = &schema["properties"]["outer"]; | |
| let required = outer["required"].as_array().unwrap(); | |
| assert!(required.contains(&json!("valid"))); | |
| assert!(!required.contains(&json!("nonexistent"))); | |
| } | |
| fn test_gemini_string_enum_preserved() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "kind": { | |
| "type": "string", | |
| "enum": ["text", "code", "image"] | |
| } | |
| }, | |
| "required": ["kind"], | |
| "additionalProperties": false, | |
| "$schema": "http://json-schema.org/draft-07/schema#" | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| // $schema removed, additionalProperties removed | |
| assert!(!schema.as_object().unwrap().contains_key("$schema")); | |
| assert!( | |
| !schema | |
| .as_object() | |
| .unwrap() | |
| .contains_key("additionalProperties") | |
| ); | |
| // String enum preserved | |
| assert_eq!(schema["properties"]["kind"]["type"], "string"); | |
| assert_eq!( | |
| schema["properties"]["kind"]["enum"], | |
| json!(["text", "code", "image"]) | |
| ); | |
| } | |
| fn test_gemini_non_empty_object_preserved() { | |
| let mut schema = json!({ | |
| "type": "object", | |
| "properties": { | |
| "name": { "type": "string" } | |
| } | |
| }); | |
| sanitize_gemini_schema(&mut schema); | |
| assert_eq!(schema["type"], "object"); | |
| assert_eq!(schema["properties"]["name"]["type"], "string"); | |
| } | |
| } | |