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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
248 pub raw_argv: bool,
249 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
260 pub glob_passthrough: bool,
261}
262
263impl ToolSchema {
264 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
266 Self {
267 name: name.into(),
268 description: description.into(),
269 params: Vec::new(),
270 examples: Vec::new(),
271 map_positionals: false,
272 subcommands: Vec::new(),
273 aliases: Vec::new(),
274 owns_output: false,
275 raw_argv: false,
276 glob_passthrough: false,
277 }
278 }
279
280 pub fn with_raw_argv(mut self) -> Self {
283 self.raw_argv = true;
284 self
285 }
286
287 pub fn with_glob_passthrough(mut self) -> Self {
291 self.glob_passthrough = true;
292 self
293 }
294
295 pub fn with_positional_mapping(mut self) -> Self {
297 self.map_positionals = true;
298 self
299 }
300
301 pub fn param(mut self, param: ParamSchema) -> Self {
303 self.params.push(param);
304 self
305 }
306
307 pub fn example(mut self, description: impl Into<String>, code: impl Into<String>) -> Self {
309 self.examples.push(Example::new(description, code));
310 self
311 }
312
313 pub fn subcommand(mut self, child: ToolSchema) -> Self {
315 self.subcommands.push(child);
316 self
317 }
318
319 pub fn with_command_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
323 self.aliases = aliases.into_iter().map(Into::into).collect();
324 self
325 }
326
327 pub fn matches_command(&self, word: &str) -> bool {
330 self.name == word || self.aliases.iter().any(|a| a == word)
331 }
332
333 pub fn with_owned_output(mut self) -> Self {
342 self.mark_owned_output();
343 self
344 }
345
346 fn mark_owned_output(&mut self) {
347 self.owns_output = true;
348 if !self.params.iter().any(|p| p.name == "json") {
349 self.params.push(
350 ParamSchema::new("json", "bool").with_description("Render output as JSON"),
351 );
352 }
353 for child in &mut self.subcommands {
354 child.mark_owned_output();
355 }
356 }
357}
358
359#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
361#[non_exhaustive]
362pub struct ToolArgs {
363 pub positional: Vec<Value>,
365 pub named: BTreeMap<String, Value>,
367 pub flags: HashSet<String>,
369}
370
371impl ToolArgs {
372 pub fn new() -> Self {
374 Self::default()
375 }
376
377 pub fn get_positional(&self, index: usize) -> Option<&Value> {
379 self.positional.get(index)
380 }
381
382 pub fn get_named(&self, key: &str) -> Option<&Value> {
384 self.named.get(key)
385 }
386
387 pub fn get(&self, name: &str, positional_index: usize) -> Option<&Value> {
391 self.named.get(name).or_else(|| self.positional.get(positional_index))
392 }
393
394 pub fn get_string(&self, name: &str, positional_index: usize) -> Option<String> {
396 self.get(name, positional_index).and_then(|v| match v {
397 Value::String(s) => Some(s.clone()),
398 Value::Int(i) => Some(i.to_string()),
399 Value::Float(f) => Some(f.to_string()),
400 Value::Bool(b) => Some(b.to_string()),
401 _ => None,
402 })
403 }
404
405 pub fn get_bool(&self, name: &str, positional_index: usize) -> Option<bool> {
407 self.get(name, positional_index).and_then(|v| match v {
408 Value::Bool(b) => Some(*b),
409 Value::String(s) => match s.as_str() {
410 "true" | "yes" | "1" => Some(true),
411 "false" | "no" | "0" => Some(false),
412 _ => None,
413 },
414 Value::Int(i) => Some(*i != 0),
415 _ => None,
416 })
417 }
418
419 pub fn has_flag(&self, name: &str) -> bool {
421 if self.flags.contains(name) {
423 return true;
424 }
425 self.named.get(name).is_some_and(|v| match v {
427 Value::Bool(b) => *b,
428 Value::String(s) => !s.is_empty() && s != "false" && s != "0",
429 _ => true,
430 })
431 }
432
433 pub fn flagify_bool_named(&mut self, schema: &ToolSchema) {
453 let value_keys: HashSet<&str> = schema
456 .params
457 .iter()
458 .filter(|p| !p.positional && !is_bool_param_type(&p.param_type))
459 .flat_map(|p| {
460 std::iter::once(p.name.as_str())
461 .chain(p.aliases.iter().map(|a| a.trim_start_matches('-')))
462 })
463 .collect();
464
465 let bool_keys: Vec<String> = self
466 .named
467 .iter()
468 .filter(|(k, v)| matches!(v, Value::Bool(_)) && !value_keys.contains(k.as_str()))
469 .map(|(k, _)| k.clone())
470 .collect();
471 for k in bool_keys {
472 if let Some(Value::Bool(true)) = self.named.remove(&k) {
476 self.flags.insert(k);
477 }
478 }
479 }
480
481 pub fn to_argv(&self) -> Vec<String> {
494 let mut argv = Vec::with_capacity(
495 self.flags.len() + self.named.len() * 2 + self.positional.len() + 1,
496 );
497
498 let mut flags: Vec<&String> = self.flags.iter().collect();
503 flags.sort();
504 for flag in flags {
505 argv.push(flag_token(flag));
506 }
507
508 for (key, value) in &self.named {
513 for rendered in render_named_value(value) {
514 argv.push(format!("{}={}", flag_token(key), rendered));
515 }
516 }
517
518 if !self.positional.is_empty() {
521 argv.push("--".to_string());
522 for value in &self.positional {
523 argv.push(value_to_argv_token(value));
524 }
525 }
526
527 argv
528 }
529}
530
531fn flag_token(name: &str) -> String {
532 if name.chars().count() == 1 {
533 format!("-{name}")
534 } else {
535 format!("--{name}")
536 }
537}
538
539fn is_bool_param_type(param_type: &str) -> bool {
541 param_type.eq_ignore_ascii_case("bool") || param_type.eq_ignore_ascii_case("boolean")
542}
543
544fn render_named_value(value: &Value) -> Vec<String> {
545 match value {
546 Value::Json(serde_json::Value::Array(outer)) if outer.iter().all(|v| v.is_array()) => {
550 outer
551 .iter()
552 .map(|inner| {
553 inner
554 .as_array()
555 .map(|a| a.iter().map(json_value_to_token).collect::<Vec<_>>().join(" "))
556 .unwrap_or_default()
557 })
558 .collect()
559 }
560 _ => vec![value_to_argv_token(value)],
561 }
562}
563
564fn value_to_argv_token(value: &Value) -> String {
565 match value {
566 Value::Null => String::new(),
567 Value::Bool(b) => b.to_string(),
568 Value::Int(i) => i.to_string(),
569 Value::Float(f) => f.to_string(),
570 Value::String(s) => s.clone(),
571 Value::Json(j) => j.to_string(),
572 Value::Bytes(data) => format!("[binary: {} bytes]", data.len()),
576 }
577}
578
579fn json_value_to_token(value: &serde_json::Value) -> String {
580 match value {
581 serde_json::Value::Null => String::new(),
582 serde_json::Value::Bool(b) => b.to_string(),
583 serde_json::Value::Number(n) => n.to_string(),
584 serde_json::Value::String(s) => s.clone(),
585 other => other.to_string(),
586 }
587}
588
589#[cfg(test)]
590mod schema_serde_tests {
591 use super::*;
592
593 #[test]
596 fn flat_schema_omits_new_fields_on_wire() {
597 let schema = ToolSchema::new("cat", "concatenate")
598 .param(ParamSchema::required("path", "string", "file to read").positional());
599 let json = serde_json::to_value(&schema).expect("serialize");
600 let obj = json.as_object().expect("object");
601 assert!(!obj.contains_key("subcommands"), "flat tool leaks subcommands: {json}");
602 assert!(!obj.contains_key("aliases"), "flat tool leaks command aliases: {json}");
603 }
604
605 #[test]
609 fn flat_wire_form_deserializes_to_empty() {
610 let flat = serde_json::json!({
611 "name": "cat",
612 "description": "concatenate",
613 "params": [],
614 "examples": [],
615 "map_positionals": false
616 });
617 let schema: ToolSchema = serde_json::from_value(flat).expect("deserialize flat form");
618 assert!(schema.subcommands.is_empty());
619 assert!(schema.aliases.is_empty());
620 }
621
622 #[test]
625 fn with_owned_output_marks_tree_and_advertises_json() {
626 let schema = ToolSchema::new("kj", "kaijutsu")
627 .subcommand(
628 ToolSchema::new("context", "ctx")
629 .subcommand(ToolSchema::new("list", "list contexts")),
630 )
631 .with_owned_output();
632
633 assert!(schema.owns_output, "root marked");
634 assert!(schema.params.iter().any(|p| p.name == "json"), "root advertises json");
635 let context = &schema.subcommands[0];
636 assert!(context.owns_output, "child marked");
637 let list = &context.subcommands[0];
638 assert!(list.owns_output, "grandchild marked");
639 assert!(list.params.iter().any(|p| p.name == "json"), "leaf advertises json");
640 }
641
642 #[test]
644 fn with_owned_output_does_not_double_add_json() {
645 let schema = ToolSchema::new("kj", "kaijutsu")
646 .param(ParamSchema::new("json", "bool"))
647 .with_owned_output();
648 let json_count = schema.params.iter().filter(|p| p.name == "json").count();
649 assert_eq!(json_count, 1, "json should appear exactly once");
650 }
651
652 #[test]
654 fn owns_output_serde() {
655 let flat = ToolSchema::new("ls", "list");
656 let json = serde_json::to_value(&flat).expect("serialize");
657 let obj = json.as_object().expect("object");
658 assert!(!obj.contains_key("owns_output"), "false omitted: {json}");
659
660 let owned = ToolSchema::new("kj", "kaijutsu").with_owned_output();
661 let wire = serde_json::to_string(&owned).expect("serialize");
662 let back: ToolSchema = serde_json::from_str(&wire).expect("deserialize");
663 assert!(back.owns_output);
664 }
665
666 #[test]
668 fn subcommand_tree_round_trips() {
669 let schema = ToolSchema::new("kj", "kaijutsu")
670 .subcommand(
671 ToolSchema::new("context", "context ops")
672 .with_command_aliases(["ctx"])
673 .subcommand(ToolSchema::new("list", "list contexts").with_command_aliases(["ls"])),
674 );
675 let json = serde_json::to_string(&schema).expect("serialize");
676 let back: ToolSchema = serde_json::from_str(&json).expect("deserialize");
677 assert_eq!(back.subcommands.len(), 1);
678 let context = &back.subcommands[0];
679 assert!(context.matches_command("context"));
680 assert!(context.matches_command("ctx"));
681 assert_eq!(context.subcommands.len(), 1);
682 assert!(context.subcommands[0].matches_command("ls"));
683 }
684}
685
686#[cfg(test)]
687mod to_argv_tests {
688 use super::*;
689
690 #[test]
691 fn empty_args_produce_empty_argv() {
692 assert!(ToolArgs::new().to_argv().is_empty());
693 }
694
695 #[test]
696 fn positionals_emitted_after_double_dash() {
697 let mut args = ToolArgs::new();
698 args.positional.push(Value::String("hello".into()));
699 args.positional.push(Value::String("world".into()));
700 assert_eq!(args.to_argv(), vec!["--", "hello", "world"]);
701 }
702
703 #[test]
704 fn single_char_flags_emit_short_form() {
705 let mut args = ToolArgs::new();
706 args.flags.insert("n".into());
707 args.flags.insert("verbose".into());
708 assert_eq!(args.to_argv(), vec!["-n", "--verbose"]);
710 }
711
712 #[test]
713 fn named_values_use_equals_form() {
714 let mut args = ToolArgs::new();
715 args.named.insert("count".into(), Value::Int(5));
716 args.named.insert("name".into(), Value::String("foo".into()));
717 assert_eq!(args.to_argv(), vec!["--count=5", "--name=foo"]);
719 }
720
721 #[test]
722 fn single_char_named_emits_short_equals() {
723 let mut args = ToolArgs::new();
724 args.named.insert("n".into(), Value::Int(5));
725 assert_eq!(args.to_argv(), vec!["-n=5"]);
726 }
727
728 #[test]
729 fn positional_with_leading_dash_survives_double_dash() {
730 let mut args = ToolArgs::new();
731 args.positional.push(Value::String("-n".into()));
732 assert_eq!(args.to_argv(), vec!["--", "-n"]);
734 }
735
736 #[test]
737 fn mixed_flags_named_positionals() {
738 let mut args = ToolArgs::new();
739 args.flags.insert("verbose".into());
740 args.named.insert("limit".into(), Value::Int(10));
741 args.positional.push(Value::String("file.txt".into()));
742 assert_eq!(
743 args.to_argv(),
744 vec!["--verbose", "--limit=10", "--", "file.txt"]
745 );
746 }
747
748 #[test]
749 fn flagify_bool_named_promotes_true_to_flag() {
750 let mut args = ToolArgs::new();
751 args.named.insert("recursive".into(), Value::Bool(true));
752 args.named.insert("limit".into(), Value::Int(5));
753
754 args.flagify_bool_named(&ToolSchema::new("t", ""));
755
756 assert!(args.flags.contains("recursive"));
757 assert!(!args.named.contains_key("recursive"));
758 assert_eq!(args.named.get("limit"), Some(&Value::Int(5)));
760 }
761
762 #[test]
763 fn flagify_bool_named_drops_false() {
764 let mut args = ToolArgs::new();
765 args.named.insert("recursive".into(), Value::Bool(false));
766
767 args.flagify_bool_named(&ToolSchema::new("t", ""));
768
769 assert!(!args.flags.contains("recursive"));
770 assert!(!args.named.contains_key("recursive"));
771 }
772
773 #[test]
774 fn flagify_bool_named_is_idempotent() {
775 let mut args = ToolArgs::new();
776 args.named.insert("recursive".into(), Value::Bool(true));
777 args.flagify_bool_named(&ToolSchema::new("t", ""));
778 args.flagify_bool_named(&ToolSchema::new("t", ""));
779 assert!(args.flags.contains("recursive"));
780 }
781
782 #[test]
785 fn flagify_bool_named_round_trips_through_to_argv() {
786 let mut args = ToolArgs::new();
787 args.named.insert("R".into(), Value::Bool(true));
788 args.flagify_bool_named(&ToolSchema::new("t", ""));
789 let argv = args.to_argv();
790 assert!(argv.contains(&"-R".to_string()), "expected -R, got {:?}", argv);
791 assert!(!argv.iter().any(|s| s.contains('=')), "no =value should appear, got {:?}", argv);
792 }
793
794 #[test]
798 fn flagify_bool_named_keeps_value_flag_value() {
799 let mut schema = ToolSchema::new("spawn", "");
800 schema.params.push(ParamSchema::new("command", "string"));
801
802 let mut args = ToolArgs::new();
803 args.named.insert("command".into(), Value::Bool(true));
804 args.flagify_bool_named(&schema);
805
806 assert!(!args.flags.contains("command"), "value flag must not collapse to a bare flag");
807 assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
808 let argv = args.to_argv();
809 assert!(
810 argv.iter().any(|s| s == "--command=true"),
811 "expected --command=true, got {:?}",
812 argv
813 );
814 }
815
816 #[test]
821 fn flagify_bool_named_distinguishes_bool_from_value_param() {
822 let mut schema = ToolSchema::new("t", "");
823 schema.params.push(ParamSchema::new("verbose", "bool"));
824 schema.params.push(ParamSchema::new("command", "string"));
825
826 let mut args = ToolArgs::new();
827 args.named.insert("verbose".into(), Value::Bool(true));
828 args.named.insert("command".into(), Value::Bool(true));
829 args.flagify_bool_named(&schema);
830
831 assert!(args.flags.contains("verbose"));
833 assert!(!args.named.contains_key("verbose"));
834 assert!(!args.flags.contains("command"));
836 assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
837 }
838}