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 command: Some(schema.name.clone()),
82 });
83 }
84 }
85
86 for param in &flag_params {
88 if !param.required {
89 continue;
90 }
91 let has_named = args.named.contains_key(¶m.name);
92 let has_flag = param.param_type == "bool" && args.has_flag(¶m.name);
93 if !has_named && !has_flag {
94 let code = IssueCode::MissingRequiredArg;
95 issues.push(ValidationIssue {
96 severity: code.default_severity(),
97 code,
98 message: format!("required parameter '{}' not provided", param.name),
99 span: None,
100 suggestion: Some(format!("add --{} <value>", param.name)),
101 command: Some(schema.name.clone()),
102 });
103 }
104 }
105
106 let known_flags: HashSet<&str> = flag_params
110 .iter()
111 .filter(|p| p.param_type == "bool")
112 .flat_map(|p| {
113 std::iter::once(p.name.as_str())
114 .chain(p.aliases.iter().map(|a| a.as_str()))
115 })
116 .collect();
117
118 for flag in &args.flags {
119 let flag_name = flag.trim_start_matches('-');
121 if is_global_output_flag(flag_name) {
123 continue;
124 }
125 if !known_flags.contains(flag_name) && !known_flags.contains(flag.as_str()) {
126 let matches_alias = flag_params.iter().any(|p| p.matches_flag(flag));
128 if !matches_alias {
129 issues.push(ValidationIssue {
130 severity: Severity::Warning,
131 code: IssueCode::UnknownFlag,
132 message: format!("unknown flag '{}'", flag),
133 span: None,
134 suggestion: None,
135 command: Some(schema.name.clone()),
136 });
137 }
138 }
139 }
140
141 for (key, value) in &args.named {
144 if let Some(param) = schema.params.iter().find(|p| &p.name == key)
145 && let Some(issue) = check_type_compatibility(key, value, ¶m.param_type, &schema.name) {
146 issues.push(issue);
147 }
148 }
149
150 for (slot, value) in args.positional.iter().enumerate() {
154 if let Some(param) = positional_params.get(slot)
155 && let Some(issue) = check_type_compatibility(¶m.name, value, ¶m.param_type, &schema.name) {
156 issues.push(issue);
157 }
158 }
159
160 issues
161}
162
163pub fn is_global_output_flag(name: &str) -> bool {
184 name == "json"
185}
186
187fn check_type_compatibility(
189 name: &str,
190 value: &Value,
191 expected_type: &str,
192 command: &str,
193) -> Option<ValidationIssue> {
194 let compatible = match expected_type {
195 "any" => true,
196 "string" => true, "int" => matches!(value, Value::Int(_) | Value::String(_)),
198 "float" => matches!(value, Value::Float(_) | Value::Int(_) | Value::String(_)),
199 "bool" => matches!(value, Value::Bool(_) | Value::String(_)),
200 "array" => matches!(value, Value::String(_)), "object" => matches!(value, Value::String(_)), _ => true, };
204
205 if compatible {
206 None
207 } else {
208 let code = IssueCode::InvalidArgType;
209 Some(ValidationIssue {
210 severity: code.default_severity(),
211 code,
212 message: format!(
213 "argument '{}' has type {:?}, expected {}",
214 name, value, expected_type
215 ),
216 span: None,
217 suggestion: None,
218 command: Some(command.to_string()),
219 })
220 }
221}
222
223#[cfg(test)]
224mod validate_tests {
225 use super::*;
226 use kaish_types::{ParamSchema, ToolSchema};
227
228 fn schema_with_positionals_after_flags() -> ToolSchema {
229 ToolSchema::new("demo", "demo")
231 .param(
232 ParamSchema::new("verbose", "bool")
233 .with_default(Some(Value::Bool(false)))
234 .with_aliases(["v"]),
235 )
236 .param(ParamSchema::new("lines", "int").with_aliases(["n"]))
237 .param(
238 ParamSchema::new("path", "string")
239 .with_required(true)
240 .positional(),
241 )
242 }
243
244 #[test]
249 fn required_positional_satisfied_when_positional_sits_after_flags() {
250 let schema = schema_with_positionals_after_flags();
251 let mut args = ToolArgs::new();
252 args.positional.push(Value::String("foo.txt".into()));
253
254 let issues = validate_against_schema(&args, &schema);
255 assert!(
256 !issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
257 "required positional should be satisfied by positional[0]; got {:?}",
258 issues
259 );
260 }
261
262 #[test]
263 fn required_positional_missing_when_no_positional_given() {
264 let schema = schema_with_positionals_after_flags();
265 let mut args = ToolArgs::new();
266 args.flags.insert("verbose".into());
267
268 let issues = validate_against_schema(&args, &schema);
269 assert!(
270 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
271 "missing required positional should error; got {:?}",
272 issues
273 );
274 }
275
276 #[test]
283 fn positional_type_check_targets_positional_slot_not_struct_index() {
284 let mut schema = ToolSchema::new("demo", "demo");
285 schema = schema
287 .param(ParamSchema::new("verbose", "bool").with_default(Some(Value::Bool(false))))
288 .param(
289 ParamSchema::new("count", "int")
290 .with_required(true)
291 .positional(),
292 )
293 .param(
294 ParamSchema::new("name", "string")
295 .with_required(true)
296 .positional(),
297 );
298
299 let mut args = ToolArgs::new();
300 args.positional.push(Value::Int(5));
301 args.positional.push(Value::String("widget".into()));
302
303 let issues = validate_against_schema(&args, &schema);
304 assert!(
305 !issues.iter().any(|i| matches!(i.code, IssueCode::InvalidArgType)),
306 "int->int and string->string slots should validate clean; got {:?}",
307 issues
308 );
309 }
310
311 #[test]
314 fn required_flag_still_errors_when_missing() {
315 let schema = ToolSchema::new("demo", "demo").param(
316 ParamSchema::new("output", "string")
317 .with_required(true)
318 .with_aliases(["o"]),
319 );
320
321 let args = ToolArgs::new();
322 let issues = validate_against_schema(&args, &schema);
323 assert!(
324 issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg),
325 "required flag should error when missing; got {:?}",
326 issues
327 );
328 }
329
330 #[test]
335 fn missing_required_positional_carries_command_name() {
336 let schema = schema_with_positionals_after_flags();
337 let args = ToolArgs::new();
338
339 let issues = validate_against_schema(&args, &schema);
340 let issue = issues
341 .iter()
342 .find(|i| i.code == IssueCode::MissingRequiredArg)
343 .expect("expected a MissingRequiredArg issue");
344 assert_eq!(issue.command.as_deref(), Some("demo"));
345 }
346
347 #[test]
354 fn missing_required_flag_carries_command_name() {
355 let schema = ToolSchema::new("demo", "demo").param(
356 ParamSchema::new("output", "string")
357 .with_required(true)
358 .with_aliases(["o"]),
359 );
360
361 let args = ToolArgs::new();
362 let issues = validate_against_schema(&args, &schema);
363 let issue = issues
364 .iter()
365 .find(|i| i.code == IssueCode::MissingRequiredArg)
366 .expect("expected a MissingRequiredArg issue");
367 assert_eq!(issue.command.as_deref(), Some("demo"));
368 }
369
370 #[test]
374 fn unknown_flag_carries_command_name() {
375 let schema = ToolSchema::new("demo", "demo").param(
376 ParamSchema::new("verbose", "bool").with_default(Some(Value::Bool(false))),
377 );
378 let mut args = ToolArgs::new();
379 args.flags.insert("bogus".to_string());
380
381 let issues = validate_against_schema(&args, &schema);
382 let issue = issues
383 .iter()
384 .find(|i| i.code == IssueCode::UnknownFlag)
385 .expect("expected an UnknownFlag issue");
386 assert_eq!(issue.command.as_deref(), Some("demo"));
387 }
388
389 #[test]
390 fn invalid_arg_type_carries_command_name() {
391 let schema = ToolSchema::new("demo", "demo").param(
392 ParamSchema::new("count", "int").with_required(true).positional(),
393 );
394 let mut args = ToolArgs::new();
395 args.positional.push(Value::Bool(true));
397
398 let issues = validate_against_schema(&args, &schema);
399 let issue = issues
400 .iter()
401 .find(|i| i.code == IssueCode::InvalidArgType)
402 .expect("expected an InvalidArgType issue");
403 assert_eq!(issue.command.as_deref(), Some("demo"));
404 }
405}