1mod execute;
132mod extension;
133mod logic_only;
134mod toolset;
135
136pub use extension::{ToolDefExtension, ToolDefExtensionBuilder, ToolDefInvocationTrace};
137pub use toolset::{DiscoverTool, DiscoveryMode, ScriptingToolSet, ScriptingToolSetBuilder};
138
139pub use crate::tool_def::{
141 AsyncToolCallback, AsyncToolExec, SyncToolExec, ToolArgs, ToolCallback, ToolDef, ToolImpl,
142};
143
144use bashkit::Builtin;
145use bashkit::{ExecutionLimits, ExecutionProfile, Tool, ToolService};
146use serde::{Deserialize, Serialize};
147use std::sync::{Arc, Mutex};
148
149#[derive(Clone)]
151pub enum CallbackKind {
152 Sync(SyncToolExec),
154 Async(AsyncToolExec),
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164pub enum ScriptedCommandKind {
165 Tool,
166 Help,
167 Discover,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct ScriptedCommandInvocation {
173 pub name: String,
174 pub kind: ScriptedCommandKind,
175 pub args: Vec<String>,
176 pub exit_code: i32,
177}
178
179#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct ScriptedExecutionTrace {
182 pub invocations: Vec<ScriptedCommandInvocation>,
183}
184
185#[derive(Clone)]
191pub(crate) struct RegisteredTool {
192 pub(crate) def: ToolDef,
193 pub(crate) callback: CallbackKind,
194 pub(crate) dry_run: Option<CallbackKind>,
195}
196
197impl RegisteredTool {
198 pub(crate) fn from_tool_impl(tool: ToolImpl) -> Self {
201 let callback = if let Some(async_cb) = tool.exec {
202 CallbackKind::Async(async_cb)
203 } else if let Some(sync_cb) = tool.exec_sync {
204 CallbackKind::Sync(sync_cb)
205 } else {
206 let name = tool.def.name.clone();
208 CallbackKind::Sync(Arc::new(move |_| Err(format!("{name}: no exec defined"))))
209 };
210 Self {
211 def: tool.def,
212 callback,
213 dry_run: None,
214 }
215 }
216}
217
218pub struct ScriptedToolBuilder {
242 name: String,
243 locale: String,
244 short_desc: Option<String>,
245 tools: Vec<RegisteredTool>,
246 builtins: Vec<(String, Arc<dyn Builtin>)>,
249 limits: Option<ExecutionLimits>,
250 profile: Option<ExecutionProfile>,
251 env_vars: Vec<(String, String)>,
252 compact_prompt: bool,
253 sanitize_errors: bool,
256}
257
258impl ScriptedToolBuilder {
259 pub(crate) fn new(name: impl Into<String>) -> Self {
260 Self {
261 name: name.into(),
262 locale: "en-US".to_string(),
263 short_desc: None,
264 tools: Vec::new(),
265 builtins: Vec::new(),
266 limits: None,
267 profile: None,
268 env_vars: Vec::new(),
269 compact_prompt: false,
270 sanitize_errors: true,
271 }
272 }
273
274 pub fn locale(mut self, locale: &str) -> Self {
276 self.locale = locale.to_string();
277 self
278 }
279
280 pub fn short_description(mut self, desc: impl Into<String>) -> Self {
282 self.short_desc = Some(desc.into());
283 self
284 }
285
286 pub fn tool(mut self, tool: ToolImpl) -> Self {
291 self.tools.push(RegisteredTool::from_tool_impl(tool));
292 self
293 }
294
295 pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
332 self.builtins.push((name.into(), Arc::from(builtin)));
333 self
334 }
335
336 pub fn tool_fn(
342 mut self,
343 def: ToolDef,
344 exec: impl Fn(&ToolArgs) -> Result<String, String> + Send + Sync + 'static,
345 ) -> Self {
346 self.tools.push(RegisteredTool {
347 def,
348 callback: CallbackKind::Sync(Arc::new(exec)),
349 dry_run: None,
350 });
351 self
352 }
353
354 pub fn tool_with_dry_run(
358 mut self,
359 def: ToolDef,
360 exec: impl Fn(&ToolArgs) -> Result<String, String> + Send + Sync + 'static,
361 dry_run: impl Fn(&ToolArgs) -> Result<String, String> + Send + Sync + 'static,
362 ) -> Self {
363 self.tools.push(RegisteredTool {
364 def,
365 callback: CallbackKind::Sync(Arc::new(exec)),
366 dry_run: Some(CallbackKind::Sync(Arc::new(dry_run))),
367 });
368 self
369 }
370
371 pub fn async_tool_fn<F, Fut>(mut self, def: ToolDef, exec: F) -> Self
378 where
379 F: Fn(ToolArgs) -> Fut + Send + Sync + 'static,
380 Fut: Future<Output = Result<String, String>> + Send + 'static,
381 {
382 let cb: AsyncToolExec = Arc::new(move |args| Box::pin(exec(args)));
383 self.tools.push(RegisteredTool {
384 def,
385 callback: CallbackKind::Async(cb),
386 dry_run: None,
387 });
388 self
389 }
390
391 pub fn limits(mut self, limits: ExecutionLimits) -> Self {
393 self.limits = Some(limits);
394 self
395 }
396
397 pub fn profile(mut self, profile: ExecutionProfile) -> Self {
402 self.profile = Some(profile);
403 self
404 }
405
406 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
408 self.env_vars.push((key.into(), value.into()));
409 self
410 }
411
412 pub fn sanitize_errors(mut self, sanitize: bool) -> Self {
419 self.sanitize_errors = sanitize;
420 self
421 }
422
423 pub fn compact_prompt(mut self, compact: bool) -> Self {
429 self.compact_prompt = compact;
430 self
431 }
432
433 pub fn build(&self) -> ScriptedTool {
435 let short_desc = self
436 .short_desc
437 .clone()
438 .unwrap_or_else(|| format!("ScriptedTool: {}", self.name));
439 let tool_names = self
440 .tools
441 .iter()
442 .map(|tool| tool.def.name.as_str())
443 .collect::<Vec<_>>()
444 .join(", ");
445
446 ScriptedTool {
447 name: self.name.clone(),
448 locale: self.locale.clone(),
449 display_name: self.name.clone(),
450 short_desc,
451 description: format!(
452 "{}: {}",
453 bashkit::tool::localized(
454 self.locale.as_str(),
455 "Compose tool callbacks through bash scripts",
456 "Компонує виклики інструментів через bash-скрипти",
457 ),
458 tool_names
459 ),
460 tools: self.tools.clone(),
461 builtins: self.builtins.clone(),
462 limits: self.limits.clone(),
463 profile: self.profile.clone(),
464 env_vars: self.env_vars.clone(),
465 compact_prompt: self.compact_prompt,
466 sanitize_errors: self.sanitize_errors,
467 last_execution_trace: Arc::new(Mutex::new(None)),
468 }
469 }
470
471 pub fn build_service(&self) -> ToolService {
473 let tool = self.build();
474 tower::util::BoxCloneService::new(tower::service_fn(move |args| {
475 let tool = tool.clone();
476 async move {
477 let execution = tool.execution(args)?;
478 let output = execution.execute().await?;
479 Ok(output.result)
480 }
481 }))
482 }
483
484 pub fn build_tool_definition(&self) -> serde_json::Value {
486 let tool = self.build();
487 serde_json::json!({
488 "type": "function",
489 "function": {
490 "name": tool.name(),
491 "description": tool.description(),
492 "parameters": self.build_input_schema(),
493 }
494 })
495 }
496
497 pub fn build_input_schema(&self) -> serde_json::Value {
499 bashkit::tool::tool_request_schema()
500 }
501
502 pub fn build_output_schema(&self) -> serde_json::Value {
504 bashkit::tool::tool_response_schema()
505 }
506}
507
508#[derive(Clone)]
526pub struct ScriptedTool {
527 pub(crate) name: String,
528 pub(crate) locale: String,
529 pub(crate) display_name: String,
530 pub(crate) short_desc: String,
531 pub(crate) description: String,
532 pub(crate) tools: Vec<RegisteredTool>,
533 pub(crate) builtins: Vec<(String, Arc<dyn Builtin>)>,
534 pub(crate) limits: Option<ExecutionLimits>,
535 pub(crate) profile: Option<ExecutionProfile>,
536 pub(crate) env_vars: Vec<(String, String)>,
537 pub(crate) compact_prompt: bool,
538 pub(crate) sanitize_errors: bool,
539 pub(crate) last_execution_trace: Arc<Mutex<Option<ScriptedExecutionTrace>>>,
540}
541
542impl ScriptedTool {
543 pub fn builder(name: impl Into<String>) -> ScriptedToolBuilder {
545 ScriptedToolBuilder::new(name)
546 }
547
548 pub fn take_last_execution_trace(&self) -> Option<ScriptedExecutionTrace> {
550 self.last_execution_trace
551 .lock()
552 .expect("scripted execution trace poisoned")
553 .take()
554 }
555
556 pub(crate) fn store_last_execution_trace(&self, trace: ScriptedExecutionTrace) {
557 *self
558 .last_execution_trace
559 .lock()
560 .expect("scripted execution trace poisoned") = Some(trace);
561 }
562}
563
564#[cfg(test)]
569mod tests {
570 use super::*;
571 use bashkit::tool::{Tool, ToolRequest, VERSION};
572
573 fn build_test_tool() -> ScriptedTool {
574 ScriptedTool::builder("test_api")
575 .short_description("Test API")
576 .tool_fn(
577 ToolDef::new("get_user", "Fetch user by id").with_schema(serde_json::json!({
578 "type": "object",
579 "properties": {
580 "id": {"type": "integer"}
581 }
582 })),
583 |args: &ToolArgs| {
584 let id = args.param_i64("id").ok_or("missing --id")?;
585 Ok(format!(
586 "{{\"id\":{id},\"name\":\"Alice\",\"email\":\"alice@example.com\"}}\n"
587 ))
588 },
589 )
590 .tool_fn(
591 ToolDef::new("get_orders", "List orders for user").with_schema(serde_json::json!({
592 "type": "object",
593 "properties": {
594 "user_id": {"type": "integer"}
595 }
596 })),
597 |args: &ToolArgs| {
598 let uid = args.param_i64("user_id").ok_or("missing --user_id")?;
599 Ok(format!(
600 "[{{\"order_id\":1,\"user_id\":{uid},\"total\":29.99}},\
601 {{\"order_id\":2,\"user_id\":{uid},\"total\":49.50}}]\n"
602 ))
603 },
604 )
605 .tool_fn(
606 ToolDef::new("fail_tool", "Always fails"),
607 |_args: &ToolArgs| Err("service unavailable".to_string()),
608 )
609 .tool_fn(
610 ToolDef::new("from_stdin", "Read from stdin, uppercase it"),
611 |args: &ToolArgs| match args.stdin.as_deref() {
612 Some(input) => Ok(input.to_uppercase()),
613 None => Err("no stdin".to_string()),
614 },
615 )
616 .build()
617 }
618
619 #[test]
622 fn test_builder_name_and_description() {
623 let tool = build_test_tool();
624 assert_eq!(tool.name(), "test_api");
625 assert_eq!(tool.short_description(), "Test API");
626 }
627
628 #[test]
629 fn test_builder_default_short_description() {
630 let tool = ScriptedTool::builder("mytools")
631 .tool_fn(ToolDef::new("noop", "No-op"), |_args: &ToolArgs| {
632 Ok("ok\n".to_string())
633 })
634 .build();
635 assert_eq!(tool.short_description(), "ScriptedTool: mytools");
636 }
637
638 #[test]
639 fn test_description_lists_tools() {
640 let tool = build_test_tool();
641 let desc = tool.description();
642 assert!(desc.contains("get_user"));
643 assert!(desc.contains("get_orders"));
644 assert!(desc.contains("fail_tool"));
645 assert!(desc.contains("from_stdin"));
646 }
647
648 #[test]
649 fn test_help_has_tool_commands_section() {
650 let tool = build_test_tool();
651 let help = tool.help();
652 assert!(help.contains("## Tool Commands"));
653 assert!(help.contains("get_user"));
654 assert!(help.contains("Fetch user by id"));
655 }
656
657 #[test]
658 fn test_system_prompt_lists_tools() {
659 let tool = build_test_tool();
660 let sp = tool.system_prompt();
661 assert!(sp.starts_with("test_api:"));
662 assert!(sp.contains("get_user"));
663 assert!(sp.contains("get_orders"));
664 assert!(sp.contains("--key value"));
665 }
666
667 #[test]
668 fn test_system_prompt_includes_schema() {
669 let tool = ScriptedTool::builder("schema_test")
670 .tool_fn(
671 ToolDef::new("get_user", "Fetch user by id").with_schema(serde_json::json!({
672 "type": "object",
673 "properties": {
674 "id": {"type": "integer"}
675 },
676 "required": ["id"]
677 })),
678 |_args: &ToolArgs| Ok("ok\n".to_string()),
679 )
680 .build();
681 let sp = tool.system_prompt();
682 assert!(
683 sp.contains("--id <integer>"),
684 "system prompt should show flags"
685 );
686 }
687
688 #[test]
689 fn test_schemas() {
690 let tool = build_test_tool();
691 let input = tool.input_schema();
692 assert!(input["properties"]["commands"].is_object());
693 let output = tool.output_schema();
694 assert!(output["properties"]["stdout"].is_object());
695 }
696
697 #[test]
698 fn test_builder_contract_helpers() {
699 let builder = ScriptedTool::builder("test_api")
700 .tool_fn(ToolDef::new("ping", "Ping"), |_args: &ToolArgs| {
701 Ok("pong\n".to_string())
702 });
703 let definition = builder.build_tool_definition();
704 let input_schema = builder.build_input_schema();
705 let output_schema = builder.build_output_schema();
706
707 assert_eq!(definition["type"], "function");
708 assert_eq!(definition["function"]["name"], "test_api");
709 assert_eq!(definition["function"]["parameters"], input_schema);
710 assert!(output_schema["properties"]["stdout"].is_object());
711 }
712
713 #[tokio::test]
714 async fn test_builder_service_executes() {
715 use tower::ServiceExt;
716
717 let service = ScriptedTool::builder("test_api")
718 .tool_fn(ToolDef::new("ping", "Ping"), |_args: &ToolArgs| {
719 Ok("pong\n".to_string())
720 })
721 .build_service();
722
723 let result = service
724 .oneshot(serde_json::json!({"commands": "ping"}))
725 .await
726 .unwrap_or_else(|err| panic!("service should execute: {err}"));
727
728 assert_eq!(result["stdout"], "pong\n");
729 assert_eq!(result["exit_code"], 0);
730 }
731
732 #[test]
733 fn test_locale_localizes_description() {
734 let tool = ScriptedTool::builder("ua_api")
735 .locale("uk-UA")
736 .tool_fn(ToolDef::new("ping", "Ping"), |_args: &ToolArgs| {
737 Ok("pong\n".to_string())
738 })
739 .build();
740
741 assert!(tool.description().contains("Компонує"));
742 assert_eq!(tool.locale(), "uk-UA");
743 }
744
745 #[test]
746 fn test_version() {
747 let tool = build_test_tool();
748 assert_eq!(tool.version(), VERSION);
749 }
750
751 #[tokio::test]
754 async fn test_execute_empty() {
755 let tool = build_test_tool();
756 let resp = tool
757 .execute(ToolRequest {
758 commands: String::new(),
759 timeout_ms: None,
760 })
761 .await;
762 assert_eq!(resp.exit_code, 0);
763 assert!(resp.stdout.is_empty());
764 }
765
766 #[tokio::test]
767 async fn test_execute_single_tool() {
768 let tool = build_test_tool();
769 let resp = tool
770 .execute(ToolRequest {
771 commands: "get_user --id 42".to_string(),
772 timeout_ms: None,
773 })
774 .await;
775 assert_eq!(resp.exit_code, 0);
776 assert!(resp.stdout.contains("\"name\":\"Alice\""));
777 assert!(resp.stdout.contains("\"id\":42"));
778 }
779
780 #[tokio::test]
781 async fn test_execute_key_equals_value() {
782 let tool = build_test_tool();
783 let resp = tool
784 .execute(ToolRequest {
785 commands: "get_user --id=42".to_string(),
786 timeout_ms: None,
787 })
788 .await;
789 assert_eq!(resp.exit_code, 0);
790 assert!(resp.stdout.contains("\"id\":42"));
791 }
792
793 #[tokio::test]
794 async fn test_execute_pipeline_with_jq() {
795 let tool = build_test_tool();
796 let resp = tool
797 .execute(ToolRequest {
798 commands: "get_user --id 42 | jq -r '.name'".to_string(),
799 timeout_ms: None,
800 })
801 .await;
802 assert_eq!(resp.exit_code, 0);
803 assert_eq!(resp.stdout.trim(), "Alice");
804 }
805
806 #[tokio::test]
807 async fn test_execute_multi_step() {
808 let tool = build_test_tool();
809 let script = r#"
810 user=$(get_user --id 1)
811 name=$(echo "$user" | jq -r '.name')
812 orders=$(get_orders --user_id 1)
813 total=$(echo "$orders" | jq '[.[].total] | add')
814 echo "User: $name, Total: $total"
815 "#;
816 let resp = tool
817 .execute(ToolRequest {
818 commands: script.to_string(),
819 timeout_ms: None,
820 })
821 .await;
822 assert_eq!(resp.exit_code, 0);
823 assert_eq!(resp.stdout.trim(), "User: Alice, Total: 79.49");
824 }
825
826 #[tokio::test]
827 async fn test_execute_tool_failure() {
828 let tool = build_test_tool();
829 let resp = tool
830 .execute(ToolRequest {
831 commands: "fail_tool".to_string(),
832 timeout_ms: None,
833 })
834 .await;
835 assert_ne!(resp.exit_code, 0);
836 assert!(resp.stderr.contains("callback failed"));
837 }
838
839 #[tokio::test]
840 async fn test_execute_tool_failure_with_fallback() {
841 let tool = build_test_tool();
842 let resp = tool
843 .execute(ToolRequest {
844 commands: "fail_tool || echo 'fallback'".to_string(),
845 timeout_ms: None,
846 })
847 .await;
848 assert_eq!(resp.exit_code, 0);
849 assert!(resp.stdout.contains("fallback"));
850 }
851
852 #[tokio::test]
853 async fn test_execute_stdin_pipe() {
854 let tool = build_test_tool();
855 let resp = tool
856 .execute(ToolRequest {
857 commands: "echo hello | from_stdin".to_string(),
858 timeout_ms: None,
859 })
860 .await;
861 assert_eq!(resp.exit_code, 0);
862 assert_eq!(resp.stdout.trim(), "HELLO");
863 }
864
865 #[tokio::test]
866 async fn test_execute_loop_over_tools() {
867 let tool = build_test_tool();
868 let script = r#"
869 for uid in 1 2 3; do
870 get_user --id $uid | jq -r '.name'
871 done
872 "#;
873 let resp = tool
874 .execute(ToolRequest {
875 commands: script.to_string(),
876 timeout_ms: None,
877 })
878 .await;
879 assert_eq!(resp.exit_code, 0);
880 assert_eq!(resp.stdout.trim(), "Alice\nAlice\nAlice");
881 }
882
883 #[tokio::test]
884 async fn test_execute_conditional() {
885 let tool = build_test_tool();
886 let script = r#"
887 user=$(get_user --id 5)
888 name=$(echo "$user" | jq -r '.name')
889 if [ "$name" = "Alice" ]; then
890 echo "found alice"
891 else
892 echo "not alice"
893 fi
894 "#;
895 let resp = tool
896 .execute(ToolRequest {
897 commands: script.to_string(),
898 timeout_ms: None,
899 })
900 .await;
901 assert_eq!(resp.exit_code, 0);
902 assert_eq!(resp.stdout.trim(), "found alice");
903 }
904
905 #[tokio::test]
906 async fn test_scripted_tool_rejects_filesystem_command() {
907 let tool = build_test_tool();
908 let resp = tool
909 .execute(ToolRequest {
910 commands: "mkdir -p /tmp/work".to_string(),
911 timeout_ms: None,
912 })
913 .await;
914
915 assert_eq!(resp.exit_code, 127);
916 assert!(resp.stderr.contains("command not found"), "{}", resp.stderr);
917 }
918
919 #[tokio::test]
920 async fn test_scripted_tool_rejects_file_redirection() {
921 let tool = build_test_tool();
922 let resp = tool
923 .execute(ToolRequest {
924 commands: "echo data > /tmp/out".to_string(),
925 timeout_ms: None,
926 })
927 .await;
928
929 assert_ne!(resp.exit_code, 0);
930 assert!(
931 resp.stderr.contains("filesystem redirection disabled"),
932 "{}",
933 resp.stderr
934 );
935 }
936
937 #[tokio::test]
938 async fn test_scripted_tool_rejects_file_redirection_before_callback() {
939 use std::sync::atomic::{AtomicUsize, Ordering};
940
941 let calls = Arc::new(AtomicUsize::new(0));
942 let tool_calls = Arc::clone(&calls);
943 let tool = ScriptedTool::builder("test_api")
944 .tool_fn(
945 ToolDef::new("side_effect", "Count calls"),
946 move |_args: &ToolArgs| {
947 tool_calls.fetch_add(1, Ordering::SeqCst);
948 Ok("called\n".to_string())
949 },
950 )
951 .build();
952
953 let resp = tool
954 .execute(ToolRequest {
955 commands: "side_effect > /tmp/out".to_string(),
956 timeout_ms: None,
957 })
958 .await;
959
960 assert_ne!(resp.exit_code, 0);
961 assert!(
962 resp.stderr.contains("filesystem redirection disabled"),
963 "{}",
964 resp.stderr
965 );
966 assert_eq!(calls.load(Ordering::SeqCst), 0);
967 }
968
969 #[tokio::test]
970 async fn test_scripted_tool_allows_dev_null_redirection() {
971 let tool = build_test_tool();
972 let resp = tool
973 .execute(ToolRequest {
974 commands: "echo hidden > /dev/null; echo visible".to_string(),
975 timeout_ms: None,
976 })
977 .await;
978
979 assert_eq!(resp.exit_code, 0);
980 assert_eq!(resp.stdout.trim(), "visible");
981 }
982
983 #[tokio::test]
984 async fn test_scripted_tool_rejects_input_redirection() {
985 let tool = build_test_tool();
986 let resp = tool
987 .execute(ToolRequest {
988 commands: "from_stdin < /tmp/in".to_string(),
989 timeout_ms: None,
990 })
991 .await;
992
993 assert_ne!(resp.exit_code, 0);
994 assert!(
995 resp.stderr.contains("filesystem redirection disabled"),
996 "{}",
997 resp.stderr
998 );
999 }
1000
1001 #[tokio::test]
1002 async fn test_scripted_tool_rejects_path_operands_for_dual_use_tools() {
1003 let tool = build_test_tool();
1004 let resp = tool
1005 .execute(ToolRequest {
1006 commands: "grep Alice /tmp/users.json".to_string(),
1007 timeout_ms: None,
1008 })
1009 .await;
1010
1011 assert_ne!(resp.exit_code, 0);
1012 let combined = format!("{}{}", resp.stdout, resp.stderr);
1013 assert!(
1014 combined.contains("filesystem access disabled"),
1015 "{}",
1016 combined
1017 );
1018 }
1019
1020 #[tokio::test]
1021 async fn test_scripted_tool_rejects_script_path_execution() {
1022 let tool = build_test_tool();
1023 let resp = tool
1024 .execute(ToolRequest {
1025 commands: "/tmp/script.sh".to_string(),
1026 timeout_ms: None,
1027 })
1028 .await;
1029
1030 assert_eq!(resp.exit_code, 127);
1031 assert!(resp.stderr.contains("command not found"), "{}", resp.stderr);
1032 }
1033
1034 #[tokio::test]
1035 async fn test_scripted_tool_rejects_process_substitution() {
1036 let tool = build_test_tool();
1037 let resp = tool
1038 .execute(ToolRequest {
1039 commands: "from_stdin < <(echo data)".to_string(),
1040 timeout_ms: None,
1041 })
1042 .await;
1043
1044 assert_ne!(resp.exit_code, 0);
1045 assert!(
1046 resp.stderr.contains("process substitution disabled"),
1047 "{}",
1048 resp.stderr
1049 );
1050 }
1051
1052 #[tokio::test]
1053 async fn test_execute_with_env() {
1054 let tool = ScriptedTool::builder("env_test")
1055 .env("API_BASE", "https://api.example.com")
1056 .tool_fn(ToolDef::new("noop", "No-op"), |_args: &ToolArgs| {
1057 Ok("ok\n".to_string())
1058 })
1059 .build();
1060
1061 let resp = tool
1062 .execute(ToolRequest {
1063 commands: "echo $API_BASE".to_string(),
1064 timeout_ms: None,
1065 })
1066 .await;
1067 assert_eq!(resp.exit_code, 0);
1068 assert_eq!(resp.stdout.trim(), "https://api.example.com");
1069 }
1070
1071 #[tokio::test]
1072 async fn test_execute_with_status_callback() {
1073 use std::sync::{Arc, Mutex};
1074
1075 let tool = build_test_tool();
1076 let phases = Arc::new(Mutex::new(Vec::new()));
1077 let phases_clone = phases.clone();
1078
1079 let resp = tool
1080 .execute_with_status(
1081 ToolRequest {
1082 commands: "get_user --id 1".to_string(),
1083 timeout_ms: None,
1084 },
1085 Box::new(move |status| {
1086 phases_clone
1087 .lock()
1088 .expect("lock poisoned")
1089 .push(status.phase.clone());
1090 }),
1091 )
1092 .await;
1093
1094 assert_eq!(resp.exit_code, 0);
1095 let phases = phases.lock().expect("lock poisoned");
1096 assert!(phases.contains(&"validate".to_string()));
1097 assert!(phases.contains(&"execute".to_string()));
1098 assert!(phases.contains(&"complete".to_string()));
1099 }
1100
1101 #[tokio::test]
1102 async fn test_multiple_execute_calls() {
1103 let tool = build_test_tool();
1104
1105 let resp1 = tool
1106 .execute(ToolRequest {
1107 commands: "get_user --id 1 | jq -r '.name'".to_string(),
1108 timeout_ms: None,
1109 })
1110 .await;
1111 assert_eq!(resp1.stdout.trim(), "Alice");
1112
1113 let resp2 = tool
1114 .execute(ToolRequest {
1115 commands: "get_orders --user_id 1 | jq 'length'".to_string(),
1116 timeout_ms: None,
1117 })
1118 .await;
1119 assert_eq!(resp2.stdout.trim(), "2");
1120 }
1121
1122 #[tokio::test]
1123 async fn test_boolean_flag() {
1124 let tool = ScriptedTool::builder("bool_test")
1125 .tool_fn(
1126 ToolDef::new("search", "Search").with_schema(serde_json::json!({
1127 "type": "object",
1128 "properties": {
1129 "query": {"type": "string"},
1130 "verbose": {"type": "boolean"}
1131 }
1132 })),
1133 |args: &ToolArgs| {
1134 let q = args.param_str("query").unwrap_or("");
1135 let v = args.param_bool("verbose").unwrap_or(false);
1136 Ok(format!("q={q} verbose={v}\n"))
1137 },
1138 )
1139 .build();
1140
1141 let resp = tool
1142 .execute(ToolRequest {
1143 commands: "search --verbose --query hello".to_string(),
1144 timeout_ms: None,
1145 })
1146 .await;
1147 assert_eq!(resp.exit_code, 0);
1148 assert_eq!(resp.stdout.trim(), "q=hello verbose=true");
1149 }
1150
1151 #[tokio::test]
1152 async fn test_no_schema_treats_as_strings() {
1153 let tool = ScriptedTool::builder("str_test")
1154 .tool_fn(
1155 ToolDef::new("echo_args", "Echo params as JSON"),
1156 |args: &ToolArgs| Ok(format!("{}\n", args.params)),
1157 )
1158 .build();
1159
1160 let resp = tool
1161 .execute(ToolRequest {
1162 commands: "echo_args --name Alice --count 3".to_string(),
1163 timeout_ms: None,
1164 })
1165 .await;
1166 assert_eq!(resp.exit_code, 0);
1167 let parsed: serde_json::Value =
1168 serde_json::from_str(resp.stdout.trim()).expect("stdout should be valid JSON");
1169 assert_eq!(parsed["name"], "Alice");
1170 assert_eq!(parsed["count"], "3"); }
1172
1173 #[tokio::test]
1176 async fn test_shared_arc_across_callbacks() {
1177 use std::sync::{Arc, Mutex};
1178
1179 let shared = Arc::new("shared-token".to_string());
1180 let call_log = Arc::new(Mutex::new(Vec::<String>::new()));
1181
1182 let s1 = shared.clone();
1183 let log1 = call_log.clone();
1184 let s2 = shared.clone();
1185 let log2 = call_log.clone();
1186
1187 let tool = ScriptedTool::builder("ctx_test")
1188 .tool_fn(
1189 ToolDef::new("tool_a", "First tool"),
1190 move |_args: &ToolArgs| {
1191 log1.lock().expect("lock").push(format!("a:{}", *s1));
1192 Ok("a\n".to_string())
1193 },
1194 )
1195 .tool_fn(
1196 ToolDef::new("tool_b", "Second tool"),
1197 move |_args: &ToolArgs| {
1198 log2.lock().expect("lock").push(format!("b:{}", *s2));
1199 Ok("b\n".to_string())
1200 },
1201 )
1202 .build();
1203
1204 let resp = tool
1205 .execute(ToolRequest {
1206 commands: "tool_a && tool_b".to_string(),
1207 timeout_ms: None,
1208 })
1209 .await;
1210 assert_eq!(resp.exit_code, 0);
1211 let log = call_log.lock().expect("lock");
1212 assert_eq!(*log, vec!["a:shared-token", "b:shared-token"]);
1213 }
1214
1215 #[tokio::test]
1216 async fn test_mutable_shared_state_across_callbacks() {
1217 use std::sync::{Arc, Mutex};
1218
1219 let counter = Arc::new(Mutex::new(0u64));
1220 let c = counter.clone();
1221
1222 let tool = ScriptedTool::builder("mut_test")
1223 .tool_fn(
1224 ToolDef::new("increment", "Bump counter"),
1225 move |_args: &ToolArgs| {
1226 let mut count = c.lock().expect("lock");
1227 *count += 1;
1228 Ok(format!("{count}\n"))
1229 },
1230 )
1231 .build();
1232
1233 let resp = tool
1234 .execute(ToolRequest {
1235 commands: "increment; increment; increment".to_string(),
1236 timeout_ms: None,
1237 })
1238 .await;
1239 assert_eq!(resp.exit_code, 0);
1240 assert_eq!(*counter.lock().expect("lock"), 3);
1241 }
1242
1243 #[tokio::test]
1246 async fn test_fresh_interpreter_per_execute() {
1247 let tool = ScriptedTool::builder("isolation_test")
1248 .tool_fn(ToolDef::new("noop", "No-op"), |_args: &ToolArgs| {
1249 Ok("ok\n".to_string())
1250 })
1251 .build();
1252
1253 let resp1 = tool
1255 .execute(ToolRequest {
1256 commands: "export MY_VAR=hello; echo $MY_VAR".to_string(),
1257 timeout_ms: None,
1258 })
1259 .await;
1260 assert_eq!(resp1.stdout.trim(), "hello");
1261
1262 let resp2 = tool
1264 .execute(ToolRequest {
1265 commands: "echo \">${MY_VAR}<\"".to_string(),
1266 timeout_ms: None,
1267 })
1268 .await;
1269 assert_eq!(resp2.stdout.trim(), "><");
1270 }
1271
1272 #[tokio::test]
1273 async fn test_arc_callback_persists_across_execute_calls() {
1274 use std::sync::{Arc, Mutex};
1275
1276 let counter = Arc::new(Mutex::new(0u64));
1277 let c = counter.clone();
1278
1279 let tool = ScriptedTool::builder("persist_test")
1280 .tool_fn(
1281 ToolDef::new("count", "Count calls"),
1282 move |_args: &ToolArgs| {
1283 let mut n = c.lock().expect("lock");
1284 *n += 1;
1285 Ok(format!("{n}\n"))
1286 },
1287 )
1288 .build();
1289
1290 let resp1 = tool
1292 .execute(ToolRequest {
1293 commands: "count".to_string(),
1294 timeout_ms: None,
1295 })
1296 .await;
1297 assert_eq!(resp1.stdout.trim(), "1");
1298
1299 let resp2 = tool
1301 .execute(ToolRequest {
1302 commands: "count".to_string(),
1303 timeout_ms: None,
1304 })
1305 .await;
1306 assert_eq!(resp2.stdout.trim(), "2");
1307 }
1308
1309 #[tokio::test]
1310 async fn test_execution_trace_records_help_discover_and_tool_invocations() {
1311 let tool = build_test_tool();
1312
1313 let resp = tool
1314 .execute(ToolRequest {
1315 commands: "discover --search user\nhelp get_user\nget_user --id 42".to_string(),
1316 timeout_ms: None,
1317 })
1318 .await;
1319 assert_eq!(resp.exit_code, 0);
1320
1321 let trace = tool
1322 .take_last_execution_trace()
1323 .expect("execution trace should be recorded");
1324 assert_eq!(trace.invocations.len(), 3);
1325 assert_eq!(trace.invocations[0].name, "discover");
1326 assert_eq!(trace.invocations[0].kind, ScriptedCommandKind::Discover);
1327 assert_eq!(trace.invocations[1].name, "help");
1328 assert_eq!(trace.invocations[1].kind, ScriptedCommandKind::Help);
1329 assert_eq!(trace.invocations[2].name, "get_user");
1330 assert_eq!(trace.invocations[2].kind, ScriptedCommandKind::Tool);
1331 }
1332
1333 #[tokio::test]
1336 async fn test_async_tool_basic() {
1337 let tool = ScriptedTool::builder("async_api")
1338 .async_tool_fn(
1339 ToolDef::new("greet", "Greet async").with_schema(serde_json::json!({
1340 "type": "object",
1341 "properties": { "name": {"type": "string"} }
1342 })),
1343 |args: ToolArgs| async move {
1344 let name = args.param_str("name").unwrap_or("world").to_string();
1345 Ok(format!("hello {name}\n"))
1346 },
1347 )
1348 .build();
1349
1350 let resp = tool
1351 .execute(ToolRequest {
1352 commands: "greet --name Async".to_string(),
1353 timeout_ms: None,
1354 })
1355 .await;
1356 assert_eq!(resp.exit_code, 0);
1357 assert_eq!(resp.stdout.trim(), "hello Async");
1358 }
1359
1360 #[tokio::test]
1361 async fn test_mixed_sync_async_tools() {
1362 let tool = ScriptedTool::builder("mixed")
1363 .tool_fn(ToolDef::new("sync_ping", "Sync"), |_args: &ToolArgs| {
1364 Ok("sync-pong\n".to_string())
1365 })
1366 .async_tool_fn(
1367 ToolDef::new("async_ping", "Async"),
1368 |_args: ToolArgs| async move { Ok("async-pong\n".to_string()) },
1369 )
1370 .build();
1371
1372 let resp = tool
1373 .execute(ToolRequest {
1374 commands: "sync_ping; async_ping".to_string(),
1375 timeout_ms: None,
1376 })
1377 .await;
1378 assert_eq!(resp.exit_code, 0);
1379 assert!(resp.stdout.contains("sync-pong"));
1380 assert!(resp.stdout.contains("async-pong"));
1381 }
1382
1383 #[tokio::test]
1384 async fn test_async_tool_error_propagates() {
1385 let tool = ScriptedTool::builder("err_api")
1386 .sanitize_errors(false)
1387 .async_tool_fn(
1388 ToolDef::new("fail", "Always fails"),
1389 |_args: ToolArgs| async move { Err("async boom".to_string()) },
1390 )
1391 .build();
1392
1393 let resp = tool
1394 .execute(ToolRequest {
1395 commands: "fail".to_string(),
1396 timeout_ms: None,
1397 })
1398 .await;
1399 assert_ne!(resp.exit_code, 0);
1400 assert!(resp.stderr.contains("async boom"));
1401 }
1402
1403 #[tokio::test]
1404 async fn test_async_tool_stdin_pipe() {
1405 let tool = ScriptedTool::builder("pipe_api")
1406 .async_tool_fn(
1407 ToolDef::new("upper", "Uppercase stdin"),
1408 |args: ToolArgs| async move { Ok(args.stdin.unwrap_or_default().to_uppercase()) },
1409 )
1410 .build();
1411
1412 let resp = tool
1413 .execute(ToolRequest {
1414 commands: "echo hello | upper".to_string(),
1415 timeout_ms: None,
1416 })
1417 .await;
1418 assert_eq!(resp.exit_code, 0);
1419 assert!(resp.stdout.contains("HELLO"));
1420 }
1421
1422 #[tokio::test]
1425 async fn test_tool_impl_in_scripted_tool() {
1426 let get_user = ToolImpl::new(ToolDef::new("get_user", "Fetch user by ID").with_schema(
1427 serde_json::json!({
1428 "type": "object",
1429 "properties": { "id": {"type": "integer"} },
1430 "required": ["id"]
1431 }),
1432 ))
1433 .with_exec_sync(|args| {
1434 let id = args.param_i64("id").ok_or("missing --id")?;
1435 Ok(format!("{{\"id\":{id},\"name\":\"Alice\"}}\n"))
1436 });
1437
1438 let tool = ScriptedTool::builder("api")
1439 .short_description("Test API")
1440 .tool(get_user)
1441 .build();
1442
1443 assert!(tool.system_prompt().contains("get_user"));
1444 assert!(tool.help().contains("get_user"));
1445
1446 let resp = tool
1447 .execute(ToolRequest {
1448 commands: "get_user --id 42 | jq -r '.name'".to_string(),
1449 timeout_ms: None,
1450 })
1451 .await;
1452 assert_eq!(resp.exit_code, 0);
1453 assert_eq!(resp.stdout.trim(), "Alice");
1454 }
1455
1456 #[tokio::test]
1457 async fn test_tool_impl_async_exec_in_scripted_tool() {
1458 let greet = ToolImpl::new(ToolDef::new("greet", "Greet someone").with_schema(
1459 serde_json::json!({
1460 "type": "object",
1461 "properties": { "name": {"type": "string"} }
1462 }),
1463 ))
1464 .with_exec(|args| async move {
1465 let name = args.param_str("name").unwrap_or("world");
1466 Ok(format!("hello {name}\n"))
1467 });
1468
1469 let tool = ScriptedTool::builder("api").tool(greet).build();
1470
1471 let resp = tool
1472 .execute(ToolRequest {
1473 commands: "greet --name Bob".to_string(),
1474 timeout_ms: None,
1475 })
1476 .await;
1477 assert_eq!(resp.exit_code, 0);
1478 assert_eq!(resp.stdout.trim(), "hello Bob");
1479 }
1480
1481 #[tokio::test]
1482 async fn test_tool_impl_mixed_with_tool_fn() {
1483 let tool_impl = ToolImpl::new(ToolDef::new("impl_cmd", "From ToolImpl"))
1484 .with_exec_sync(|_args| Ok("from_impl\n".to_string()));
1485
1486 let tool = ScriptedTool::builder("mixed")
1487 .tool(tool_impl)
1488 .tool_fn(ToolDef::new("fn_cmd", "From tool_fn"), |_args| {
1489 Ok("from_fn\n".to_string())
1490 })
1491 .build();
1492
1493 let resp = tool
1494 .execute(ToolRequest {
1495 commands: "echo $(impl_cmd) $(fn_cmd)".to_string(),
1496 timeout_ms: None,
1497 })
1498 .await;
1499 assert_eq!(resp.exit_code, 0);
1500 assert!(resp.stdout.contains("from_impl"));
1501 assert!(resp.stdout.contains("from_fn"));
1502 }
1503
1504 #[tokio::test]
1505 async fn test_tool_def_extension_registers_tools_help_and_discover_in_bash() {
1506 let extension = ToolDefExtension::builder()
1507 .tool_fn(
1508 ToolDef::new("get_user", "Fetch user by ID")
1509 .with_schema(serde_json::json!({
1510 "type": "object",
1511 "properties": { "id": {"type": "integer"} }
1512 }))
1513 .with_category("users")
1514 .with_tags(&["read"]),
1515 |args: &ToolArgs| {
1516 let id = args.param_i64("id").ok_or("missing --id")?;
1517 Ok(format!("{{\"id\":{id}}}\n"))
1518 },
1519 )
1520 .build();
1521
1522 let mut bash = bashkit::Bash::builder().extension(extension).build();
1523 let result = bash
1524 .exec("discover --category users\nhelp get_user\nget_user --id 7")
1525 .await
1526 .expect("extension commands should execute");
1527
1528 assert_eq!(result.exit_code, 0);
1529 assert!(result.stdout.contains("get_user"));
1530 assert!(result.stdout.contains("Usage: get_user --id <integer>"));
1531 assert!(result.stdout.contains(r#""id":7"#));
1532 }
1533
1534 #[tokio::test]
1535 async fn test_tool_def_extension_builds_have_isolated_invocation_logs() {
1536 let builder = ToolDefExtension::builder()
1539 .tool_fn(ToolDef::new("echo_arg", "Echo"), |args: &ToolArgs| {
1540 Ok(format!("{}\n", args.param_str("msg").unwrap_or_default()))
1541 });
1542 let ext_a = builder.build();
1543 let ext_b = builder.build();
1544 let handle_a = ext_a.invocation_trace();
1545 let handle_b = ext_b.invocation_trace();
1546
1547 let mut bash_a = bashkit::Bash::builder().extension(ext_a).build();
1548 let mut bash_b = bashkit::Bash::builder().extension(ext_b).build();
1549 bash_a
1550 .exec("echo_arg --msg alpha")
1551 .await
1552 .expect("bash a should execute");
1553 bash_b
1554 .exec("echo_arg --msg beta")
1555 .await
1556 .expect("bash b should execute");
1557
1558 let trace_a = handle_a.take_invocations();
1559 let trace_b = handle_b.take_invocations();
1560 assert_eq!(trace_a.len(), 1);
1561 assert_eq!(
1562 trace_a[0].args,
1563 vec!["--msg".to_string(), "alpha".to_string()]
1564 );
1565 assert_eq!(trace_b.len(), 1);
1566 assert_eq!(
1567 trace_b[0].args,
1568 vec!["--msg".to_string(), "beta".to_string()]
1569 );
1570 }
1571
1572 #[tokio::test]
1573 async fn test_tool_def_extension_clones_have_isolated_invocation_logs() {
1574 let extension = ToolDefExtension::builder()
1577 .tool_fn(ToolDef::new("echo_arg", "Echo"), |args: &ToolArgs| {
1578 Ok(format!("{}\n", args.param_str("msg").unwrap_or_default()))
1579 })
1580 .build();
1581 let clone = extension.clone();
1582 let extension_trace = extension.invocation_trace();
1583 let clone_trace = clone.invocation_trace();
1584
1585 let mut bash_a = bashkit::Bash::builder().extension(extension).build();
1586 let mut bash_b = bashkit::Bash::builder().extension(clone).build();
1587 bash_a
1588 .exec("echo_arg --msg gamma")
1589 .await
1590 .expect("bash a should execute");
1591 bash_b
1592 .exec("echo_arg --msg delta")
1593 .await
1594 .expect("bash b should execute");
1595
1596 let trace_a = extension_trace.take_invocations();
1597 let trace_b = clone_trace.take_invocations();
1598 assert_eq!(trace_a.len(), 1);
1599 assert_eq!(
1600 trace_a[0].args,
1601 vec!["--msg".to_string(), "gamma".to_string()]
1602 );
1603 assert_eq!(trace_b.len(), 1);
1604 assert_eq!(
1605 trace_b[0].args,
1606 vec!["--msg".to_string(), "delta".to_string()]
1607 );
1608 }
1609
1610 #[tokio::test]
1611 async fn test_tool_def_extension_invocations_are_bounded_and_truncated() {
1612 let extension = ToolDefExtension::builder()
1613 .tool_fn(ToolDef::new("noop", "No-op"), |_args: &ToolArgs| {
1614 Ok("ok\n".to_string())
1615 })
1616 .build();
1617 let handle = extension.invocation_trace();
1618 let mut bash = bashkit::Bash::builder().extension(extension).build();
1619
1620 for _ in 0..300 {
1621 let cmd = format!("noop --msg {}", "x".repeat(1500));
1622 bash.exec(&cmd).await.expect("noop should execute");
1623 }
1624 let trace = handle.take_invocations();
1625 assert_eq!(trace.len(), 256, "log must be capped at MAX_LOG_ENTRIES");
1626 assert_eq!(
1627 trace[0].args[1].len(),
1628 1024,
1629 "long argv tokens must be truncated to MAX_LOG_ARG_BYTES"
1630 );
1631 }
1632
1633 #[tokio::test]
1634 async fn test_tool_def_extension_truncation_is_byte_aware_for_utf8() {
1635 let extension = ToolDefExtension::builder()
1639 .tool_fn(ToolDef::new("noop", "No-op"), |_args: &ToolArgs| {
1640 Ok("ok\n".to_string())
1641 })
1642 .build();
1643 let handle = extension.invocation_trace();
1644 let mut bash = bashkit::Bash::builder().extension(extension).build();
1645
1646 let big = "\u{1F600}".repeat(400);
1647 let cmd = format!("noop --msg {}", big);
1648 bash.exec(&cmd).await.expect("noop should execute");
1649
1650 let trace = handle.take_invocations();
1651 assert_eq!(trace.len(), 1);
1652 let truncated = &trace[0].args[1];
1653 assert!(truncated.len() <= 1024, "byte length must respect cap");
1654 assert_eq!(truncated.chars().count(), 256);
1656 }
1657
1658 #[tokio::test]
1661 async fn test_tool_help_flag_returns_help_text() {
1662 let tool = build_test_tool();
1663 let resp = tool
1664 .execute(ToolRequest {
1665 commands: "get_user --help".to_string(),
1666 timeout_ms: None,
1667 })
1668 .await;
1669 assert_eq!(resp.exit_code, 0);
1670 assert!(
1671 resp.stdout.contains("get_user"),
1672 "help should include tool name"
1673 );
1674 assert!(
1675 resp.stdout.contains("Fetch user by id"),
1676 "help should include description"
1677 );
1678 assert!(
1679 resp.stdout.contains("--id"),
1680 "help should include parameter flags"
1681 );
1682 }
1683
1684 #[tokio::test]
1685 async fn test_tool_help_flag_does_not_invoke_callback() {
1686 let tool = build_test_tool();
1687 let resp = tool
1689 .execute(ToolRequest {
1690 commands: "fail_tool --help".to_string(),
1691 timeout_ms: None,
1692 })
1693 .await;
1694 assert_eq!(
1695 resp.exit_code, 0,
1696 "--help should succeed even for fail_tool"
1697 );
1698 assert!(
1699 resp.stdout.contains("Always fails"),
1700 "help should include description"
1701 );
1702 }
1703
1704 #[tokio::test]
1705 async fn test_tool_help_flag_same_as_help_builtin() {
1706 let tool = build_test_tool();
1707 let help_output = tool
1708 .execute(ToolRequest {
1709 commands: "help get_user".to_string(),
1710 timeout_ms: None,
1711 })
1712 .await;
1713 let flag_output = tool
1714 .execute(ToolRequest {
1715 commands: "get_user --help".to_string(),
1716 timeout_ms: None,
1717 })
1718 .await;
1719 assert_eq!(
1720 help_output.stdout, flag_output.stdout,
1721 "`--help` should produce same output as `help <tool>`"
1722 );
1723 }
1724
1725 #[tokio::test]
1726 async fn test_tool_help_flag_stripped_from_args() {
1727 let tool = build_test_tool();
1728 let resp = tool
1730 .execute(ToolRequest {
1731 commands: "get_user --help --id 42".to_string(),
1732 timeout_ms: None,
1733 })
1734 .await;
1735 assert_eq!(resp.exit_code, 0);
1736 assert!(resp.stdout.contains("Fetch user by id"));
1738 assert!(
1739 !resp.stdout.contains("Alice"),
1740 "callback should NOT be invoked"
1741 );
1742 }
1743
1744 #[tokio::test]
1747 async fn test_dry_run_validates_args() {
1748 let tool = build_test_tool();
1749 let resp = tool
1750 .execute(ToolRequest {
1751 commands: "get_user --dry-run --id 42".to_string(),
1752 timeout_ms: None,
1753 })
1754 .await;
1755 assert_eq!(resp.exit_code, 0);
1756 let parsed: serde_json::Value =
1757 serde_json::from_str(resp.stdout.trim()).expect("stdout should be valid JSON");
1758 assert_eq!(parsed["dry_run"], true);
1759 assert_eq!(parsed["valid"], true);
1760 assert_eq!(parsed["params"]["id"], 42);
1761 }
1762
1763 #[tokio::test]
1764 async fn test_dry_run_does_not_invoke_callback() {
1765 let tool = build_test_tool();
1766 let resp = tool
1768 .execute(ToolRequest {
1769 commands: "fail_tool --dry-run".to_string(),
1770 timeout_ms: None,
1771 })
1772 .await;
1773 assert_eq!(
1774 resp.exit_code, 0,
1775 "--dry-run should not invoke the callback"
1776 );
1777 }
1778
1779 #[tokio::test]
1780 async fn test_dry_run_help_precedence() {
1781 let tool = build_test_tool();
1782 let resp = tool
1783 .execute(ToolRequest {
1784 commands: "get_user --help --dry-run".to_string(),
1785 timeout_ms: None,
1786 })
1787 .await;
1788 assert_eq!(resp.exit_code, 0);
1789 assert!(
1791 resp.stdout.contains("Fetch user by id"),
1792 "should show help text"
1793 );
1794 assert!(
1795 !resp.stdout.contains("dry_run"),
1796 "should NOT show dry-run JSON"
1797 );
1798 }
1799
1800 #[tokio::test]
1801 async fn test_custom_dry_run_handler() {
1802 let tool = ScriptedTool::builder("dr_test")
1803 .tool_with_dry_run(
1804 ToolDef::new("check", "Validate input").with_schema(serde_json::json!({
1805 "type": "object",
1806 "properties": { "id": {"type": "integer"} }
1807 })),
1808 |args: &ToolArgs| {
1809 let id = args.param_i64("id").ok_or("missing --id")?;
1810 Ok(format!("executed {id}\n"))
1811 },
1812 |args: &ToolArgs| {
1813 let id = args.param_i64("id").ok_or("missing --id")?;
1814 Ok(format!("custom-dry-run id={id}\n"))
1815 },
1816 )
1817 .build();
1818
1819 let resp = tool
1820 .execute(ToolRequest {
1821 commands: "check --dry-run --id 7".to_string(),
1822 timeout_ms: None,
1823 })
1824 .await;
1825 assert_eq!(resp.exit_code, 0);
1826 assert_eq!(resp.stdout.trim(), "custom-dry-run id=7");
1827 }
1828
1829 #[tokio::test]
1830 async fn test_custom_dry_run_handler_sanitizes_errors() {
1831 let tool = ScriptedTool::builder("dr_sanitize")
1832 .tool_with_dry_run(
1833 ToolDef::new("check", "Validate input"),
1834 |_args: &ToolArgs| Ok("ok\n".to_string()),
1835 |_args: &ToolArgs| {
1836 Err("sensitive: /tmp/token.txt postgres://user:pass@localhost/db".to_string())
1837 },
1838 )
1839 .build();
1840
1841 let resp = tool
1842 .execute(ToolRequest {
1843 commands: "check --dry-run".to_string(),
1844 timeout_ms: None,
1845 })
1846 .await;
1847 assert_eq!(resp.exit_code, 1);
1848 assert!(resp.stderr.contains("callback failed"));
1849 assert!(!resp.stderr.contains("sensitive"));
1850 assert!(!resp.stderr.contains("postgres://"));
1851 }
1852
1853 #[tokio::test]
1854 async fn test_help_flag_returns_help() {
1855 let tool = build_test_tool();
1856 let resp = tool
1857 .execute(ToolRequest {
1858 commands: "get_user --help".to_string(),
1859 timeout_ms: None,
1860 })
1861 .await;
1862 assert_eq!(resp.exit_code, 0);
1863 assert!(
1864 resp.stdout.contains("get_user"),
1865 "help should include tool name"
1866 );
1867 assert!(
1868 resp.stdout.contains("Fetch user by id"),
1869 "help should include description"
1870 );
1871 assert!(
1872 resp.stdout.contains("--id"),
1873 "help should include parameter flags"
1874 );
1875 }
1876}