1use serde_json::{json, Value};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ParamType {
13 String,
14 Integer,
15 Number,
16 Boolean,
17 Array,
18 Object,
19}
20
21impl ParamType {
22 pub fn json_name(&self) -> &'static str {
23 match self {
24 ParamType::String => "string",
25 ParamType::Integer => "integer",
26 ParamType::Number => "number",
27 ParamType::Boolean => "boolean",
28 ParamType::Array => "array",
29 ParamType::Object => "object",
30 }
31 }
32
33 pub fn matches(&self, v: &Value) -> bool {
35 match self {
36 ParamType::String => v.is_string(),
37 ParamType::Integer => v.is_i64() || v.is_u64(),
38 ParamType::Number => v.is_number(),
39 ParamType::Boolean => v.is_boolean(),
40 ParamType::Array => v.is_array(),
41 ParamType::Object => v.is_object(),
42 }
43 }
44
45 pub fn coerce(&self, v: &Value) -> Value {
49 match (self, v) {
50 (ParamType::Integer, Value::String(s)) => s
51 .trim()
52 .parse::<i64>()
53 .map(|n| json!(n))
54 .unwrap_or_else(|_| v.clone()),
55 (ParamType::Number, Value::String(s)) => {
56 if let Ok(n) = s.trim().parse::<i64>() {
57 json!(n)
58 } else if let Ok(f) = s.trim().parse::<f64>() {
59 json!(f)
60 } else {
61 v.clone()
62 }
63 }
64 (ParamType::Boolean, Value::String(s)) => {
65 match s.trim().to_ascii_lowercase().as_str() {
66 "true" | "1" | "yes" => json!(true),
67 "false" | "0" | "no" => json!(false),
68 _ => v.clone(),
69 }
70 }
71 _ => v.clone(),
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct ToolParameter {
79 pub name: &'static str,
80 pub ty: ParamType,
81 pub required: bool,
82 pub description: &'static str,
83 pub aliases: &'static [&'static str],
85}
86
87impl ToolParameter {
88 pub const fn new(
89 name: &'static str,
90 ty: ParamType,
91 required: bool,
92 description: &'static str,
93 aliases: &'static [&'static str],
94 ) -> Self {
95 ToolParameter {
96 name,
97 ty,
98 required,
99 description,
100 aliases,
101 }
102 }
103}
104
105#[derive(Debug, Clone)]
107pub struct ToolSchema {
108 pub name: &'static str,
109 pub description: &'static str,
110 pub parameters: Vec<ToolParameter>,
111}
112
113impl ToolSchema {
114 pub fn new(
115 name: &'static str,
116 description: &'static str,
117 parameters: Vec<ToolParameter>,
118 ) -> Self {
119 ToolSchema {
120 name,
121 description,
122 parameters,
123 }
124 }
125
126 fn json_schema(&self) -> (Value, Vec<Value>) {
128 let mut props = serde_json::Map::new();
129 let mut required = Vec::new();
130 for p in &self.parameters {
131 props.insert(
132 p.name.to_string(),
133 json!({"type": p.ty.json_name(), "description": p.description}),
134 );
135 if p.required {
136 required.push(Value::String(p.name.to_string()));
137 }
138 }
139 (Value::Object(props), required)
140 }
141
142 pub fn to_openai_tool(&self) -> Value {
144 let (props, required) = self.json_schema();
145 json!({
146 "type": "function",
147 "function": {
148 "name": self.name,
149 "description": self.description,
150 "parameters": {
151 "type": "object",
152 "properties": props,
153 "required": required,
154 }
155 }
156 })
157 }
158
159 pub fn to_anthropic_tool(&self) -> Value {
161 let (props, required) = self.json_schema();
162 json!({
163 "name": self.name,
164 "description": self.description,
165 "input_schema": {
166 "type": "object",
167 "properties": props,
168 "required": required,
169 }
170 })
171 }
172
173 pub fn normalize_params(&self, input: &Value) -> Value {
175 let obj = match input.as_object() {
176 Some(o) => o,
177 None => return input.clone(),
178 };
179 let mut alias_map: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
181 for p in &self.parameters {
182 alias_map.insert(p.name, p.name);
183 for a in p.aliases {
184 alias_map.insert(a, p.name);
185 }
186 }
187 let mut out = serde_json::Map::new();
188 for (k, v) in obj {
189 let canonical = alias_map.get(k.as_str()).copied().unwrap_or(k.as_str());
190 let coerced = self
192 .parameters
193 .iter()
194 .find(|p| p.name == canonical)
195 .map(|p| p.ty.coerce(v))
196 .unwrap_or_else(|| v.clone());
197 out.insert(canonical.to_string(), coerced);
198 }
199 Value::Object(out)
200 }
201
202 pub fn validate(&self, normalized: &Value) -> std::result::Result<(), String> {
205 let obj = normalized.as_object();
206 let mut missing = Vec::new();
208 for p in &self.parameters {
209 if p.required {
210 let present = obj.map(|o| o.contains_key(p.name)).unwrap_or(false);
211 if !present {
212 missing.push(p.name);
213 }
214 }
215 }
216 if !missing.is_empty() {
217 let details: Vec<String> = missing
218 .iter()
219 .map(|m| {
220 let p = self.parameters.iter().find(|p| &p.name == m).unwrap();
221 format!(" ⢠{} ({}): {}", p.name, p.ty.json_name(), p.description)
222 })
223 .collect();
224 return Err(format!(
225 "ā Missing required parameters: {}\n\nš Parameter details:\n{}",
226 missing.join(", "),
227 details.join("\n")
228 ));
229 }
230 if let Some(o) = obj {
232 for p in &self.parameters {
233 if let Some(v) = o.get(p.name) {
234 if !v.is_null() && !p.ty.matches(v) {
235 return Err(format!(
236 "Parameter '{}' expected type {}, got a different type",
237 p.name,
238 p.ty.json_name()
239 ));
240 }
241 }
242 }
243 }
244 Ok(())
245 }
246
247 pub fn documentation(&self) -> String {
249 let mut doc = format!(
250 "## {}\n\n{}\n\n### Parameters\n",
251 self.name, self.description
252 );
253 for p in &self.parameters {
254 let req = if p.required { "required" } else { "optional" };
255 let aliases = if p.aliases.is_empty() {
256 String::new()
257 } else {
258 format!(" (aliases: {})", p.aliases.join(", "))
259 };
260 doc.push_str(&format!(
261 "- {}{} ({}, {}): {}\n",
262 p.name,
263 aliases,
264 p.ty.json_name(),
265 req,
266 p.description
267 ));
268 }
269 doc
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn sample() -> ToolSchema {
278 ToolSchema::new(
279 "run_shell",
280 "run a shell command",
281 vec![
282 ToolParameter::new(
283 "command",
284 ParamType::String,
285 true,
286 "the command",
287 &["cmd", "shell", "script", "exec"],
288 ),
289 ToolParameter::new(
290 "timeout",
291 ParamType::Integer,
292 false,
293 "seconds",
294 &["time_limit"],
295 ),
296 ],
297 )
298 }
299
300 #[test]
301 fn openai_tool_shape() {
302 let t = sample().to_openai_tool();
303 assert_eq!(t["type"], "function");
304 assert_eq!(t["function"]["name"], "run_shell");
305 assert_eq!(
306 t["function"]["parameters"]["properties"]["command"]["type"],
307 "string"
308 );
309 assert_eq!(t["function"]["parameters"]["required"][0], "command");
310 }
311
312 #[test]
313 fn anthropic_tool_shape() {
314 let t = sample().to_anthropic_tool();
315 assert_eq!(t["name"], "run_shell");
316 assert_eq!(
317 t["input_schema"]["properties"]["timeout"]["type"],
318 "integer"
319 );
320 }
321
322 #[test]
323 fn alias_normalization() {
324 let s = sample();
325 let n = s.normalize_params(&json!({"cmd": "ls", "time_limit": 5}));
326 assert_eq!(n["command"], "ls");
327 assert_eq!(n["timeout"], 5);
328 }
329
330 #[test]
331 fn validate_missing_required() {
332 let s = sample();
333 let n = s.normalize_params(&json!({"timeout": 5}));
334 let err = s.validate(&n).unwrap_err();
335 assert!(err.contains("command"));
336 }
337
338 #[test]
339 fn validate_type_error() {
340 let s = sample();
341 let n = s.normalize_params(&json!({"command": 123}));
342 assert!(s.validate(&n).is_err());
343 }
344
345 #[test]
346 fn validate_ok() {
347 let s = sample();
348 let n = s.normalize_params(&json!({"command": "ls", "timeout": 5}));
349 assert!(s.validate(&n).is_ok());
350 }
351
352 #[test]
353 fn normalize_coerces_string_scalars_to_declared_types() {
354 let s = ToolSchema::new(
355 "t",
356 "d",
357 vec![
358 ToolParameter::new("n", ParamType::Integer, false, "", &[]),
359 ToolParameter::new("f", ParamType::Number, false, "", &[]),
360 ToolParameter::new("b", ParamType::Boolean, false, "", &[]),
361 ],
362 );
363 let out = s.normalize_params(&json!({"n": "3000", "f": "1.5", "b": "true"}));
364 assert_eq!(out["n"], json!(3000));
365 assert_eq!(out["f"], json!(1.5));
366 assert_eq!(out["b"], json!(true));
367 assert!(s.validate(&out).is_ok(), "coerced params must validate");
368 let out = s.normalize_params(&json!({"n": "abc"}));
370 assert_eq!(out["n"], json!("abc"));
371 assert!(s.validate(&out).is_err());
372 }
373
374 #[test]
375 fn coerce_handles_whitespace_and_word_booleans() {
376 assert_eq!(ParamType::Integer.coerce(&json!(" 42 ")), json!(42));
377 assert_eq!(ParamType::Number.coerce(&json!("42")), json!(42));
378 assert_eq!(ParamType::Number.coerce(&json!("2.5")), json!(2.5));
379 assert_eq!(ParamType::Boolean.coerce(&json!(" YES ")), json!(true));
380 assert_eq!(ParamType::Boolean.coerce(&json!("no")), json!(false));
381 assert_eq!(ParamType::Boolean.coerce(&json!("1")), json!(true));
382 assert_eq!(ParamType::Boolean.coerce(&json!("maybe")), json!("maybe"));
384 assert_eq!(ParamType::Integer.coerce(&json!(3.5)), json!(3.5));
385 }
386
387 #[test]
388 fn normalize_passthrough_for_non_objects() {
389 let s = sample();
390 assert_eq!(s.normalize_params(&json!([1, 2])), json!([1, 2]));
391 assert_eq!(s.normalize_params(&json!("x")), json!("x"));
392 assert_eq!(s.normalize_params(&json!(null)), json!(null));
393 }
394
395 #[test]
396 fn validate_null_optional_is_ok() {
397 let s = sample();
398 let n = s.normalize_params(&json!({"command": "ls", "timeout": null}));
399 assert!(s.validate(&n).is_ok());
400 }
401
402 #[test]
403 fn documentation_lists_params_and_aliases() {
404 let doc = sample().documentation();
405 assert!(doc.contains("run_shell"));
406 assert!(doc.contains("command"));
407 assert!(doc.contains("aliases: cmd"));
408 assert!(doc.contains("required"));
409 }
410}