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}
250
251impl ToolSchema {
252 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
254 Self {
255 name: name.into(),
256 description: description.into(),
257 params: Vec::new(),
258 examples: Vec::new(),
259 map_positionals: false,
260 subcommands: Vec::new(),
261 aliases: Vec::new(),
262 owns_output: false,
263 raw_argv: false,
264 }
265 }
266
267 pub fn with_raw_argv(mut self) -> Self {
270 self.raw_argv = true;
271 self
272 }
273
274 pub fn with_positional_mapping(mut self) -> Self {
276 self.map_positionals = true;
277 self
278 }
279
280 pub fn param(mut self, param: ParamSchema) -> Self {
282 self.params.push(param);
283 self
284 }
285
286 pub fn example(mut self, description: impl Into<String>, code: impl Into<String>) -> Self {
288 self.examples.push(Example::new(description, code));
289 self
290 }
291
292 pub fn subcommand(mut self, child: ToolSchema) -> Self {
294 self.subcommands.push(child);
295 self
296 }
297
298 pub fn with_command_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
302 self.aliases = aliases.into_iter().map(Into::into).collect();
303 self
304 }
305
306 pub fn matches_command(&self, word: &str) -> bool {
309 self.name == word || self.aliases.iter().any(|a| a == word)
310 }
311
312 pub fn with_owned_output(mut self) -> Self {
321 self.mark_owned_output();
322 self
323 }
324
325 fn mark_owned_output(&mut self) {
326 self.owns_output = true;
327 if !self.params.iter().any(|p| p.name == "json") {
328 self.params.push(
329 ParamSchema::new("json", "bool").with_description("Render output as JSON"),
330 );
331 }
332 for child in &mut self.subcommands {
333 child.mark_owned_output();
334 }
335 }
336}
337
338#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
340#[non_exhaustive]
341pub struct ToolArgs {
342 pub positional: Vec<Value>,
344 pub named: BTreeMap<String, Value>,
346 pub flags: HashSet<String>,
348}
349
350impl ToolArgs {
351 pub fn new() -> Self {
353 Self::default()
354 }
355
356 pub fn get_positional(&self, index: usize) -> Option<&Value> {
358 self.positional.get(index)
359 }
360
361 pub fn get_named(&self, key: &str) -> Option<&Value> {
363 self.named.get(key)
364 }
365
366 pub fn get(&self, name: &str, positional_index: usize) -> Option<&Value> {
370 self.named.get(name).or_else(|| self.positional.get(positional_index))
371 }
372
373 pub fn get_string(&self, name: &str, positional_index: usize) -> Option<String> {
375 self.get(name, positional_index).and_then(|v| match v {
376 Value::String(s) => Some(s.clone()),
377 Value::Int(i) => Some(i.to_string()),
378 Value::Float(f) => Some(f.to_string()),
379 Value::Bool(b) => Some(b.to_string()),
380 _ => None,
381 })
382 }
383
384 pub fn get_bool(&self, name: &str, positional_index: usize) -> Option<bool> {
386 self.get(name, positional_index).and_then(|v| match v {
387 Value::Bool(b) => Some(*b),
388 Value::String(s) => match s.as_str() {
389 "true" | "yes" | "1" => Some(true),
390 "false" | "no" | "0" => Some(false),
391 _ => None,
392 },
393 Value::Int(i) => Some(*i != 0),
394 _ => None,
395 })
396 }
397
398 pub fn has_flag(&self, name: &str) -> bool {
400 if self.flags.contains(name) {
402 return true;
403 }
404 self.named.get(name).is_some_and(|v| match v {
406 Value::Bool(b) => *b,
407 Value::String(s) => !s.is_empty() && s != "false" && s != "0",
408 _ => true,
409 })
410 }
411
412 pub fn flagify_bool_named(&mut self, schema: &ToolSchema) {
432 let value_keys: HashSet<&str> = schema
435 .params
436 .iter()
437 .filter(|p| !p.positional && !is_bool_param_type(&p.param_type))
438 .flat_map(|p| {
439 std::iter::once(p.name.as_str())
440 .chain(p.aliases.iter().map(|a| a.trim_start_matches('-')))
441 })
442 .collect();
443
444 let bool_keys: Vec<String> = self
445 .named
446 .iter()
447 .filter(|(k, v)| matches!(v, Value::Bool(_)) && !value_keys.contains(k.as_str()))
448 .map(|(k, _)| k.clone())
449 .collect();
450 for k in bool_keys {
451 if let Some(Value::Bool(true)) = self.named.remove(&k) {
455 self.flags.insert(k);
456 }
457 }
458 }
459
460 pub fn to_argv(&self) -> Vec<String> {
473 let mut argv = Vec::with_capacity(
474 self.flags.len() + self.named.len() * 2 + self.positional.len() + 1,
475 );
476
477 let mut flags: Vec<&String> = self.flags.iter().collect();
482 flags.sort();
483 for flag in flags {
484 argv.push(flag_token(flag));
485 }
486
487 for (key, value) in &self.named {
492 for rendered in render_named_value(value) {
493 argv.push(format!("{}={}", flag_token(key), rendered));
494 }
495 }
496
497 if !self.positional.is_empty() {
500 argv.push("--".to_string());
501 for value in &self.positional {
502 argv.push(value_to_argv_token(value));
503 }
504 }
505
506 argv
507 }
508}
509
510fn flag_token(name: &str) -> String {
511 if name.chars().count() == 1 {
512 format!("-{name}")
513 } else {
514 format!("--{name}")
515 }
516}
517
518fn is_bool_param_type(param_type: &str) -> bool {
520 param_type.eq_ignore_ascii_case("bool") || param_type.eq_ignore_ascii_case("boolean")
521}
522
523fn render_named_value(value: &Value) -> Vec<String> {
524 match value {
525 Value::Json(serde_json::Value::Array(outer)) if outer.iter().all(|v| v.is_array()) => {
529 outer
530 .iter()
531 .map(|inner| {
532 inner
533 .as_array()
534 .map(|a| a.iter().map(json_value_to_token).collect::<Vec<_>>().join(" "))
535 .unwrap_or_default()
536 })
537 .collect()
538 }
539 _ => vec![value_to_argv_token(value)],
540 }
541}
542
543fn value_to_argv_token(value: &Value) -> String {
544 match value {
545 Value::Null => String::new(),
546 Value::Bool(b) => b.to_string(),
547 Value::Int(i) => i.to_string(),
548 Value::Float(f) => f.to_string(),
549 Value::String(s) => s.clone(),
550 Value::Json(j) => j.to_string(),
551 Value::Bytes(data) => format!("[binary: {} bytes]", data.len()),
555 }
556}
557
558fn json_value_to_token(value: &serde_json::Value) -> String {
559 match value {
560 serde_json::Value::Null => String::new(),
561 serde_json::Value::Bool(b) => b.to_string(),
562 serde_json::Value::Number(n) => n.to_string(),
563 serde_json::Value::String(s) => s.clone(),
564 other => other.to_string(),
565 }
566}
567
568#[cfg(test)]
569mod schema_serde_tests {
570 use super::*;
571
572 #[test]
575 fn flat_schema_omits_new_fields_on_wire() {
576 let schema = ToolSchema::new("cat", "concatenate")
577 .param(ParamSchema::required("path", "string", "file to read").positional());
578 let json = serde_json::to_value(&schema).expect("serialize");
579 let obj = json.as_object().expect("object");
580 assert!(!obj.contains_key("subcommands"), "flat tool leaks subcommands: {json}");
581 assert!(!obj.contains_key("aliases"), "flat tool leaks command aliases: {json}");
582 }
583
584 #[test]
588 fn flat_wire_form_deserializes_to_empty() {
589 let flat = serde_json::json!({
590 "name": "cat",
591 "description": "concatenate",
592 "params": [],
593 "examples": [],
594 "map_positionals": false
595 });
596 let schema: ToolSchema = serde_json::from_value(flat).expect("deserialize flat form");
597 assert!(schema.subcommands.is_empty());
598 assert!(schema.aliases.is_empty());
599 }
600
601 #[test]
604 fn with_owned_output_marks_tree_and_advertises_json() {
605 let schema = ToolSchema::new("kj", "kaijutsu")
606 .subcommand(
607 ToolSchema::new("context", "ctx")
608 .subcommand(ToolSchema::new("list", "list contexts")),
609 )
610 .with_owned_output();
611
612 assert!(schema.owns_output, "root marked");
613 assert!(schema.params.iter().any(|p| p.name == "json"), "root advertises json");
614 let context = &schema.subcommands[0];
615 assert!(context.owns_output, "child marked");
616 let list = &context.subcommands[0];
617 assert!(list.owns_output, "grandchild marked");
618 assert!(list.params.iter().any(|p| p.name == "json"), "leaf advertises json");
619 }
620
621 #[test]
623 fn with_owned_output_does_not_double_add_json() {
624 let schema = ToolSchema::new("kj", "kaijutsu")
625 .param(ParamSchema::new("json", "bool"))
626 .with_owned_output();
627 let json_count = schema.params.iter().filter(|p| p.name == "json").count();
628 assert_eq!(json_count, 1, "json should appear exactly once");
629 }
630
631 #[test]
633 fn owns_output_serde() {
634 let flat = ToolSchema::new("ls", "list");
635 let json = serde_json::to_value(&flat).expect("serialize");
636 let obj = json.as_object().expect("object");
637 assert!(!obj.contains_key("owns_output"), "false omitted: {json}");
638
639 let owned = ToolSchema::new("kj", "kaijutsu").with_owned_output();
640 let wire = serde_json::to_string(&owned).expect("serialize");
641 let back: ToolSchema = serde_json::from_str(&wire).expect("deserialize");
642 assert!(back.owns_output);
643 }
644
645 #[test]
647 fn subcommand_tree_round_trips() {
648 let schema = ToolSchema::new("kj", "kaijutsu")
649 .subcommand(
650 ToolSchema::new("context", "context ops")
651 .with_command_aliases(["ctx"])
652 .subcommand(ToolSchema::new("list", "list contexts").with_command_aliases(["ls"])),
653 );
654 let json = serde_json::to_string(&schema).expect("serialize");
655 let back: ToolSchema = serde_json::from_str(&json).expect("deserialize");
656 assert_eq!(back.subcommands.len(), 1);
657 let context = &back.subcommands[0];
658 assert!(context.matches_command("context"));
659 assert!(context.matches_command("ctx"));
660 assert_eq!(context.subcommands.len(), 1);
661 assert!(context.subcommands[0].matches_command("ls"));
662 }
663}
664
665#[cfg(test)]
666mod to_argv_tests {
667 use super::*;
668
669 #[test]
670 fn empty_args_produce_empty_argv() {
671 assert!(ToolArgs::new().to_argv().is_empty());
672 }
673
674 #[test]
675 fn positionals_emitted_after_double_dash() {
676 let mut args = ToolArgs::new();
677 args.positional.push(Value::String("hello".into()));
678 args.positional.push(Value::String("world".into()));
679 assert_eq!(args.to_argv(), vec!["--", "hello", "world"]);
680 }
681
682 #[test]
683 fn single_char_flags_emit_short_form() {
684 let mut args = ToolArgs::new();
685 args.flags.insert("n".into());
686 args.flags.insert("verbose".into());
687 assert_eq!(args.to_argv(), vec!["-n", "--verbose"]);
689 }
690
691 #[test]
692 fn named_values_use_equals_form() {
693 let mut args = ToolArgs::new();
694 args.named.insert("count".into(), Value::Int(5));
695 args.named.insert("name".into(), Value::String("foo".into()));
696 assert_eq!(args.to_argv(), vec!["--count=5", "--name=foo"]);
698 }
699
700 #[test]
701 fn single_char_named_emits_short_equals() {
702 let mut args = ToolArgs::new();
703 args.named.insert("n".into(), Value::Int(5));
704 assert_eq!(args.to_argv(), vec!["-n=5"]);
705 }
706
707 #[test]
708 fn positional_with_leading_dash_survives_double_dash() {
709 let mut args = ToolArgs::new();
710 args.positional.push(Value::String("-n".into()));
711 assert_eq!(args.to_argv(), vec!["--", "-n"]);
713 }
714
715 #[test]
716 fn mixed_flags_named_positionals() {
717 let mut args = ToolArgs::new();
718 args.flags.insert("verbose".into());
719 args.named.insert("limit".into(), Value::Int(10));
720 args.positional.push(Value::String("file.txt".into()));
721 assert_eq!(
722 args.to_argv(),
723 vec!["--verbose", "--limit=10", "--", "file.txt"]
724 );
725 }
726
727 #[test]
728 fn flagify_bool_named_promotes_true_to_flag() {
729 let mut args = ToolArgs::new();
730 args.named.insert("recursive".into(), Value::Bool(true));
731 args.named.insert("limit".into(), Value::Int(5));
732
733 args.flagify_bool_named(&ToolSchema::new("t", ""));
734
735 assert!(args.flags.contains("recursive"));
736 assert!(!args.named.contains_key("recursive"));
737 assert_eq!(args.named.get("limit"), Some(&Value::Int(5)));
739 }
740
741 #[test]
742 fn flagify_bool_named_drops_false() {
743 let mut args = ToolArgs::new();
744 args.named.insert("recursive".into(), Value::Bool(false));
745
746 args.flagify_bool_named(&ToolSchema::new("t", ""));
747
748 assert!(!args.flags.contains("recursive"));
749 assert!(!args.named.contains_key("recursive"));
750 }
751
752 #[test]
753 fn flagify_bool_named_is_idempotent() {
754 let mut args = ToolArgs::new();
755 args.named.insert("recursive".into(), Value::Bool(true));
756 args.flagify_bool_named(&ToolSchema::new("t", ""));
757 args.flagify_bool_named(&ToolSchema::new("t", ""));
758 assert!(args.flags.contains("recursive"));
759 }
760
761 #[test]
764 fn flagify_bool_named_round_trips_through_to_argv() {
765 let mut args = ToolArgs::new();
766 args.named.insert("R".into(), Value::Bool(true));
767 args.flagify_bool_named(&ToolSchema::new("t", ""));
768 let argv = args.to_argv();
769 assert!(argv.contains(&"-R".to_string()), "expected -R, got {:?}", argv);
770 assert!(!argv.iter().any(|s| s.contains('=')), "no =value should appear, got {:?}", argv);
771 }
772
773 #[test]
777 fn flagify_bool_named_keeps_value_flag_value() {
778 let mut schema = ToolSchema::new("spawn", "");
779 schema.params.push(ParamSchema::new("command", "string"));
780
781 let mut args = ToolArgs::new();
782 args.named.insert("command".into(), Value::Bool(true));
783 args.flagify_bool_named(&schema);
784
785 assert!(!args.flags.contains("command"), "value flag must not collapse to a bare flag");
786 assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
787 let argv = args.to_argv();
788 assert!(
789 argv.iter().any(|s| s == "--command=true"),
790 "expected --command=true, got {:?}",
791 argv
792 );
793 }
794
795 #[test]
800 fn flagify_bool_named_distinguishes_bool_from_value_param() {
801 let mut schema = ToolSchema::new("t", "");
802 schema.params.push(ParamSchema::new("verbose", "bool"));
803 schema.params.push(ParamSchema::new("command", "string"));
804
805 let mut args = ToolArgs::new();
806 args.named.insert("verbose".into(), Value::Bool(true));
807 args.named.insert("command".into(), Value::Bool(true));
808 args.flagify_bool_named(&schema);
809
810 assert!(args.flags.contains("verbose"));
812 assert!(!args.named.contains_key("verbose"));
813 assert!(!args.flags.contains("command"));
815 assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
816 }
817}