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            });
82        }
83    }
84
85    // Required flags: matched by name (or alias) against args.named / args.flags.
86    for param in &flag_params {
87        if !param.required {
88            continue;
89        }
90        let has_named = args.named.contains_key(&param.name);
91        let has_flag = param.param_type == "bool" && args.has_flag(&param.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    // Check for unknown flags (only warn - tools may accept dynamic flags).
105    // Only bool flags are gathered for the strict known-flag set; the
106    // alias-fallback below catches value-taking flags via `matches_flag`.
107    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        // Strip leading dashes for comparison
118        let flag_name = flag.trim_start_matches('-');
119        // Global output flags are handled by the kernel, not the tool
120        if is_global_output_flag(flag_name) {
121            continue;
122        }
123        if !known_flags.contains(flag_name) && !known_flags.contains(flag.as_str()) {
124            // Check if any flag param matches this flag via aliases
125            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    // Type compatibility for named args (search the full schema — callers
139    // may name either a positional or a flag param).
140    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, &param.param_type) {
143                issues.push(issue);
144            }
145    }
146
147    // Type compatibility for positional args (matched by slot among
148    // positional params). Extra positionals past the schema are ignored —
149    // many builtins (cat, cp, mkdir) accept variadic positionals.
150    for (slot, value) in args.positional.iter().enumerate() {
151        if let Some(param) = positional_params.get(slot)
152            && let Some(issue) = check_type_compatibility(&param.name, value, &param.param_type) {
153                issues.push(issue);
154            }
155    }
156
157    issues
158}
159
160// ============================================================
161// Global Output Flags (--json)
162// ============================================================
163//
164// `--json` is declared per-builtin via `GlobalFlags` flatten
165// (`crate::global_flags`). Builtins parse it inside execute() and write the
166// output format via `ToolCtx::set_output_format`; the kernel applies the
167// format after execute() returns.
168
169/// Check if a flag name is the kernel-owned `--json` flag.
170///
171/// External commands (no schema) bypass clap entirely and the kernel
172/// doesn't touch their argv — `cargo --json` and similar work as
173/// expected.
174///
175/// Every binder asks this before deciding the flag is the kernel's: the
176/// typed, `raw_argv`, and verbatim arms in the kernel, both arms of the
177/// validator's binder, and scatter/gather's early-error path. A tool that
178/// declares its own value-taking `--json` would have its value taken by
179/// them; `schema_from_clap` skips the name for exactly that reason.
180pub fn is_global_output_flag(name: &str) -> bool {
181    name == "json"
182}
183
184/// Check if a value is compatible with a type.
185fn check_type_compatibility(name: &str, value: &Value, expected_type: &str) -> Option<ValidationIssue> {
186    let compatible = match expected_type {
187        "any" => true,
188        "string" => true, // Everything can be a string
189        "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(_)), // Arrays are passed as strings in kaish
193        "object" => matches!(value, Value::String(_)), // Objects are JSON strings
194        _ => true, // Unknown types pass
195    };
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        // Mirrors clap-derived order: flag fields first, positionals last.
221        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    /// Regression for the clap-migration index-mismatch: `cat foo.txt` should
236    /// satisfy the required positional `path` even though `path` is at struct
237    /// index 2 (after `verbose`/`lines`). The old code matched positional[0]
238    /// against `verbose` and required positional[2] to exist.
239    #[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    /// Positional type check must look up the positional slot, not the
268    /// struct-field index. Here we have a string positional at slot 0; the
269    /// old code would have type-checked positional[0] against the int param
270    /// `lines` (struct index 1) and emit nothing — but now an int positional
271    /// against the string slot must be accepted, and a string positional
272    /// against an int positional slot would error.
273    #[test]
274    fn positional_type_check_targets_positional_slot_not_struct_index() {
275        let mut schema = ToolSchema::new("demo", "demo");
276        // Two positionals: count (int) then name (string).
277        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    /// Required *flag* (non-positional) must still fire MissingRequiredArg
303    /// when absent — separating the loops shouldn't silently drop the check.
304    #[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}