oxicode_ai/dialect/
coercion.rs1use crate::tools::Tool;
10use serde_json::Value as JsonValue;
11use std::collections::{HashMap, HashSet};
12
13#[derive(Debug, Clone, Default)]
15pub struct ToolArgShape {
16 pub string_args: HashSet<String>,
18 pub properties: HashMap<String, JsonValue>,
20 pub parameter_order: Vec<String>,
22}
23
24pub fn build_arg_shapes(tools: &[Tool]) -> HashMap<String, ToolArgShape> {
26 let mut shapes = HashMap::new();
27 for tool in tools {
28 let props = resolve_properties(&tool.parameters);
29 let mut string_args = HashSet::new();
30 let mut parameter_order = Vec::new();
31 if let Some(obj) = props {
35 for (key, schema) in obj {
36 parameter_order.push(key.clone());
37 if is_string_only_schema(&schema) {
38 string_args.insert(key);
39 }
40 }
41 }
42 shapes.insert(
43 tool.name.clone(),
44 ToolArgShape {
45 string_args,
46 properties: HashMap::new(),
47 parameter_order,
48 },
49 );
50 }
51 shapes
52}
53
54fn resolve_properties(parameters: &JsonValue) -> Option<serde_json::Map<String, JsonValue>> {
56 let obj = parameters.as_object()?;
57 obj.get("properties")?.as_object().cloned()
58}
59
60pub fn is_string_only_schema(schema: &JsonValue) -> bool {
65 let mut types = collect_schema_types(schema, 0);
66 types.remove("null");
67 types.len() == 1 && types.contains("string")
68}
69
70fn collect_schema_types(schema: &JsonValue, depth: usize) -> HashSet<String> {
72 let mut out = HashSet::new();
73 if depth > 8 {
74 return out;
75 }
76 let Some(node) = schema.as_object() else {
77 return out;
78 };
79
80 match node.get("type") {
81 Some(JsonValue::String(t)) => {
82 out.insert(t.clone());
83 }
84 Some(JsonValue::Array(arr)) => {
85 for t in arr {
86 if let JsonValue::String(s) = t {
87 out.insert(s.clone());
88 }
89 }
90 }
91 _ => {}
92 }
93
94 if !node.contains_key("type") {
96 if let Some(JsonValue::Array(en)) = node.get("enum") {
97 for v in en {
98 out.insert(json_type_of(v).to_string());
99 }
100 }
101 if let Some(c) = node.get("const") {
102 out.insert(json_type_of(c).to_string());
103 }
104 }
105
106 for key in ["anyOf", "oneOf", "allOf"] {
107 if let Some(JsonValue::Array(branches)) = node.get(key) {
108 for sub in branches {
109 out.extend(collect_schema_types(sub, depth + 1));
110 }
111 }
112 }
113
114 out
115}
116
117fn json_type_of(value: &JsonValue) -> &'static str {
119 match value {
120 JsonValue::Null => "null",
121 JsonValue::Bool(_) => "boolean",
122 JsonValue::Number(_) => "number",
123 JsonValue::String(_) => "string",
124 JsonValue::Array(_) | JsonValue::Object(_) => "object",
125 }
126}
127
128pub fn decode_value(raw: &str) -> JsonValue {
132 let trimmed = raw.trim();
133 if trimmed.is_empty() {
134 return JsonValue::String(trimmed.to_string());
135 }
136 match serde_json::from_str::<JsonValue>(trimmed) {
137 Ok(v) => v,
138 Err(_) => JsonValue::String(raw.to_string()),
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use serde_json::json;
146
147 fn tool(name: &str, params: JsonValue) -> Tool {
148 Tool {
149 name: name.to_string(),
150 description: String::new(),
151 parameters: params,
152 }
153 }
154
155 #[test]
156 fn detects_string_only_args() {
157 let t = tool(
158 "write",
159 json!({
160 "type": "object",
161 "properties": {
162 "path": {"type": "string"},
163 "content": {"type": "string"},
164 "lines": {"type": "integer"},
165 "force": {"type": ["boolean", "null"]},
166 }
167 }),
168 );
169 let shapes = build_arg_shapes(&[t]);
170 let shape = &shapes["write"];
171 assert!(shape.string_args.contains("path"));
172 assert!(shape.string_args.contains("content"));
173 assert!(!shape.string_args.contains("lines"));
174 assert!(!shape.string_args.contains("force"));
176 }
177
178 #[test]
179 fn nullable_string_is_string_only() {
180 assert!(is_string_only_schema(&json!({"type": ["string", "null"]})));
181 assert!(!is_string_only_schema(
182 &json!({"type": ["string", "number"]})
183 ));
184 }
185
186 #[test]
187 fn enum_infers_types() {
188 assert!(is_string_only_schema(&json!({"enum": ["a", "b"]})));
189 assert!(!is_string_only_schema(&json!({"enum": [1, 2]})));
190 }
191
192 #[test]
193 fn anyof_union_collects_types() {
194 let schema = json!({"anyOf": [{"type": "string"}, {"type": "null"}]});
195 assert!(is_string_only_schema(&schema));
196 }
197
198 #[test]
199 fn decode_value_prefers_json() {
200 assert_eq!(decode_value("42"), json!(42));
201 assert_eq!(decode_value("true"), json!(true));
202 assert_eq!(decode_value(r#""hi""#), json!("hi"));
203 assert_eq!(decode_value("hello world"), json!("hello world"));
205 assert_eq!(decode_value(" "), json!(""));
206 }
207}