1use std::collections::{BTreeMap, HashSet};
4
5use crate::value::Value;
6
7fn default_consumes() -> usize {
8 1
9}
10
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13#[non_exhaustive]
14pub struct ParamSchema {
15 pub name: String,
17 pub param_type: String,
19 pub required: bool,
21 pub default: Option<Value>,
23 pub description: String,
25 pub aliases: Vec<String>,
27 #[serde(default = "default_consumes")]
35 pub consumes: usize,
36 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44 pub repeatable: bool,
45 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
53 pub positional: bool,
54}
55
56impl ParamSchema {
57 pub fn required(name: impl Into<String>, param_type: impl Into<String>, description: impl Into<String>) -> Self {
59 Self {
60 name: name.into(),
61 param_type: param_type.into(),
62 required: true,
63 default: None,
64 description: description.into(),
65 aliases: Vec::new(),
66 consumes: 1,
67 repeatable: false,
68 positional: false,
69 }
70 }
71
72 pub fn optional(name: impl Into<String>, param_type: impl Into<String>, default: Value, description: impl Into<String>) -> Self {
74 Self {
75 name: name.into(),
76 param_type: param_type.into(),
77 required: false,
78 default: Some(default),
79 description: description.into(),
80 aliases: Vec::new(),
81 consumes: 1,
82 repeatable: false,
83 positional: false,
84 }
85 }
86
87 pub fn new(name: impl Into<String>, param_type: impl Into<String>) -> Self {
94 Self {
95 name: name.into(),
96 param_type: param_type.into(),
97 required: false,
98 default: None,
99 description: String::new(),
100 aliases: Vec::new(),
101 consumes: 1,
102 repeatable: false,
103 positional: false,
104 }
105 }
106
107 pub fn with_description(mut self, description: impl Into<String>) -> Self {
109 self.description = description.into();
110 self
111 }
112
113 pub fn with_required(mut self, required: bool) -> Self {
115 self.required = required;
116 self
117 }
118
119 pub fn with_default(mut self, default: Option<Value>) -> Self {
121 self.default = default;
122 self
123 }
124
125 pub fn with_positional(mut self, positional: bool) -> Self {
128 self.positional = positional;
129 self
130 }
131
132 pub fn positional(mut self) -> Self {
137 self.positional = true;
138 self
139 }
140
141 pub fn with_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
145 self.aliases = aliases.into_iter().map(Into::into).collect();
146 self
147 }
148
149 pub fn consumes(mut self, n: usize) -> Self {
153 assert!(n >= 1, "ParamSchema::consumes requires n >= 1 (use a bool param for flags that take no value)");
154 self.consumes = n;
155 self
156 }
157
158 pub fn with_repeatable(mut self, repeatable: bool) -> Self {
162 self.repeatable = repeatable;
163 self
164 }
165
166 pub fn matches_flag(&self, flag: &str) -> bool {
168 if self.name == flag {
169 return true;
170 }
171 self.aliases.iter().any(|a| a == flag)
172 }
173}
174
175#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
177pub struct Example {
178 pub description: String,
180 pub code: String,
182}
183
184impl Example {
185 pub fn new(description: impl Into<String>, code: impl Into<String>) -> Self {
187 Self {
188 description: description.into(),
189 code: code.into(),
190 }
191 }
192}
193
194#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
196#[non_exhaustive]
197pub struct ToolSchema {
198 pub name: String,
200 pub description: String,
202 pub params: Vec<ParamSchema>,
204 pub examples: Vec<Example>,
206 pub map_positionals: bool,
210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
221 pub subcommands: Vec<ToolSchema>,
222 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 pub aliases: Vec<String>,
227 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
235 pub owns_output: bool,
236}
237
238impl ToolSchema {
239 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
241 Self {
242 name: name.into(),
243 description: description.into(),
244 params: Vec::new(),
245 examples: Vec::new(),
246 map_positionals: false,
247 subcommands: Vec::new(),
248 aliases: Vec::new(),
249 owns_output: false,
250 }
251 }
252
253 pub fn with_positional_mapping(mut self) -> Self {
255 self.map_positionals = true;
256 self
257 }
258
259 pub fn param(mut self, param: ParamSchema) -> Self {
261 self.params.push(param);
262 self
263 }
264
265 pub fn example(mut self, description: impl Into<String>, code: impl Into<String>) -> Self {
267 self.examples.push(Example::new(description, code));
268 self
269 }
270
271 pub fn subcommand(mut self, child: ToolSchema) -> Self {
273 self.subcommands.push(child);
274 self
275 }
276
277 pub fn with_command_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
281 self.aliases = aliases.into_iter().map(Into::into).collect();
282 self
283 }
284
285 pub fn matches_command(&self, word: &str) -> bool {
288 self.name == word || self.aliases.iter().any(|a| a == word)
289 }
290
291 pub fn with_owned_output(mut self) -> Self {
300 self.mark_owned_output();
301 self
302 }
303
304 fn mark_owned_output(&mut self) {
305 self.owns_output = true;
306 if !self.params.iter().any(|p| p.name == "json") {
307 self.params.push(
308 ParamSchema::new("json", "bool").with_description("Render output as JSON"),
309 );
310 }
311 for child in &mut self.subcommands {
312 child.mark_owned_output();
313 }
314 }
315}
316
317#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
319#[non_exhaustive]
320pub struct ToolArgs {
321 pub positional: Vec<Value>,
323 pub named: BTreeMap<String, Value>,
325 pub flags: HashSet<String>,
327}
328
329impl ToolArgs {
330 pub fn new() -> Self {
332 Self::default()
333 }
334
335 pub fn get_positional(&self, index: usize) -> Option<&Value> {
337 self.positional.get(index)
338 }
339
340 pub fn get_named(&self, key: &str) -> Option<&Value> {
342 self.named.get(key)
343 }
344
345 pub fn get(&self, name: &str, positional_index: usize) -> Option<&Value> {
349 self.named.get(name).or_else(|| self.positional.get(positional_index))
350 }
351
352 pub fn get_string(&self, name: &str, positional_index: usize) -> Option<String> {
354 self.get(name, positional_index).and_then(|v| match v {
355 Value::String(s) => Some(s.clone()),
356 Value::Int(i) => Some(i.to_string()),
357 Value::Float(f) => Some(f.to_string()),
358 Value::Bool(b) => Some(b.to_string()),
359 _ => None,
360 })
361 }
362
363 pub fn get_bool(&self, name: &str, positional_index: usize) -> Option<bool> {
365 self.get(name, positional_index).and_then(|v| match v {
366 Value::Bool(b) => Some(*b),
367 Value::String(s) => match s.as_str() {
368 "true" | "yes" | "1" => Some(true),
369 "false" | "no" | "0" => Some(false),
370 _ => None,
371 },
372 Value::Int(i) => Some(*i != 0),
373 _ => None,
374 })
375 }
376
377 pub fn has_flag(&self, name: &str) -> bool {
379 if self.flags.contains(name) {
381 return true;
382 }
383 self.named.get(name).is_some_and(|v| match v {
385 Value::Bool(b) => *b,
386 Value::String(s) => !s.is_empty() && s != "false" && s != "0",
387 _ => true,
388 })
389 }
390
391 pub fn flagify_bool_named(&mut self, schema: &ToolSchema) {
411 let value_keys: HashSet<&str> = schema
414 .params
415 .iter()
416 .filter(|p| !p.positional && !is_bool_param_type(&p.param_type))
417 .flat_map(|p| {
418 std::iter::once(p.name.as_str())
419 .chain(p.aliases.iter().map(|a| a.trim_start_matches('-')))
420 })
421 .collect();
422
423 let bool_keys: Vec<String> = self
424 .named
425 .iter()
426 .filter(|(k, v)| matches!(v, Value::Bool(_)) && !value_keys.contains(k.as_str()))
427 .map(|(k, _)| k.clone())
428 .collect();
429 for k in bool_keys {
430 if let Some(Value::Bool(true)) = self.named.remove(&k) {
434 self.flags.insert(k);
435 }
436 }
437 }
438
439 pub fn to_argv(&self) -> Vec<String> {
452 let mut argv = Vec::with_capacity(
453 self.flags.len() + self.named.len() * 2 + self.positional.len() + 1,
454 );
455
456 let mut flags: Vec<&String> = self.flags.iter().collect();
461 flags.sort();
462 for flag in flags {
463 argv.push(flag_token(flag));
464 }
465
466 for (key, value) in &self.named {
471 for rendered in render_named_value(value) {
472 argv.push(format!("{}={}", flag_token(key), rendered));
473 }
474 }
475
476 if !self.positional.is_empty() {
479 argv.push("--".to_string());
480 for value in &self.positional {
481 argv.push(value_to_argv_token(value));
482 }
483 }
484
485 argv
486 }
487}
488
489fn flag_token(name: &str) -> String {
490 if name.chars().count() == 1 {
491 format!("-{name}")
492 } else {
493 format!("--{name}")
494 }
495}
496
497fn is_bool_param_type(param_type: &str) -> bool {
499 param_type.eq_ignore_ascii_case("bool") || param_type.eq_ignore_ascii_case("boolean")
500}
501
502fn render_named_value(value: &Value) -> Vec<String> {
503 match value {
504 Value::Json(serde_json::Value::Array(outer)) if outer.iter().all(|v| v.is_array()) => {
508 outer
509 .iter()
510 .map(|inner| {
511 inner
512 .as_array()
513 .map(|a| a.iter().map(json_value_to_token).collect::<Vec<_>>().join(" "))
514 .unwrap_or_default()
515 })
516 .collect()
517 }
518 _ => vec![value_to_argv_token(value)],
519 }
520}
521
522fn value_to_argv_token(value: &Value) -> String {
523 match value {
524 Value::Null => String::new(),
525 Value::Bool(b) => b.to_string(),
526 Value::Int(i) => i.to_string(),
527 Value::Float(f) => f.to_string(),
528 Value::String(s) => s.clone(),
529 Value::Json(j) => j.to_string(),
530 Value::Bytes(data) => format!("[binary: {} bytes]", data.len()),
534 }
535}
536
537fn json_value_to_token(value: &serde_json::Value) -> String {
538 match value {
539 serde_json::Value::Null => String::new(),
540 serde_json::Value::Bool(b) => b.to_string(),
541 serde_json::Value::Number(n) => n.to_string(),
542 serde_json::Value::String(s) => s.clone(),
543 other => other.to_string(),
544 }
545}
546
547#[cfg(test)]
548mod schema_serde_tests {
549 use super::*;
550
551 #[test]
554 fn flat_schema_omits_new_fields_on_wire() {
555 let schema = ToolSchema::new("cat", "concatenate")
556 .param(ParamSchema::required("path", "string", "file to read").positional());
557 let json = serde_json::to_value(&schema).expect("serialize");
558 let obj = json.as_object().expect("object");
559 assert!(!obj.contains_key("subcommands"), "flat tool leaks subcommands: {json}");
560 assert!(!obj.contains_key("aliases"), "flat tool leaks command aliases: {json}");
561 }
562
563 #[test]
567 fn flat_wire_form_deserializes_to_empty() {
568 let flat = serde_json::json!({
569 "name": "cat",
570 "description": "concatenate",
571 "params": [],
572 "examples": [],
573 "map_positionals": false
574 });
575 let schema: ToolSchema = serde_json::from_value(flat).expect("deserialize flat form");
576 assert!(schema.subcommands.is_empty());
577 assert!(schema.aliases.is_empty());
578 }
579
580 #[test]
583 fn with_owned_output_marks_tree_and_advertises_json() {
584 let schema = ToolSchema::new("kj", "kaijutsu")
585 .subcommand(
586 ToolSchema::new("context", "ctx")
587 .subcommand(ToolSchema::new("list", "list contexts")),
588 )
589 .with_owned_output();
590
591 assert!(schema.owns_output, "root marked");
592 assert!(schema.params.iter().any(|p| p.name == "json"), "root advertises json");
593 let context = &schema.subcommands[0];
594 assert!(context.owns_output, "child marked");
595 let list = &context.subcommands[0];
596 assert!(list.owns_output, "grandchild marked");
597 assert!(list.params.iter().any(|p| p.name == "json"), "leaf advertises json");
598 }
599
600 #[test]
602 fn with_owned_output_does_not_double_add_json() {
603 let schema = ToolSchema::new("kj", "kaijutsu")
604 .param(ParamSchema::new("json", "bool"))
605 .with_owned_output();
606 let json_count = schema.params.iter().filter(|p| p.name == "json").count();
607 assert_eq!(json_count, 1, "json should appear exactly once");
608 }
609
610 #[test]
612 fn owns_output_serde() {
613 let flat = ToolSchema::new("ls", "list");
614 let json = serde_json::to_value(&flat).expect("serialize");
615 let obj = json.as_object().expect("object");
616 assert!(!obj.contains_key("owns_output"), "false omitted: {json}");
617
618 let owned = ToolSchema::new("kj", "kaijutsu").with_owned_output();
619 let wire = serde_json::to_string(&owned).expect("serialize");
620 let back: ToolSchema = serde_json::from_str(&wire).expect("deserialize");
621 assert!(back.owns_output);
622 }
623
624 #[test]
626 fn subcommand_tree_round_trips() {
627 let schema = ToolSchema::new("kj", "kaijutsu")
628 .subcommand(
629 ToolSchema::new("context", "context ops")
630 .with_command_aliases(["ctx"])
631 .subcommand(ToolSchema::new("list", "list contexts").with_command_aliases(["ls"])),
632 );
633 let json = serde_json::to_string(&schema).expect("serialize");
634 let back: ToolSchema = serde_json::from_str(&json).expect("deserialize");
635 assert_eq!(back.subcommands.len(), 1);
636 let context = &back.subcommands[0];
637 assert!(context.matches_command("context"));
638 assert!(context.matches_command("ctx"));
639 assert_eq!(context.subcommands.len(), 1);
640 assert!(context.subcommands[0].matches_command("ls"));
641 }
642}
643
644#[cfg(test)]
645mod to_argv_tests {
646 use super::*;
647
648 #[test]
649 fn empty_args_produce_empty_argv() {
650 assert!(ToolArgs::new().to_argv().is_empty());
651 }
652
653 #[test]
654 fn positionals_emitted_after_double_dash() {
655 let mut args = ToolArgs::new();
656 args.positional.push(Value::String("hello".into()));
657 args.positional.push(Value::String("world".into()));
658 assert_eq!(args.to_argv(), vec!["--", "hello", "world"]);
659 }
660
661 #[test]
662 fn single_char_flags_emit_short_form() {
663 let mut args = ToolArgs::new();
664 args.flags.insert("n".into());
665 args.flags.insert("verbose".into());
666 assert_eq!(args.to_argv(), vec!["-n", "--verbose"]);
668 }
669
670 #[test]
671 fn named_values_use_equals_form() {
672 let mut args = ToolArgs::new();
673 args.named.insert("count".into(), Value::Int(5));
674 args.named.insert("name".into(), Value::String("foo".into()));
675 assert_eq!(args.to_argv(), vec!["--count=5", "--name=foo"]);
677 }
678
679 #[test]
680 fn single_char_named_emits_short_equals() {
681 let mut args = ToolArgs::new();
682 args.named.insert("n".into(), Value::Int(5));
683 assert_eq!(args.to_argv(), vec!["-n=5"]);
684 }
685
686 #[test]
687 fn positional_with_leading_dash_survives_double_dash() {
688 let mut args = ToolArgs::new();
689 args.positional.push(Value::String("-n".into()));
690 assert_eq!(args.to_argv(), vec!["--", "-n"]);
692 }
693
694 #[test]
695 fn mixed_flags_named_positionals() {
696 let mut args = ToolArgs::new();
697 args.flags.insert("verbose".into());
698 args.named.insert("limit".into(), Value::Int(10));
699 args.positional.push(Value::String("file.txt".into()));
700 assert_eq!(
701 args.to_argv(),
702 vec!["--verbose", "--limit=10", "--", "file.txt"]
703 );
704 }
705
706 #[test]
707 fn flagify_bool_named_promotes_true_to_flag() {
708 let mut args = ToolArgs::new();
709 args.named.insert("recursive".into(), Value::Bool(true));
710 args.named.insert("limit".into(), Value::Int(5));
711
712 args.flagify_bool_named(&ToolSchema::new("t", ""));
713
714 assert!(args.flags.contains("recursive"));
715 assert!(!args.named.contains_key("recursive"));
716 assert_eq!(args.named.get("limit"), Some(&Value::Int(5)));
718 }
719
720 #[test]
721 fn flagify_bool_named_drops_false() {
722 let mut args = ToolArgs::new();
723 args.named.insert("recursive".into(), Value::Bool(false));
724
725 args.flagify_bool_named(&ToolSchema::new("t", ""));
726
727 assert!(!args.flags.contains("recursive"));
728 assert!(!args.named.contains_key("recursive"));
729 }
730
731 #[test]
732 fn flagify_bool_named_is_idempotent() {
733 let mut args = ToolArgs::new();
734 args.named.insert("recursive".into(), Value::Bool(true));
735 args.flagify_bool_named(&ToolSchema::new("t", ""));
736 args.flagify_bool_named(&ToolSchema::new("t", ""));
737 assert!(args.flags.contains("recursive"));
738 }
739
740 #[test]
743 fn flagify_bool_named_round_trips_through_to_argv() {
744 let mut args = ToolArgs::new();
745 args.named.insert("R".into(), Value::Bool(true));
746 args.flagify_bool_named(&ToolSchema::new("t", ""));
747 let argv = args.to_argv();
748 assert!(argv.contains(&"-R".to_string()), "expected -R, got {:?}", argv);
749 assert!(!argv.iter().any(|s| s.contains('=')), "no =value should appear, got {:?}", argv);
750 }
751
752 #[test]
756 fn flagify_bool_named_keeps_value_flag_value() {
757 let mut schema = ToolSchema::new("spawn", "");
758 schema.params.push(ParamSchema::new("command", "string"));
759
760 let mut args = ToolArgs::new();
761 args.named.insert("command".into(), Value::Bool(true));
762 args.flagify_bool_named(&schema);
763
764 assert!(!args.flags.contains("command"), "value flag must not collapse to a bare flag");
765 assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
766 let argv = args.to_argv();
767 assert!(
768 argv.iter().any(|s| s == "--command=true"),
769 "expected --command=true, got {:?}",
770 argv
771 );
772 }
773
774 #[test]
779 fn flagify_bool_named_distinguishes_bool_from_value_param() {
780 let mut schema = ToolSchema::new("t", "");
781 schema.params.push(ParamSchema::new("verbose", "bool"));
782 schema.params.push(ParamSchema::new("command", "string"));
783
784 let mut args = ToolArgs::new();
785 args.named.insert("verbose".into(), Value::Bool(true));
786 args.named.insert("command".into(), Value::Bool(true));
787 args.flagify_bool_named(&schema);
788
789 assert!(args.flags.contains("verbose"));
791 assert!(!args.named.contains_key("verbose"));
792 assert!(!args.flags.contains("command"));
794 assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
795 }
796}