1use async_trait::async_trait;
9use bashkit::ExecResult;
10use bashkit::Result;
11use bashkit::{Builtin, BuiltinContext as Context};
12use std::future::Future;
13use std::pin::Pin;
14use std::sync::Arc;
15
16#[derive(Clone)]
26pub struct ToolDef {
27 pub name: String,
29 pub description: String,
31 pub input_schema: serde_json::Value,
33 pub tags: Vec<String>,
35 pub category: Option<String>,
37}
38
39impl ToolDef {
40 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
42 Self {
43 name: name.into(),
44 description: description.into(),
45 input_schema: serde_json::Value::Object(Default::default()),
46 tags: Vec::new(),
47 category: None,
48 }
49 }
50
51 pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
53 self.input_schema = schema;
54 self
55 }
56
57 pub fn with_tags(mut self, tags: &[&str]) -> Self {
59 self.tags = tags.iter().map(|s| s.to_string()).collect();
60 self
61 }
62
63 pub fn with_category(mut self, category: &str) -> Self {
65 self.category = Some(category.to_string());
66 self
67 }
68}
69
70pub struct ToolArgs {
80 pub params: serde_json::Value,
82 pub stdin: Option<String>,
84 context: ToolArgsContext,
85}
86
87#[derive(Default)]
88struct ToolArgsContext {
89 capability: Option<bashkit::ExecutionCapability<ToolArgsContextData>>,
90}
91
92#[derive(Clone)]
93pub(crate) struct ToolArgsContextData {
94 tenant_id: Option<String>,
95 surface: Option<crate::tool_registry::ToolCallSurface>,
96}
97
98impl ToolArgsContextData {
99 pub(crate) fn new(
100 tenant_id: Option<String>,
101 surface: crate::tool_registry::ToolCallSurface,
102 ) -> Self {
103 Self {
104 tenant_id,
105 surface: Some(surface),
106 }
107 }
108}
109
110impl ToolArgs {
111 pub fn new(params: serde_json::Value, stdin: Option<String>) -> Self {
113 Self {
114 params,
115 stdin,
116 context: ToolArgsContext::default(),
117 }
118 }
119
120 pub(crate) fn with_context(
121 params: serde_json::Value,
122 stdin: Option<String>,
123 capability: bashkit::ExecutionCapability<ToolArgsContextData>,
124 ) -> Self {
125 Self {
126 params,
127 stdin,
128 context: ToolArgsContext {
129 capability: Some(capability),
130 },
131 }
132 }
133
134 pub fn tenant_id(
136 &self,
137 ) -> std::result::Result<Option<String>, bashkit::ExecutionCapabilityError> {
138 self.context
139 .capability
140 .as_ref()
141 .map(|capability| capability.try_with(|context| context.tenant_id.clone()))
142 .transpose()
143 .map(Option::flatten)
144 }
145
146 pub fn surface(
148 &self,
149 ) -> std::result::Result<
150 Option<crate::tool_registry::ToolCallSurface>,
151 bashkit::ExecutionCapabilityError,
152 > {
153 self.context
154 .capability
155 .as_ref()
156 .map(|capability| capability.try_with(|context| context.surface))
157 .transpose()
158 .map(Option::flatten)
159 }
160 pub fn param_str(&self, key: &str) -> Option<&str> {
162 self.params.get(key).and_then(|v| v.as_str())
163 }
164
165 pub fn param_i64(&self, key: &str) -> Option<i64> {
167 self.params.get(key).and_then(|v| v.as_i64())
168 }
169
170 pub fn param_f64(&self, key: &str) -> Option<f64> {
172 self.params.get(key).and_then(|v| v.as_f64())
173 }
174
175 pub fn param_bool(&self, key: &str) -> Option<bool> {
177 self.params.get(key).and_then(|v| v.as_bool())
178 }
179}
180
181pub type SyncToolExec = Arc<dyn Fn(&ToolArgs) -> std::result::Result<String, String> + Send + Sync>;
190
191pub type AsyncToolExec = Arc<
197 dyn Fn(ToolArgs) -> Pin<Box<dyn Future<Output = std::result::Result<String, String>> + Send>>
198 + Send
199 + Sync,
200>;
201
202pub type ToolCallback = SyncToolExec;
205pub type AsyncToolCallback = AsyncToolExec;
207
208#[derive(Clone)]
235pub struct ToolImpl {
236 pub def: ToolDef,
238 pub exec: Option<AsyncToolExec>,
240 pub exec_sync: Option<SyncToolExec>,
242 pub sanitize_errors: bool,
244}
245
246impl ToolImpl {
247 pub fn new(def: ToolDef) -> Self {
249 Self {
250 def,
251 exec: None,
252 exec_sync: None,
253 sanitize_errors: true,
254 }
255 }
256
257 pub fn with_exec<F, Fut>(mut self, f: F) -> Self
259 where
260 F: Fn(ToolArgs) -> Fut + Send + Sync + 'static,
261 Fut: Future<Output = std::result::Result<String, String>> + Send + 'static,
262 {
263 self.exec = Some(Arc::new(move |args| Box::pin(f(args))));
264 self
265 }
266
267 pub fn with_exec_sync(
269 mut self,
270 f: impl Fn(&ToolArgs) -> std::result::Result<String, String> + Send + Sync + 'static,
271 ) -> Self {
272 self.exec_sync = Some(Arc::new(f));
273 self
274 }
275
276 pub fn sanitize_errors(mut self, sanitize: bool) -> Self {
285 self.sanitize_errors = sanitize;
286 self
287 }
288
289 fn callback_failed_message(&self) -> String {
290 format!("{}: callback failed\n", self.def.name)
291 }
292}
293
294#[async_trait]
295impl Builtin for ToolImpl {
296 async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
297 let params = parse_flags(ctx.args, &self.def.input_schema)
298 .map_err(|e| bashkit::Error::Execution(format!("{}: {e}", self.def.name)))?;
299 let context = ToolArgsContextData::new(None, crate::tool_registry::ToolCallSurface::Shell);
300 let stdin = ctx.stdin.map(String::from);
301 let tool_args = match ctx.execution_capability(context) {
302 Some(capability) => ToolArgs::with_context(params, stdin, capability),
303 None => ToolArgs::new(params, stdin),
306 };
307
308 let result = if let Some(cb) = &self.exec {
310 ctx.run_budgeted((cb)(tool_args)).await?
311 } else if let Some(cb) = &self.exec_sync {
312 if let Some(budget) = ctx.execution_budget() {
313 budget
314 .try_with(|budget| budget.check())
315 .map_err(|_| bashkit::Error::Cancelled)??;
316 }
317 let result = (cb)(&tool_args);
318 if let Some(budget) = ctx.execution_budget() {
319 budget
320 .try_with(|budget| budget.check())
321 .map_err(|_| bashkit::Error::Cancelled)??;
322 }
323 result
324 } else {
325 return Err(bashkit::Error::Execution(format!(
326 "{}: no exec defined",
327 self.def.name
328 )));
329 };
330
331 match result {
332 Ok(stdout) => Ok(ExecResult::ok(stdout)),
333 Err(msg) if self.sanitize_errors => {
334 #[cfg(not(feature = "tracing"))]
335 let _ = &msg;
336 #[cfg(feature = "tracing")]
337 tracing::debug!(
338 tool = %self.def.name,
339 error = %msg,
340 "tool callback error (sanitized)"
341 );
342 Ok(ExecResult::err(self.callback_failed_message(), 1))
343 }
344 Err(msg) => Ok(ExecResult::err(msg, 1)),
345 }
346 }
347}
348
349fn unknown_flag_error(
369 key: &str,
370 properties: &serde_json::Map<String, serde_json::Value>,
371) -> String {
372 if properties.is_empty() {
373 return format!("unknown flag: --{key} (this command takes no flags)");
374 }
375 let mut known: Vec<&str> = properties.keys().map(String::as_str).collect();
376 known.sort_unstable();
377 let known = known
378 .iter()
379 .map(|name| format!("--{name}"))
380 .collect::<Vec<_>>()
381 .join(", ");
382 format!("unknown flag: --{key} (expected one of: {known})")
383}
384
385pub(crate) fn parse_flags(
386 raw_args: &[String],
387 schema: &serde_json::Value,
388) -> std::result::Result<serde_json::Value, String> {
389 let mut budget = FlagParseBudget::default();
390 parse_flags_with_budget(raw_args, schema, &mut budget)
391}
392
393fn parse_flags_with_budget(
394 raw_args: &[String],
395 schema: &serde_json::Value,
396 budget: &mut FlagParseBudget,
397) -> std::result::Result<serde_json::Value, String> {
398 let properties = schema
399 .get("properties")
400 .and_then(|p| p.as_object())
401 .cloned()
402 .unwrap_or_default();
403 let reject_unknown =
408 schema.get("additionalProperties") == Some(&serde_json::Value::Bool(false));
409
410 let mut result = serde_json::Map::new();
411 let mut i = 0;
412
413 while i < raw_args.len() {
414 let arg = &raw_args[i];
415
416 let Some(flag) = arg.strip_prefix("--") else {
417 return Err(format!("expected --flag, got: {arg}"));
418 };
419
420 if let Some((key, raw_value)) = flag.split_once('=') {
422 if reject_unknown && !properties.contains_key(key) {
423 return Err(unknown_flag_error(key, &properties));
424 }
425 budget.add_bytes(raw_value.len(), key)?;
426 let value = coerce_value(raw_value, properties.get(key), schema);
427 result.insert(key.to_string(), value);
428 i += 1;
429 continue;
430 }
431
432 let key = flag.to_string();
433 if reject_unknown && !properties.contains_key(&key) {
434 return Err(unknown_flag_error(&key, &properties));
435 }
436 let prop_schema = properties.get(&key).cloned();
437 let effective = prop_schema
438 .as_ref()
439 .map(|s| resolve_effective_type(s, schema, 0))
440 .unwrap_or(EffectiveType::Unknown);
441
442 i += 1;
443
444 match effective {
445 EffectiveType::Boolean => {
446 result.insert(key, serde_json::Value::Bool(true));
447 }
448 EffectiveType::Array => {
449 let items_schema = prop_schema.as_ref().and_then(|s| s.get("items")).cloned();
450 let items_effective = items_schema
451 .as_ref()
452 .map(|s| resolve_effective_type(s, schema, 0))
453 .unwrap_or(EffectiveType::Unknown);
454
455 match consume_array_value(
456 raw_args,
457 &mut i,
458 items_schema.as_ref(),
459 items_effective,
460 schema,
461 &key,
462 budget,
463 )? {
464 ArrayInput::Items(items) => {
465 let entry = result
466 .entry(key.clone())
467 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
468 if let serde_json::Value::Array(arr) = entry {
469 ensure_array_item_limit(&key, arr.len(), items.len())?;
470 arr.extend(items);
471 } else {
472 ensure_array_item_limit(&key, 0, items.len())?;
473 *entry = serde_json::Value::Array(items);
474 }
475 }
476 ArrayInput::Raw(value) => {
477 result.insert(key, value);
480 }
481 }
482 }
483 EffectiveType::Object => {
484 let value = consume_object_value(
485 raw_args,
486 &mut i,
487 prop_schema.as_ref(),
488 schema,
489 &key,
490 budget,
491 )?;
492 result.insert(key, value);
493 }
494 _ => {
495 if i < raw_args.len() && !raw_args[i].starts_with("--") {
496 let raw_value = &raw_args[i];
497 budget.add_bytes(raw_value.len(), &key)?;
498 let value = coerce_value(raw_value, prop_schema.as_ref(), schema);
499 result.insert(key, value);
500 i += 1;
501 } else {
502 result.insert(key, serde_json::Value::Bool(true));
503 }
504 }
505 }
506 }
507
508 Ok(serde_json::Value::Object(result))
509}
510
511fn resolve_object_properties(
514 schema: &serde_json::Value,
515 root_schema: &serde_json::Value,
516 depth: usize,
517) -> serde_json::Map<String, serde_json::Value> {
518 if depth > MAX_REF_DEPTH {
519 return Default::default();
520 }
521 if let Some(ref_str) = schema.get("$ref").and_then(|r| r.as_str()) {
522 if let Some(target) = resolve_ref(ref_str, root_schema) {
523 return resolve_object_properties(target, root_schema, depth + 1);
524 }
525 return Default::default();
526 }
527 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
528 return props.clone();
529 }
530 let mut merged = serde_json::Map::new();
531 for key in ["oneOf", "anyOf", "allOf"] {
532 if let Some(branches) = schema.get(key).and_then(|v| v.as_array()) {
533 for branch in branches {
534 let props = resolve_object_properties(branch, root_schema, depth + 1);
535 for (k, v) in props {
536 merged.entry(k).or_insert(v);
537 }
538 }
539 }
540 }
541 merged
542}
543
544fn collect_object_from_pairs(
546 args: &[String],
547 i: &mut usize,
548 object_schema: Option<&serde_json::Value>,
549 root_schema: &serde_json::Value,
550 flag_name: &str,
551 budget: &mut FlagParseBudget,
552) -> std::result::Result<serde_json::Map<String, serde_json::Value>, String> {
553 let mut obj = serde_json::Map::new();
554 let inner_props = object_schema
555 .map(|s| resolve_object_properties(s, root_schema, 0))
556 .unwrap_or_default();
557
558 while *i < args.len() {
559 let arg = &args[*i];
560 if arg.starts_with("--") {
561 break;
562 }
563 let Some((k, v)) = arg.split_once('=') else {
564 return Err(format!(
565 "--{flag_name}: expected --flag or key=value, got '{arg}'"
566 ));
567 };
568
569 if !inner_props.is_empty() && !inner_props.contains_key(k) {
570 let mut valid: Vec<&str> = inner_props.keys().map(|s| s.as_str()).collect();
571 valid.sort();
572 return Err(format!(
573 "--{flag_name}: unknown key '{k}'; valid keys: {}",
574 valid.join(", ")
575 ));
576 }
577
578 budget.add_bytes(k.len().saturating_add(v.len()), flag_name)?;
579 let nested_schema = inner_props.get(k);
580 let value = coerce_value(v, nested_schema, root_schema);
581 obj.insert(k.to_string(), value);
582 *i += 1;
583 }
584 Ok(obj)
585}
586
587enum ArrayInput {
589 Items(Vec<serde_json::Value>),
591 Raw(serde_json::Value),
594}
595
596fn consume_array_value(
600 args: &[String],
601 i: &mut usize,
602 items_schema: Option<&serde_json::Value>,
603 items_effective: EffectiveType,
604 root_schema: &serde_json::Value,
605 flag_name: &str,
606 budget: &mut FlagParseBudget,
607) -> std::result::Result<ArrayInput, String> {
608 if *i >= args.len() || args[*i].starts_with("--") {
609 return Err(format!("--{flag_name}: missing value"));
610 }
611 let next = &args[*i];
612 let trimmed = next.trim_start();
613
614 if trimmed.starts_with('[') {
615 if let Some(serde_json::Value::Array(arr)) = parse_aggregate_json_value(next) {
616 ensure_array_item_limit(flag_name, 0, arr.len())?;
617 budget.add_bytes(next.len(), flag_name)?;
618 *i += 1;
619 return Ok(ArrayInput::Items(arr));
620 }
621 *i += 1;
622 return Ok(ArrayInput::Raw(serde_json::Value::String(next.clone())));
623 }
624
625 if items_effective == EffectiveType::Object {
626 if trimmed.starts_with('{') {
627 if let Some(parsed) = parse_aggregate_json_value(next) {
628 budget.add_bytes(next.len(), flag_name)?;
629 *i += 1;
630 return Ok(ArrayInput::Items(vec![parsed]));
631 }
632 *i += 1;
633 return Ok(ArrayInput::Raw(serde_json::Value::String(next.clone())));
634 }
635 if next.contains('=') {
636 let obj =
637 collect_object_from_pairs(args, i, items_schema, root_schema, flag_name, budget)?;
638 return Ok(ArrayInput::Items(vec![serde_json::Value::Object(obj)]));
639 }
640 return Err(format!(
641 "--{flag_name}: expected JSON or key=value pairs, got '{next}'"
642 ));
643 }
644
645 let item_count = next.split(',').count();
647 ensure_array_item_limit(flag_name, 0, item_count)?;
648 budget.add_bytes(next.len(), flag_name)?;
649 let mut out = Vec::with_capacity(item_count);
650 for part in next.split(',') {
651 out.push(coerce_value(part, items_schema, root_schema));
652 }
653 *i += 1;
654 Ok(ArrayInput::Items(out))
655}
656
657fn consume_object_value(
660 args: &[String],
661 i: &mut usize,
662 prop_schema: Option<&serde_json::Value>,
663 root_schema: &serde_json::Value,
664 flag_name: &str,
665 budget: &mut FlagParseBudget,
666) -> std::result::Result<serde_json::Value, String> {
667 if *i >= args.len() || args[*i].starts_with("--") {
668 return Ok(serde_json::Value::Bool(true));
669 }
670 let next = &args[*i];
671 let trimmed = next.trim_start();
672
673 if trimmed.starts_with('{') || trimmed.starts_with('[') {
674 budget.add_bytes(next.len(), flag_name)?;
675 let value = coerce_value(next, prop_schema, root_schema);
676 *i += 1;
677 return Ok(value);
678 }
679
680 if next.contains('=') {
681 let obj = collect_object_from_pairs(args, i, prop_schema, root_schema, flag_name, budget)?;
682 return Ok(serde_json::Value::Object(obj));
683 }
684
685 budget.add_bytes(next.len(), flag_name)?;
686 let value = coerce_value(next, prop_schema, root_schema);
687 *i += 1;
688 Ok(value)
689}
690
691#[derive(PartialEq, Clone, Copy, Debug)]
696enum EffectiveType {
697 String,
698 Integer,
699 Number,
700 Boolean,
701 Array,
702 Object,
703 Unknown,
704}
705
706const MAX_REF_DEPTH: usize = 16;
707const MAX_AGGREGATE_FLAG_JSON_BYTES: usize = 64 * 1024;
708const MAX_PARSED_FLAG_BYTES: usize = 64 * 1024;
709const MAX_ARRAY_FLAG_ITEMS: usize = 4096;
710
711#[derive(Default)]
712struct FlagParseBudget {
713 bytes: usize,
714}
715
716impl FlagParseBudget {
717 fn add_bytes(&mut self, bytes: usize, flag_name: &str) -> std::result::Result<(), String> {
718 self.bytes = self.bytes.saturating_add(bytes);
719 if self.bytes > MAX_PARSED_FLAG_BYTES {
720 return Err(format!(
721 "--{flag_name}: parsed flag data exceeds {MAX_PARSED_FLAG_BYTES} bytes"
722 ));
723 }
724 Ok(())
725 }
726}
727
728fn ensure_array_item_limit(
729 flag_name: &str,
730 existing: usize,
731 adding: usize,
732) -> std::result::Result<(), String> {
733 let total = existing.saturating_add(adding);
734 if total > MAX_ARRAY_FLAG_ITEMS {
735 return Err(format!(
736 "--{flag_name}: array flag has {total} items; maximum is {MAX_ARRAY_FLAG_ITEMS}"
737 ));
738 }
739 Ok(())
740}
741
742fn type_str_to_effective(s: &str) -> EffectiveType {
743 match s {
744 "string" => EffectiveType::String,
745 "integer" => EffectiveType::Integer,
746 "number" => EffectiveType::Number,
747 "boolean" => EffectiveType::Boolean,
748 "array" => EffectiveType::Array,
749 "object" => EffectiveType::Object,
750 _ => EffectiveType::Unknown,
751 }
752}
753
754fn resolve_ref<'a>(
755 ref_str: &str,
756 root_schema: &'a serde_json::Value,
757) -> Option<&'a serde_json::Value> {
758 let suffix = ref_str.strip_prefix("#/")?;
759 let mut current = root_schema;
760 for segment in suffix.split('/') {
761 let decoded = segment.replace("~1", "/").replace("~0", "~");
762 current = current.get(&decoded)?;
763 }
764 Some(current)
765}
766
767fn resolve_effective_type(
768 schema: &serde_json::Value,
769 root_schema: &serde_json::Value,
770 depth: usize,
771) -> EffectiveType {
772 if depth > MAX_REF_DEPTH {
773 return EffectiveType::Unknown;
774 }
775
776 if let Some(ref_str) = schema.get("$ref").and_then(|r| r.as_str()) {
777 if let Some(target) = resolve_ref(ref_str, root_schema) {
778 return resolve_effective_type(target, root_schema, depth + 1);
779 }
780 return EffectiveType::Unknown;
781 }
782
783 match schema.get("type") {
784 Some(serde_json::Value::String(s)) => return type_str_to_effective(s),
785 Some(serde_json::Value::Array(arr)) => {
786 for t in arr {
788 if let Some(s) = t.as_str()
789 && (s == "array" || s == "object")
790 {
791 return type_str_to_effective(s);
792 }
793 }
794 for t in arr {
795 if let Some(s) = t.as_str()
796 && s != "null"
797 {
798 return type_str_to_effective(s);
799 }
800 }
801 }
802 _ => {}
803 }
804
805 for key in ["oneOf", "anyOf", "allOf"] {
806 if let Some(branches) = schema.get(key).and_then(|v| v.as_array()) {
807 for branch in branches {
808 let et = resolve_effective_type(branch, root_schema, depth + 1);
809 if matches!(et, EffectiveType::Array | EffectiveType::Object) {
810 return et;
811 }
812 }
813 for branch in branches {
814 let et = resolve_effective_type(branch, root_schema, depth + 1);
815 if !matches!(et, EffectiveType::Unknown) {
816 return et;
817 }
818 }
819 }
820 }
821
822 if schema.get("items").is_some() {
823 return EffectiveType::Array;
824 }
825 if schema.get("properties").is_some() {
826 return EffectiveType::Object;
827 }
828
829 EffectiveType::Unknown
830}
831
832fn coerce_value(
834 raw: &str,
835 prop_schema: Option<&serde_json::Value>,
836 root_schema: &serde_json::Value,
837) -> serde_json::Value {
838 let effective = prop_schema
839 .map(|s| resolve_effective_type(s, root_schema, 0))
840 .unwrap_or(EffectiveType::Unknown);
841
842 match effective {
843 EffectiveType::Integer => raw
844 .parse::<i64>()
845 .map(serde_json::Value::from)
846 .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
847 EffectiveType::Number => raw
848 .parse::<f64>()
849 .map(|n| serde_json::json!(n))
850 .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
851 EffectiveType::Boolean => match raw {
852 "true" | "1" | "yes" => serde_json::Value::Bool(true),
853 "false" | "0" | "no" => serde_json::Value::Bool(false),
854 _ => serde_json::Value::String(raw.to_string()),
855 },
856 EffectiveType::Array | EffectiveType::Object => {
857 let trimmed = raw.trim_start();
858 if (trimmed.starts_with('[') || trimmed.starts_with('{'))
859 && let Some(parsed) = parse_aggregate_json_value(raw)
860 {
861 return parsed;
862 }
863 serde_json::Value::String(raw.to_string())
864 }
865 EffectiveType::String | EffectiveType::Unknown => {
866 serde_json::Value::String(raw.to_string())
867 }
868 }
869}
870
871fn parse_aggregate_json_value(raw: &str) -> Option<serde_json::Value> {
872 if raw.len() > MAX_AGGREGATE_FLAG_JSON_BYTES {
873 return None;
874 }
875 serde_json::from_str::<serde_json::Value>(raw).ok()
876}
877
878pub(crate) fn usage_from_schema(schema: &serde_json::Value) -> Option<String> {
881 let props = schema.get("properties")?.as_object()?;
882 if props.is_empty() {
883 return None;
884 }
885 let flags: Vec<String> = props
886 .iter()
887 .map(|(key, prop)| {
888 let hint = match resolve_effective_type(prop, schema, 0) {
889 EffectiveType::Object => "<json|key=value...>".to_string(),
890 EffectiveType::Array => {
891 let items = prop.get("items");
892 let items_eff = items
893 .map(|s| resolve_effective_type(s, schema, 0))
894 .unwrap_or(EffectiveType::Unknown);
895 if items_eff == EffectiveType::Object {
896 "<json|key=value...>".to_string()
897 } else {
898 "<json|a,b,c>".to_string()
899 }
900 }
901 EffectiveType::Integer => "<integer>".to_string(),
902 EffectiveType::Number => "<number>".to_string(),
903 EffectiveType::Boolean => "<boolean>".to_string(),
904 EffectiveType::String => "<string>".to_string(),
905 EffectiveType::Unknown => {
906 let ty = prop.get("type").and_then(|t| t.as_str()).unwrap_or("value");
907 format!("<{ty}>")
908 }
909 };
910 format!("--{key} {hint}")
911 })
912 .collect();
913 Some(flags.join(" "))
914}
915
916#[cfg(test)]
917mod tests {
918 use super::*;
919
920 #[test]
921 fn test_unknown_flag_kept_as_string_by_default() {
922 let schema = serde_json::json!({
924 "type": "object",
925 "properties": { "limit": {"type": "integer"} }
926 });
927 let args = vec!["--limti".to_string(), "10".to_string()];
928 let result = parse_flags(&args, &schema).unwrap();
929 assert_eq!(result["limti"], "10");
930 }
931
932 #[test]
933 fn test_closed_schema_rejects_unknown_flag() {
934 let schema = serde_json::json!({
935 "type": "object",
936 "properties": { "limit": {"type": "integer"}, "offset": {"type": "integer"} },
937 "additionalProperties": false
938 });
939 let args = vec!["--limti".to_string(), "10".to_string()];
940 let err = parse_flags(&args, &schema).unwrap_err();
941 assert!(err.contains("unknown flag: --limti"), "got: {err}");
942 assert!(err.contains("--limit"), "lists known flags: {err}");
943 assert!(err.contains("--offset"), "lists known flags: {err}");
944 }
945
946 #[test]
947 fn test_closed_schema_rejects_unknown_equals_flag() {
948 let schema = serde_json::json!({
949 "type": "object",
950 "properties": { "limit": {"type": "integer"} },
951 "additionalProperties": false
952 });
953 let args = vec!["--limti=10".to_string()];
954 let err = parse_flags(&args, &schema).unwrap_err();
955 assert!(err.contains("unknown flag: --limti"), "got: {err}");
956 }
957
958 #[test]
959 fn test_closed_schema_accepts_known_flags() {
960 let schema = serde_json::json!({
961 "type": "object",
962 "properties": { "limit": {"type": "integer"}, "name": {"type": "string"} },
963 "additionalProperties": false
964 });
965 let args = vec![
966 "--limit".to_string(),
967 "10".to_string(),
968 "--name=alice".to_string(),
969 ];
970 let result = parse_flags(&args, &schema).unwrap();
971 assert_eq!(result["limit"], 10);
972 assert_eq!(result["name"], "alice");
973 }
974
975 #[test]
976 fn test_closed_schema_without_properties_takes_no_flags() {
977 let schema = serde_json::json!({
978 "type": "object",
979 "additionalProperties": false
980 });
981 let args = vec!["--anything".to_string(), "x".to_string()];
982 let err = parse_flags(&args, &schema).unwrap_err();
983 assert!(err.contains("takes no flags"), "got: {err}");
984 }
985
986 #[test]
987 fn test_parse_flags_basic() {
988 let schema = serde_json::json!({
989 "type": "object",
990 "properties": {
991 "id": {"type": "integer"},
992 "name": {"type": "string"},
993 "verbose": {"type": "boolean"}
994 }
995 });
996 let args = vec![
997 "--id".to_string(),
998 "42".to_string(),
999 "--name".to_string(),
1000 "Alice".to_string(),
1001 "--verbose".to_string(),
1002 ];
1003 let result = parse_flags(&args, &schema).unwrap();
1004 assert_eq!(result["id"], 42);
1005 assert_eq!(result["name"], "Alice");
1006 assert_eq!(result["verbose"], true);
1007 }
1008
1009 #[test]
1010 fn test_parse_flags_equals_syntax() {
1011 let schema = serde_json::json!({
1012 "type": "object",
1013 "properties": {"id": {"type": "integer"}}
1014 });
1015 let args = vec!["--id=42".to_string()];
1016 let result = parse_flags(&args, &schema).unwrap();
1017 assert_eq!(result["id"], 42);
1018 }
1019
1020 #[test]
1021 fn test_parse_flags_json_array_string() {
1022 let schema = serde_json::json!({
1023 "type": "object",
1024 "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1025 });
1026 let args = vec!["--tags".to_string(), r#"["a","b","c"]"#.to_string()];
1027 let result = parse_flags(&args, &schema).unwrap();
1028 assert_eq!(result["tags"], serde_json::json!(["a", "b", "c"]));
1029 }
1030
1031 #[test]
1032 fn test_parse_flags_json_object_string() {
1033 let schema = serde_json::json!({
1034 "type": "object",
1035 "properties": {"server": {"type": "object"}}
1036 });
1037 let args = vec![
1038 "--server".to_string(),
1039 r#"{"name":"foo","port":8080}"#.to_string(),
1040 ];
1041 let result = parse_flags(&args, &schema).unwrap();
1042 assert_eq!(
1043 result["server"],
1044 serde_json::json!({"name": "foo", "port": 8080})
1045 );
1046 }
1047
1048 #[test]
1049 fn test_parse_flags_nullable_array() {
1050 let schema = serde_json::json!({
1052 "type": "object",
1053 "properties": {
1054 "tags": {"type": ["array", "null"], "items": {"type": "string"}}
1055 }
1056 });
1057 let args = vec!["--tags".to_string(), r#"["x","y"]"#.to_string()];
1058 let result = parse_flags(&args, &schema).unwrap();
1059 assert_eq!(result["tags"], serde_json::json!(["x", "y"]));
1060 }
1061
1062 #[test]
1063 fn test_parse_flags_oneof_null_and_ref() {
1064 let schema = serde_json::json!({
1065 "type": "object",
1066 "properties": {
1067 "config": {
1068 "oneOf": [
1069 {"type": "null"},
1070 {"$ref": "#/$defs/Config"}
1071 ]
1072 }
1073 },
1074 "$defs": {
1075 "Config": {"type": "object", "properties": {"k": {"type": "string"}}}
1076 }
1077 });
1078 let args = vec!["--config".to_string(), r#"{"k":"v"}"#.to_string()];
1079 let result = parse_flags(&args, &schema).unwrap();
1080 assert_eq!(result["config"], serde_json::json!({"k": "v"}));
1081 }
1082
1083 #[test]
1084 fn test_parse_flags_allof_composition() {
1085 let schema = serde_json::json!({
1086 "type": "object",
1087 "properties": {
1088 "data": {
1089 "allOf": [
1090 {"type": "object"},
1091 {"properties": {"x": {"type": "integer"}}}
1092 ]
1093 }
1094 }
1095 });
1096 let args = vec!["--data".to_string(), r#"{"x":1}"#.to_string()];
1097 let result = parse_flags(&args, &schema).unwrap();
1098 assert_eq!(result["data"], serde_json::json!({"x": 1}));
1099 }
1100
1101 #[test]
1102 fn test_parse_flags_invalid_json_left_as_string() {
1103 let schema = serde_json::json!({
1104 "type": "object",
1105 "properties": {"tags": {"type": "array"}}
1106 });
1107 let args = vec!["--tags".to_string(), "[1, 2,".to_string()];
1109 let result = parse_flags(&args, &schema).unwrap();
1110 assert_eq!(
1111 result["tags"],
1112 serde_json::Value::String("[1, 2,".to_string())
1113 );
1114 }
1115
1116 #[test]
1117 fn test_parse_flags_scalar_string_unchanged() {
1118 let schema = serde_json::json!({
1119 "type": "object",
1120 "properties": {"name": {"type": "string"}}
1121 });
1122 let args = vec!["--name".to_string(), "Alice".to_string()];
1123 let result = parse_flags(&args, &schema).unwrap();
1124 assert_eq!(result["name"], "Alice");
1125 }
1126
1127 #[test]
1128 fn test_parse_flags_implicit_array_from_items() {
1129 let schema = serde_json::json!({
1130 "type": "object",
1131 "properties": {"tags": {"items": {"type": "string"}}}
1132 });
1133 let args = vec!["--tags".to_string(), r#"["p","q"]"#.to_string()];
1134 let result = parse_flags(&args, &schema).unwrap();
1135 assert_eq!(result["tags"], serde_json::json!(["p", "q"]));
1136 }
1137
1138 #[test]
1139 fn test_parse_flags_implicit_object_from_properties() {
1140 let schema = serde_json::json!({
1141 "type": "object",
1142 "properties": {
1143 "server": {"properties": {"port": {"type": "integer"}}}
1144 }
1145 });
1146 let args = vec!["--server".to_string(), r#"{"port":80}"#.to_string()];
1147 let result = parse_flags(&args, &schema).unwrap();
1148 assert_eq!(result["server"], serde_json::json!({"port": 80}));
1149 }
1150
1151 #[test]
1152 fn test_parse_flags_ref_into_defs() {
1153 let schema = serde_json::json!({
1154 "type": "object",
1155 "properties": {"items": {"$ref": "#/$defs/Items"}},
1156 "$defs": {
1157 "Items": {"type": "array", "items": {"type": "integer"}}
1158 }
1159 });
1160 let args = vec!["--items".to_string(), "[1,2,3]".to_string()];
1161 let result = parse_flags(&args, &schema).unwrap();
1162 assert_eq!(result["items"], serde_json::json!([1, 2, 3]));
1163 }
1164
1165 #[test]
1166 fn test_parse_flags_ref_into_definitions() {
1167 let schema = serde_json::json!({
1169 "type": "object",
1170 "properties": {"items": {"$ref": "#/definitions/Items"}},
1171 "definitions": {
1172 "Items": {"type": "array"}
1173 }
1174 });
1175 let args = vec!["--items".to_string(), "[1,2]".to_string()];
1176 let result = parse_flags(&args, &schema).unwrap();
1177 assert_eq!(result["items"], serde_json::json!([1, 2]));
1178 }
1179
1180 #[test]
1181 fn test_parse_flags_ref_cycle_bounded() {
1182 let schema = serde_json::json!({
1184 "type": "object",
1185 "properties": {"x": {"$ref": "#/$defs/A"}},
1186 "$defs": {
1187 "A": {"$ref": "#/$defs/B"},
1188 "B": {"$ref": "#/$defs/A"}
1189 }
1190 });
1191 let args = vec!["--x".to_string(), "value".to_string()];
1192 let result = parse_flags(&args, &schema).unwrap();
1193 assert_eq!(result["x"], "value");
1195 }
1196
1197 #[test]
1198 fn test_parse_flags_array_value_not_starting_with_bracket() {
1199 let schema = serde_json::json!({
1201 "type": "object",
1202 "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1203 });
1204 let args = vec!["--tags".to_string(), "abc".to_string()];
1205 let result = parse_flags(&args, &schema).unwrap();
1206 assert_eq!(result["tags"], serde_json::json!(["abc"]));
1207 }
1208
1209 #[test]
1210 fn test_parse_flags_array_missing_value_is_error() {
1211 let schema = serde_json::json!({
1212 "type": "object",
1213 "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1214 });
1215 let args = vec!["--tags".to_string()];
1216 let err = parse_flags(&args, &schema).unwrap_err();
1217 assert_eq!(err, "--tags: missing value");
1218 }
1219
1220 #[test]
1221 fn test_parse_flags_array_missing_value_before_next_flag_is_error() {
1222 let schema = serde_json::json!({
1223 "type": "object",
1224 "properties": {
1225 "tags": {"type": "array", "items": {"type": "string"}},
1226 "name": {"type": "string"}
1227 }
1228 });
1229 let args = vec![
1230 "--tags".to_string(),
1231 "--name".to_string(),
1232 "alice".to_string(),
1233 ];
1234 let err = parse_flags(&args, &schema).unwrap_err();
1235 assert_eq!(err, "--tags: missing value");
1236 }
1237
1238 #[test]
1239 fn test_parse_flags_pair_object_single() {
1240 let schema = serde_json::json!({
1241 "type": "object",
1242 "properties": {
1243 "server": {
1244 "type": "object",
1245 "properties": {
1246 "name": {"type": "string"},
1247 "url": {"type": "string"}
1248 }
1249 }
1250 }
1251 });
1252 let args = vec![
1253 "--server".to_string(),
1254 "name=foo".to_string(),
1255 "url=https://example.com".to_string(),
1256 ];
1257 let result = parse_flags(&args, &schema).unwrap();
1258 assert_eq!(
1259 result["server"],
1260 serde_json::json!({"name": "foo", "url": "https://example.com"})
1261 );
1262 }
1263
1264 #[test]
1265 fn test_parse_flags_pair_array_of_objects_repeated() {
1266 let schema = serde_json::json!({
1267 "type": "object",
1268 "properties": {
1269 "endpoint": {
1270 "type": "array",
1271 "items": {
1272 "type": "object",
1273 "properties": {
1274 "name": {"type": "string"},
1275 "url": {"type": "string"}
1276 }
1277 }
1278 }
1279 }
1280 });
1281 let args = vec![
1282 "--endpoint".to_string(),
1283 "name=a".to_string(),
1284 "url=u1".to_string(),
1285 "--endpoint".to_string(),
1286 "name=b".to_string(),
1287 "url=u2".to_string(),
1288 ];
1289 let result = parse_flags(&args, &schema).unwrap();
1290 assert_eq!(
1291 result["endpoint"],
1292 serde_json::json!([
1293 {"name": "a", "url": "u1"},
1294 {"name": "b", "url": "u2"}
1295 ])
1296 );
1297 }
1298
1299 #[test]
1300 fn test_parse_flags_array_string_comma_split() {
1301 let schema = serde_json::json!({
1302 "type": "object",
1303 "properties": {
1304 "tags": {"type": "array", "items": {"type": "string"}}
1305 }
1306 });
1307 let args = vec!["--tags".to_string(), "a,b,c".to_string()];
1308 let result = parse_flags(&args, &schema).unwrap();
1309 assert_eq!(result["tags"], serde_json::json!(["a", "b", "c"]));
1310 }
1311
1312 #[test]
1313 fn test_parse_flags_array_string_repeated_appends() {
1314 let schema = serde_json::json!({
1315 "type": "object",
1316 "properties": {
1317 "tags": {"type": "array", "items": {"type": "string"}}
1318 }
1319 });
1320 let args = vec![
1321 "--tags".to_string(),
1322 "x".to_string(),
1323 "--tags".to_string(),
1324 "y".to_string(),
1325 ];
1326 let result = parse_flags(&args, &schema).unwrap();
1327 assert_eq!(result["tags"], serde_json::json!(["x", "y"]));
1328 }
1329
1330 #[test]
1331 fn test_parse_flags_pair_nested_type_coercion() {
1332 let schema = serde_json::json!({
1333 "type": "object",
1334 "properties": {
1335 "server": {
1336 "type": "object",
1337 "properties": {
1338 "enabled": {"type": "boolean"},
1339 "port": {"type": "integer"}
1340 }
1341 }
1342 }
1343 });
1344 let args = vec![
1345 "--server".to_string(),
1346 "enabled=true".to_string(),
1347 "port=8080".to_string(),
1348 ];
1349 let result = parse_flags(&args, &schema).unwrap();
1350 assert_eq!(
1351 result["server"],
1352 serde_json::json!({"enabled": true, "port": 8080})
1353 );
1354 }
1355
1356 #[test]
1357 fn test_parse_flags_pair_unknown_key_errors() {
1358 let schema = serde_json::json!({
1359 "type": "object",
1360 "properties": {
1361 "server": {
1362 "type": "object",
1363 "properties": {
1364 "name": {"type": "string"}
1365 }
1366 }
1367 }
1368 });
1369 let args = vec!["--server".to_string(), "bogus=foo".to_string()];
1370 let err = parse_flags(&args, &schema).unwrap_err();
1371 assert!(err.contains("unknown key"), "got: {err}");
1372 assert!(err.contains("bogus"), "got: {err}");
1373 }
1374
1375 #[test]
1376 fn test_parse_flags_object_json_form_unchanged() {
1377 let schema = serde_json::json!({
1378 "type": "object",
1379 "properties": {
1380 "server": {
1381 "type": "object",
1382 "properties": {"name": {"type": "string"}}
1383 }
1384 }
1385 });
1386 let args = vec!["--server".to_string(), r#"{"name":"foo"}"#.to_string()];
1387 let result = parse_flags(&args, &schema).unwrap();
1388 assert_eq!(result["server"], serde_json::json!({"name": "foo"}));
1389 }
1390
1391 #[test]
1392 fn test_parse_flags_pair_mixed_with_json_rejected() {
1393 let schema = serde_json::json!({
1397 "type": "object",
1398 "properties": {
1399 "server": {
1400 "type": "object",
1401 "properties": {"name": {"type": "string"}}
1402 }
1403 }
1404 });
1405 let args = vec![
1406 "--server".to_string(),
1407 r#"{"name":"foo"}"#.to_string(),
1408 "name=bar".to_string(),
1409 ];
1410 let err = parse_flags(&args, &schema).unwrap_err();
1411 assert!(err.contains("expected --flag"), "got: {err}");
1412 }
1413
1414 #[test]
1415 fn test_parse_flags_array_of_objects_json_then_pair_appends() {
1416 let schema = serde_json::json!({
1418 "type": "object",
1419 "properties": {
1420 "endpoint": {
1421 "type": "array",
1422 "items": {
1423 "type": "object",
1424 "properties": {
1425 "name": {"type": "string"}
1426 }
1427 }
1428 }
1429 }
1430 });
1431 let args = vec![
1432 "--endpoint".to_string(),
1433 r#"{"name":"j"}"#.to_string(),
1434 "--endpoint".to_string(),
1435 "name=p".to_string(),
1436 ];
1437 let result = parse_flags(&args, &schema).unwrap();
1438 assert_eq!(
1439 result["endpoint"],
1440 serde_json::json!([{"name": "j"}, {"name": "p"}])
1441 );
1442 }
1443
1444 #[test]
1445 fn test_parse_flags_array_int_comma_split_coerced() {
1446 let schema = serde_json::json!({
1447 "type": "object",
1448 "properties": {
1449 "ids": {"type": "array", "items": {"type": "integer"}}
1450 }
1451 });
1452 let args = vec!["--ids".to_string(), "1,2,3".to_string()];
1453 let result = parse_flags(&args, &schema).unwrap();
1454 assert_eq!(result["ids"], serde_json::json!([1, 2, 3]));
1455 }
1456
1457 #[test]
1458 fn test_parse_flags_comma_split_array_item_limit() {
1459 let schema = serde_json::json!({
1460 "type": "object",
1461 "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1462 });
1463 let too_many_empty_items = ",".repeat(MAX_ARRAY_FLAG_ITEMS);
1464 let args = vec!["--tags".to_string(), too_many_empty_items];
1465 let err = parse_flags(&args, &schema).unwrap_err();
1466 assert!(err.contains("maximum"), "got: {err}");
1467 }
1468
1469 #[test]
1470 fn test_parse_flags_repeated_array_item_limit() {
1471 let schema = serde_json::json!({
1472 "type": "object",
1473 "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1474 });
1475 let at_limit = std::iter::repeat_n("x", MAX_ARRAY_FLAG_ITEMS)
1476 .collect::<Vec<_>>()
1477 .join(",");
1478 let args = vec![
1479 "--tags".to_string(),
1480 at_limit,
1481 "--tags".to_string(),
1482 "overflow".to_string(),
1483 ];
1484 let err = parse_flags(&args, &schema).unwrap_err();
1485 assert!(err.contains("maximum"), "got: {err}");
1486 }
1487
1488 #[test]
1489 fn test_parse_flags_total_parsed_bytes_limit() {
1490 let schema = serde_json::json!({
1491 "type": "object",
1492 "properties": {"name": {"type": "string"}}
1493 });
1494 let args = vec!["--name".to_string(), "x".repeat(MAX_PARSED_FLAG_BYTES + 1)];
1495 let err = parse_flags(&args, &schema).unwrap_err();
1496 assert!(err.contains("parsed flag data exceeds"), "got: {err}");
1497 }
1498
1499 #[test]
1500 fn test_parse_flags_large_json_array_stays_string() {
1501 let schema = serde_json::json!({
1502 "type": "object",
1503 "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1504 });
1505 let large = format!(
1506 "[{}]",
1507 "\"a\",".repeat((MAX_AGGREGATE_FLAG_JSON_BYTES / 4) + 1)
1508 );
1509 let args = vec!["--tags".to_string(), large.clone()];
1510 let result = parse_flags(&args, &schema).unwrap();
1511 assert_eq!(result["tags"], serde_json::Value::String(large));
1512 }
1513
1514 #[test]
1515 fn test_usage_from_schema_advertises_both_forms() {
1516 let schema = serde_json::json!({
1517 "type": "object",
1518 "properties": {
1519 "server": {"type": "object"},
1520 "tags": {"type": "array", "items": {"type": "string"}},
1521 "id": {"type": "integer"}
1522 }
1523 });
1524 let usage = usage_from_schema(&schema).expect("usage");
1525 assert!(
1526 usage.contains("--server <json|key=value...>"),
1527 "got: {usage}"
1528 );
1529 assert!(usage.contains("--tags <json|a,b,c>"), "got: {usage}");
1530 assert!(usage.contains("--id <integer>"), "got: {usage}");
1531 }
1532
1533 #[test]
1534 fn test_tool_impl_sync() {
1535 let tool = ToolImpl::new(ToolDef::new("greet", "Greet a user").with_schema(
1536 serde_json::json!({
1537 "type": "object",
1538 "properties": { "name": {"type": "string"} }
1539 }),
1540 ))
1541 .with_exec_sync(|args| {
1542 let name = args.param_str("name").unwrap_or("world");
1543 Ok(format!("hello {name}\n"))
1544 });
1545
1546 assert!(tool.exec_sync.is_some());
1547 assert!(tool.exec.is_none());
1548 assert!(tool.sanitize_errors);
1549 assert_eq!(tool.def.name, "greet");
1550 }
1551
1552 async fn run_builtin(tool: ToolImpl, script: &str) -> Result<ExecResult> {
1555 let name = tool.def.name.clone();
1556 let mut bash = bashkit::Bash::builder()
1557 .builtin(&name, Box::new(tool))
1558 .build();
1559 bash.exec(script).await
1560 }
1561
1562 #[tokio::test]
1563 async fn test_tool_impl_as_builtin() {
1564 let tool = ToolImpl::new(ToolDef::new("greet", "Greet a user").with_schema(
1565 serde_json::json!({
1566 "type": "object",
1567 "properties": { "name": {"type": "string"} }
1568 }),
1569 ))
1570 .with_exec_sync(|args| {
1571 let name = args.param_str("name").unwrap_or("world");
1572 Ok(format!("hello {name}\n"))
1573 });
1574
1575 let result = run_builtin(tool, "greet --name Alice").await.unwrap();
1577 assert_eq!(result.stdout, "hello Alice\n");
1578 assert_eq!(result.exit_code, 0);
1579 }
1580
1581 #[tokio::test]
1582 async fn test_tool_impl_as_builtin_sanitizes_callback_errors() {
1583 let secret = "postgres://user:secret@internal-db/private";
1584 let tool =
1585 ToolImpl::new(ToolDef::new("lookup", "Lookup record")).with_exec_sync(move |_| {
1586 Err(format!(
1587 "backend failed for {secret} at /srv/app/src/client.rs:42"
1588 ))
1589 });
1590
1591 let result = run_builtin(tool, "lookup").await.unwrap();
1592
1593 assert_eq!(result.exit_code, 1);
1594 assert_eq!(result.stderr, "lookup: callback failed\n");
1595 assert!(!result.stderr.contains("secret"));
1596 assert!(!result.stderr.contains("internal-db"));
1597 assert!(!result.stderr.contains("/srv/app"));
1598 }
1599
1600 #[tokio::test]
1601 async fn test_tool_impl_as_builtin_can_opt_out_of_error_sanitization() {
1602 let tool = ToolImpl::new(ToolDef::new("lookup", "Lookup record"))
1603 .with_exec_sync(|_| Err("raw diagnostic".to_string()))
1604 .sanitize_errors(false);
1605
1606 let result = run_builtin(tool, "lookup").await.unwrap();
1607
1608 assert_eq!(result.exit_code, 1);
1609 assert_eq!(result.stderr, "raw diagnostic");
1610 }
1611
1612 #[tokio::test]
1613 async fn test_tool_impl_async_exec() {
1614 let tool =
1615 ToolImpl::new(ToolDef::new("echo_async", "Async echo")).with_exec(|args| async move {
1616 let msg = args.stdin.unwrap_or_default();
1617 Ok(format!("async: {msg}"))
1618 });
1619
1620 assert!(tool.exec.is_some());
1621 assert!(tool.exec_sync.is_none());
1622 }
1623
1624 #[tokio::test]
1625 async fn test_tool_impl_no_exec_errors() {
1626 let tool = ToolImpl::new(ToolDef::new("empty", "No exec"));
1627
1628 let result = run_builtin(tool, "empty").await;
1629 assert!(result.is_err());
1630 }
1631}