1use std::collections::HashSet;
4
5use async_trait::async_trait;
6
7use kaish_types::{ExecResult, ParamSchema, ToolArgs, ToolSchema, Value};
8
9use crate::ctx::ToolCtx;
10use crate::issue::{IssueCode, Severity, ValidationIssue};
11
12#[async_trait]
19pub trait Tool: Send + Sync {
20 fn name(&self) -> &str;
22
23 fn schema(&self) -> ToolSchema;
25
26 async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult;
28
29 fn validate(&self, args: &ToolArgs) -> Vec<ValidationIssue> {
34 validate_against_schema(args, &self.schema())
35 }
36}
37
38pub fn validate_against_schema(args: &ToolArgs, schema: &ToolSchema) -> Vec<ValidationIssue> {
51 if args.words.is_some() {
56 return Vec::new();
57 }
58
59 let mut issues = Vec::new();
60
61 let positional_params: Vec<&ParamSchema> = schema.params.iter().filter(|p| p.positional).collect();
62 let flag_params: Vec<&ParamSchema> = schema.params.iter().filter(|p| !p.positional).collect();
63
64 for (slot, param) in positional_params.iter().enumerate() {
66 if !param.required {
67 continue;
68 }
69 let has_positional = args.positional.len() > slot;
70 let has_named = args.named.contains_key(¶m.name);
73 if !has_positional && !has_named {
74 let code = IssueCode::MissingRequiredArg;
75 issues.push(ValidationIssue {
76 severity: code.default_severity(),
77 code,
78 message: format!("required parameter '{}' not provided", param.name),
79 span: None,
80 suggestion: Some(format!("add {} or {}=<value>", param.name, param.name)),
81 });
82 }
83 }
84
85 for param in &flag_params {
87 if !param.required {
88 continue;
89 }
90 let has_named = args.named.contains_key(¶m.name);
91 let has_flag = param.param_type == "bool" && args.has_flag(¶m.name);
92 if !has_named && !has_flag {
93 let code = IssueCode::MissingRequiredArg;
94 issues.push(ValidationIssue {
95 severity: code.default_severity(),
96 code,
97 message: format!("required parameter '{}' not provided", param.name),
98 span: None,
99 suggestion: Some(format!("add --{} <value>", param.name)),
100 });
101 }
102 }
103
104 let known_flags: HashSet<&str> = flag_params
108 .iter()
109 .filter(|p| p.param_type == "bool")
110 .flat_map(|p| {
111 std::iter::once(p.name.as_str())
112 .chain(p.aliases.iter().map(|a| a.as_str()))
113 })
114 .collect();
115
116 for flag in &args.flags {
117 let flag_name = flag.trim_start_matches('-');
119 if is_global_output_flag(flag_name) {
121 continue;
122 }
123 if !known_flags.contains(flag_name) && !known_flags.contains(flag.as_str()) {
124 let matches_alias = flag_params.iter().any(|p| p.matches_flag(flag));
126 if !matches_alias {
127 issues.push(ValidationIssue {
128 severity: Severity::Warning,
129 code: IssueCode::UnknownFlag,
130 message: format!("unknown flag '{}'", flag),
131 span: None,
132 suggestion: None,
133 });
134 }
135 }
136 }
137
138 for (key, value) in &args.named {
141 if let Some(param) = schema.params.iter().find(|p| &p.name == key)
142 && let Some(issue) = check_type_compatibility(key, value, ¶m.param_type) {
143 issues.push(issue);
144 }
145 }
146
147 for (slot, value) in args.positional.iter().enumerate() {
151 if let Some(param) = positional_params.get(slot)
152 && let Some(issue) = check_type_compatibility(¶m.name, value, ¶m.param_type) {
153 issues.push(issue);
154 }
155 }
156
157 issues
158}
159
160pub fn is_global_output_flag(name: &str) -> bool {
181 name == "json"
182}
183
184fn check_type_compatibility(name: &str, value: &Value, expected_type: &str) -> Option<ValidationIssue> {
186 let compatible = match expected_type {
187 "any" => true,
188 "string" => true, "int" => matches!(value, Value::Int(_) | Value::String(_)),
190 "float" => matches!(value, Value::Float(_) | Value::Int(_) | Value::String(_)),
191 "bool" => matches!(value, Value::Bool(_) | Value::String(_)),
192 "array" => matches!(value, Value::String(_)), "object" => matches!(value, Value::String(_)), _ => true, };
196
197 if compatible {
198 None
199 } else {
200 let code = IssueCode::InvalidArgType;
201 Some(ValidationIssue {
202 severity: code.default_severity(),
203 code,
204 message: format!(
205 "argument '{}' has type {:?}, expected {}",
206 name, value, expected_type
207 ),
208 span: None,
209 suggestion: None,
210 })
211 }
212}
213
214#[cfg(test)]
215mod validate_tests {
216 use super::*;
217 use kaish_types::{ParamSchema, ToolSchema};
218
219 fn schema_with_positionals_after_flags() -> ToolSchema {
220 ToolSchema::new("demo", "demo")
222 .param(
223 ParamSchema::new("verbose", "bool")
224 .with_default(Some(Value::Bool(false)))
225 .with_aliases(["v"]),
226 )
227 .param(ParamSchema::new("lines", "int").with_aliases(["n"]))
228 .param(
229 ParamSchema::new("path", "string")
230 .with_required(true)
231 .positional(),
232 )
233 }
234
235 #[test]
240 fn required_positional_satisfied_when_positional_sits_after_flags() {
241 let schema = schema_with_positionals_after_flags();
242 let mut args = ToolArgs::new();
243 args.positional.push(Value::String("foo.txt".into()));
244
245 let issues = validate_against_schema(&args, &schema);
246 assert!(
247 !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
248 "required positional should be satisfied by positional[0]; got {:?}",
249 issues
250 );
251 }
252
253 #[test]
254 fn required_positional_missing_when_no_positional_given() {
255 let schema = schema_with_positionals_after_flags();
256 let mut args = ToolArgs::new();
257 args.flags.insert("verbose".into());
258
259 let issues = validate_against_schema(&args, &schema);
260 assert!(
261 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
262 "missing required positional should error; got {:?}",
263 issues
264 );
265 }
266
267 #[test]
274 fn positional_type_check_targets_positional_slot_not_struct_index() {
275 let mut schema = ToolSchema::new("demo", "demo");
276 schema = schema
278 .param(ParamSchema::new("verbose", "bool").with_default(Some(Value::Bool(false))))
279 .param(
280 ParamSchema::new("count", "int")
281 .with_required(true)
282 .positional(),
283 )
284 .param(
285 ParamSchema::new("name", "string")
286 .with_required(true)
287 .positional(),
288 );
289
290 let mut args = ToolArgs::new();
291 args.positional.push(Value::Int(5));
292 args.positional.push(Value::String("widget".into()));
293
294 let issues = validate_against_schema(&args, &schema);
295 assert!(
296 !issues.iter().any(|i| matches!(i.code, IssueCode::InvalidArgType)),
297 "int->int and string->string slots should validate clean; got {:?}",
298 issues
299 );
300 }
301
302 #[test]
305 fn required_flag_still_errors_when_missing() {
306 let schema = ToolSchema::new("demo", "demo").param(
307 ParamSchema::new("output", "string")
308 .with_required(true)
309 .with_aliases(["o"]),
310 );
311
312 let args = ToolArgs::new();
313 let issues = validate_against_schema(&args, &schema);
314 assert!(
315 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
316 "required flag should error when missing; got {:?}",
317 issues
318 );
319 }
320}