Skip to main content

kaish_tool_api/
tool.rs

1//! The `Tool` trait and argument validation.
2
3use 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/// A tool that can be executed.
13///
14/// Every kaish command — builtin or third-party — implements this trait. The
15/// `execute` method receives a `&mut dyn ToolCtx`, the trimmed portable
16/// context; tools needing deeper kernel state downcast via
17/// [`ToolCtx::as_any_mut`](crate::ToolCtx::as_any_mut).
18#[async_trait]
19pub trait Tool: Send + Sync {
20    /// The tool's name (used for lookup).
21    fn name(&self) -> &str;
22
23    /// Get the tool's schema.
24    fn schema(&self) -> ToolSchema;
25
26    /// Execute the tool with the given arguments and context.
27    async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult;
28
29    /// Validate arguments without executing.
30    ///
31    /// Default implementation validates against the schema.
32    /// Override this for semantic checks (regex validity, zero increment, etc.).
33    fn validate(&self, args: &ToolArgs) -> Vec<ValidationIssue> {
34        validate_against_schema(args, &self.schema())
35    }
36}
37
38/// Validate arguments against a tool schema.
39///
40/// Splits `schema.params` into positional and named/flag groups so the
41/// positional slot index never conflates with the struct-field index. With
42/// clap-derived schemas, positionals sit *after* the flags in struct order;
43/// the old single-index walk would have falsely failed `mkdir foo` because
44/// the path slot lives at struct index 1+.
45///
46/// Checks:
47/// - Required parameters are provided (positionals by slot, flags by name).
48/// - Unknown flags (warning).
49/// - Type compatibility for both positional and named args.
50pub fn validate_against_schema(args: &ToolArgs, schema: &ToolSchema) -> Vec<ValidationIssue> {
51    // A verbatim tool owns its grammar, and every check below reads a
52    // decomposition it never receives — a required positional would look
53    // missing on a subcommand path. One that wants checks overrides
54    // `Tool::validate`.
55    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    // Required positionals: matched by slot among positional params only.
65    for (slot, param) in positional_params.iter().enumerate() {
66        if !param.required {
67            continue;
68        }
69        let has_positional = args.positional.len() > slot;
70        // A required positional can also be supplied as a named arg if the
71        // caller knows the param name (e.g. `mkdir paths=foo`).
72        let has_named = args.named.contains_key(&param.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    // Required flags: matched by name (or alias) against args.named / args.flags.
87    for param in &flag_params {
88        if !param.required {
89            continue;
90        }
91        let has_named = args.named.contains_key(&param.name);
92        let has_flag = param.param_type == "bool" && args.has_flag(&param.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    // Check for unknown flags (only warn - tools may accept dynamic flags).
107    // Only bool flags are gathered for the strict known-flag set; the
108    // alias-fallback below catches value-taking flags via `matches_flag`.
109    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        // Strip leading dashes for comparison
120        let flag_name = flag.trim_start_matches('-');
121        // Global output flags are handled by the kernel, not the tool
122        if is_global_output_flag(flag_name) {
123            continue;
124        }
125        if !known_flags.contains(flag_name) && !known_flags.contains(flag.as_str()) {
126            // Check if any flag param matches this flag via aliases
127            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    // Type compatibility for named args (search the full schema — callers
142    // may name either a positional or a flag param).
143    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, &param.param_type, &schema.name) {
146                issues.push(issue);
147            }
148    }
149
150    // Type compatibility for positional args (matched by slot among
151    // positional params). Extra positionals past the schema are ignored —
152    // many builtins (cat, cp, mkdir) accept variadic positionals.
153    for (slot, value) in args.positional.iter().enumerate() {
154        if let Some(param) = positional_params.get(slot)
155            && let Some(issue) = check_type_compatibility(&param.name, value, &param.param_type, &schema.name) {
156                issues.push(issue);
157            }
158    }
159
160    issues
161}
162
163// ============================================================
164// Global Output Flags (--json)
165// ============================================================
166//
167// `--json` is declared per-builtin via `GlobalFlags` flatten
168// (`crate::global_flags`). Builtins parse it inside execute() and write the
169// output format via `ToolCtx::set_output_format`; the kernel applies the
170// format after execute() returns.
171
172/// Check if a flag name is the kernel-owned `--json` flag.
173///
174/// External commands (no schema) bypass clap entirely and the kernel
175/// doesn't touch their argv — `cargo --json` and similar work as
176/// expected.
177///
178/// Every binder asks this before deciding the flag is the kernel's: the
179/// typed, `raw_argv`, and verbatim arms in the kernel, both arms of the
180/// validator's binder, and scatter/gather's early-error path. A tool that
181/// declares its own value-taking `--json` would have its value taken by
182/// them; `schema_from_clap` skips the name for exactly that reason.
183pub fn is_global_output_flag(name: &str) -> bool {
184    name == "json"
185}
186
187/// Check if a value is compatible with a type.
188fn 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, // Everything can be a string
197        "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(_)), // Arrays are passed as strings in kaish
201        "object" => matches!(value, Value::String(_)), // Objects are JSON strings
202        _ => true, // Unknown types pass
203    };
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        // Mirrors clap-derived order: flag fields first, positionals last.
230        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    /// Regression for the clap-migration index-mismatch: `cat foo.txt` should
245    /// satisfy the required positional `path` even though `path` is at struct
246    /// index 2 (after `verbose`/`lines`). The old code matched positional[0]
247    /// against `verbose` and required positional[2] to exist.
248    #[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    /// Positional type check must look up the positional slot, not the
277    /// struct-field index. Here we have a string positional at slot 0; the
278    /// old code would have type-checked positional[0] against the int param
279    /// `lines` (struct index 1) and emit nothing — but now an int positional
280    /// against the string slot must be accepted, and a string positional
281    /// against an int positional slot would error.
282    #[test]
283    fn positional_type_check_targets_positional_slot_not_struct_index() {
284        let mut schema = ToolSchema::new("demo", "demo");
285        // Two positionals: count (int) then name (string).
286        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    /// Required *flag* (non-positional) must still fire MissingRequiredArg
312    /// when absent — separating the loops shouldn't silently drop the check.
313    #[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    /// The schema always names the command being checked, so every issue
331    /// `validate_against_schema` raises should carry it — a caller that
332    /// calls `Tool::validate` directly (no walker in between) still gets a
333    /// structured command name, not just message text.
334    #[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    /// The positional and flag branches are two separate loops in
348    /// `validate_against_schema` (see `required_flag_still_errors_when_missing`
349    /// above) — both push the same `IssueCode::MissingRequiredArg` from their
350    /// own `ValidationIssue { .. }` literal, so the field has to be pinned on
351    /// each independently. The positional branch is covered by
352    /// `missing_required_positional_carries_command_name`; this is the flag one.
353    #[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    /// `UnknownFlag` is its own `ValidationIssue { .. }` literal in the
371    /// unknown-flags loop, not shared with either `MissingRequiredArg` branch
372    /// — it needs its own pin.
373    #[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        // Bool is not int-compatible per check_type_compatibility.
396        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}