kaish_tool_api/
clap_schema.rs1use clap::{Arg, ArgAction, Command};
11
12use kaish_types::{ParamSchema, ToolSchema, Value};
13
14pub fn schema_from_clap(
23 cmd: &Command,
24 name: &str,
25 description: &str,
26 examples: impl IntoIterator<Item = (&'static str, &'static str)>,
27) -> ToolSchema {
28 let mut schema = ToolSchema::new(name, description);
29 for param in params_from_clap(cmd) {
30 schema = schema.param(param);
31 }
32 for (desc, code) in examples {
33 schema = schema.example(desc, code);
34 }
35 schema
36}
37
38pub fn schema_tree_from_clap(
51 cmd: &Command,
52 name: &str,
53 description: &str,
54 examples: impl IntoIterator<Item = (&'static str, &'static str)>,
55) -> ToolSchema {
56 let mut schema = ToolSchema::new(name, description);
57 for param in params_from_clap(cmd) {
58 schema = schema.param(param);
59 }
60 for (desc, code) in examples {
61 schema = schema.example(desc, code);
62 }
63 for sub in cmd.get_subcommands() {
64 schema = schema.subcommand(child_schema_from_clap(sub));
65 }
66 schema
67}
68
69fn child_schema_from_clap(cmd: &Command) -> ToolSchema {
75 let name = cmd.get_name().to_string();
76 let description = cmd
77 .get_about()
78 .map(|s| s.to_string())
79 .unwrap_or_default();
80 let mut schema = ToolSchema::new(name, description);
81 for param in params_from_clap(cmd) {
82 schema = schema.param(param);
83 }
84 let aliases: Vec<String> = cmd.get_all_aliases().map(|s| s.to_string()).collect();
85 if !aliases.is_empty() {
86 schema = schema.with_command_aliases(aliases);
87 }
88 for sub in cmd.get_subcommands() {
89 schema = schema.subcommand(child_schema_from_clap(sub));
90 }
91 schema
92}
93
94pub fn params_from_clap(cmd: &Command) -> Vec<ParamSchema> {
110 cmd.get_arguments()
111 .filter(|arg| !is_skipped(arg))
112 .map(arg_to_param)
113 .collect()
114}
115
116fn is_skipped(arg: &Arg) -> bool {
117 let id = arg.get_id().as_str();
118 if matches!(id, "help" | "version" | "json") {
119 return true;
120 }
121 arg.is_hide_set() && !arg.is_positional()
126}
127
128fn arg_to_param(arg: &Arg) -> ParamSchema {
129 let id = arg.get_id().as_str();
130 let name = arg.get_long().unwrap_or(id).to_string();
140 let action = arg.get_action();
141 let is_bool = matches!(action, ArgAction::SetTrue | ArgAction::SetFalse);
142
143 let param_type = if is_bool {
144 "bool"
145 } else if matches!(action, ArgAction::Count) {
146 "int"
147 } else {
148 "string"
151 };
152
153 let description = arg
154 .get_help()
155 .map(|s| s.to_string())
156 .or_else(|| arg.get_long_help().map(|s| s.to_string()))
157 .unwrap_or_default();
158
159 let required = arg.is_required_set();
160
161 let mut aliases: Vec<String> = Vec::new();
162 if let Some(short) = arg.get_short() {
163 aliases.push(short.to_string());
164 }
165 if id != name {
169 aliases.push(id.to_string());
170 }
171 if let Some(visible) = arg.get_visible_aliases() {
172 for alias in visible {
173 aliases.push(alias.to_string());
174 }
175 }
176
177 let consumes = match arg.get_num_args() {
178 Some(range) => {
179 let lo = range.min_values();
180 if lo == 0 { 1 } else { lo }
181 }
182 None => 1,
183 };
184
185 let default = if is_bool {
186 Some(Value::Bool(false))
187 } else {
188 None
189 };
190
191 let positional = arg.is_positional();
196
197 let repeatable = matches!(action, ArgAction::Append) && !positional;
202
203 ParamSchema::new(name, param_type.to_string())
204 .with_required(required)
205 .with_default(default)
206 .with_description(description)
207 .with_aliases(aliases)
208 .consumes(consumes)
209 .with_repeatable(repeatable)
210 .with_positional(positional)
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use clap::{CommandFactory, Parser};
217
218 #[derive(Parser, Debug)]
219 #[command(name = "demo", about = "demo tool")]
220 struct DemoArgs {
221 #[arg(short = 'n', long = "number")]
223 number: bool,
224
225 #[arg(short = 'l', long = "lines", default_value_t = 10)]
227 lines: i64,
228
229 #[arg(hide = true)]
231 paths: Vec<String>,
232 }
233
234 #[derive(Parser, Debug)]
237 #[command(name = "demo-internal", about = "demo with internal flag")]
238 struct DemoInternalArgs {
239 #[arg(hide = true, long = "internal-only")]
240 internal: bool,
241
242 paths: Vec<String>,
244 }
245
246 #[test]
247 fn bool_flag_becomes_bool_param() {
248 let cmd = DemoArgs::command();
249 let params = params_from_clap(&cmd);
250 let p = params.iter().find(|p| p.name == "number").expect("number param");
251 assert_eq!(p.param_type, "bool");
252 assert!(!p.required);
253 assert_eq!(p.aliases, vec!["n".to_string()]);
254 assert!(p.description.contains("Number output lines"));
255 }
256
257 #[test]
258 fn value_flag_reports_short_alias_and_string_type() {
259 let cmd = DemoArgs::command();
260 let params = params_from_clap(&cmd);
261 let p = params.iter().find(|p| p.name == "lines").expect("lines param");
262 assert_eq!(p.param_type, "string");
263 assert!(!p.required);
264 assert_eq!(p.aliases, vec!["l".to_string()]);
265 }
266
267 #[test]
268 fn hidden_positional_is_kept_and_marked_positional() {
269 let cmd = DemoArgs::command();
270 let params = params_from_clap(&cmd);
271 let p = params.iter().find(|p| p.name == "paths").expect("paths param");
272 assert!(p.positional, "hidden positional sink should be exposed as positional");
273 assert_eq!(p.param_type, "string");
274 assert!(p.description.contains("Files to read"));
275 }
276
277 #[test]
278 fn hidden_flag_is_dropped() {
279 let cmd = DemoInternalArgs::command();
280 let params = params_from_clap(&cmd);
281 assert!(
282 params.iter().all(|p| p.name != "internal"),
283 "hidden non-positional flag should be skipped: {:?}",
284 params.iter().map(|p| &p.name).collect::<Vec<_>>()
285 );
286 assert!(params.iter().any(|p| p.name == "paths" && p.positional));
288 }
289
290 #[derive(Parser, Debug)]
298 #[command(name = "demo-id-override")]
299 struct DemoIdOverrideArgs {
300 #[arg(short = 'b', long = "bare")]
302 _bare: Option<String>,
303
304 #[arg(id = "clean", short = 'c', long = "clean")]
306 _clean: Option<String>,
307 }
308
309 #[test]
310 fn id_override_strips_leading_underscore_from_schema_name() {
311 let cmd = DemoIdOverrideArgs::command();
312 let params = params_from_clap(&cmd);
313
314 let bare = params.iter().find(|p| p.name == "bare")
320 .expect("name should be the long flag `bare`, not the field id `_bare`");
321 assert_eq!(bare.aliases, vec!["b".to_string(), "_bare".to_string()]);
322 assert!(
323 !params.iter().any(|p| p.name == "_bare"),
324 "the snake field id must not be the canonical name"
325 );
326
327 let clean = params.iter().find(|p| p.name == "clean")
330 .expect("name should be the long flag `clean`");
331 assert_eq!(clean.aliases, vec!["c".to_string()]);
332 }
333
334 #[derive(Parser, Debug)]
339 #[command(name = "demo-rep")]
340 struct DemoRepeatableArgs {
341 #[arg(short = 'e', long = "expression")]
343 expression: Vec<String>,
344
345 #[arg(short = 's', long = "single")]
347 single: Option<String>,
348
349 paths: Vec<String>,
351 }
352
353 #[test]
354 fn vec_value_flag_is_repeatable() {
355 let cmd = DemoRepeatableArgs::command();
356 let params = params_from_clap(&cmd);
357
358 let expr = params.iter().find(|p| p.name == "expression").expect("expression param");
359 assert!(expr.repeatable, "Vec<String> value flag should be repeatable");
360 assert!(!expr.positional);
361
362 let single = params.iter().find(|p| p.name == "single").expect("single param");
363 assert!(!single.repeatable, "scalar value flag must not be repeatable");
364
365 let paths = params.iter().find(|p| p.name == "paths").expect("paths param");
366 assert!(!paths.repeatable, "positional Vec sink must not be marked repeatable");
367 assert!(paths.positional);
368 }
369
370 #[test]
371 fn flag_params_are_not_marked_positional() {
372 let cmd = DemoArgs::command();
373 let params = params_from_clap(&cmd);
374 let p = params.iter().find(|p| p.name == "number").unwrap();
375 assert!(!p.positional);
376 let p = params.iter().find(|p| p.name == "lines").unwrap();
377 assert!(!p.positional);
378 }
379
380 #[test]
381 fn help_version_json_filtered() {
382 let cmd = DemoArgs::command();
383 let params = params_from_clap(&cmd);
384 assert!(params.iter().all(|p| !matches!(p.name.as_str(), "help" | "version" | "json")));
385 }
386
387 #[test]
390 fn schema_tree_reflects_subcommands_and_aliases() {
391 let list = Command::new("list").about("list contexts").visible_alias("ls");
393 let context = Command::new("context")
394 .about("context ops")
395 .visible_alias("ctx")
396 .arg(Arg::new("type").long("type").short('t').action(ArgAction::Set))
397 .subcommand(list);
398 let kj = Command::new("kj").about("kaijutsu").subcommand(context);
399
400 let schema = schema_tree_from_clap(&kj, "kj", "kaijutsu", []);
401
402 assert_eq!(schema.subcommands.len(), 1, "kj should have one child");
403 let context = &schema.subcommands[0];
404 assert!(context.matches_command("context"));
405 assert!(context.matches_command("ctx"), "command alias should route");
406 let type_param = context.params.iter().find(|p| p.name == "type").expect("type on context");
408 assert_eq!(type_param.param_type, "string");
409 assert_eq!(type_param.aliases, vec!["t".to_string()]);
410 assert!(schema.params.iter().all(|p| p.name != "type"), "leaf flag must not leak to root");
411
412 assert_eq!(context.subcommands.len(), 1);
413 let list = &context.subcommands[0];
414 assert!(list.matches_command("list"));
415 assert!(list.matches_command("ls"));
416 }
417
418 #[test]
421 fn schema_tree_of_flat_command_has_no_subcommands() {
422 let cmd = DemoArgs::command();
423 let schema = schema_tree_from_clap(&cmd, "demo", "demo tool", []);
424 assert!(schema.subcommands.is_empty());
425 assert!(schema.aliases.is_empty());
426 assert_eq!(schema.params.len(), params_from_clap(&cmd).len());
428 }
429}