Skip to main content

bashkit_scripted_tool/scripted_tool/
mod.rs

1//! Scripted tool
2//!
3//! Compose tool definitions + callbacks into a single [`Tool`] that accepts bash
4//! scripts. Each registered tool becomes a builtin command inside the interpreter,
5//! so an LLM can orchestrate many operations in one call using pipes, variables,
6//! loops, and conditionals.
7//!
8//! This module follows the same contract surface as [`bashkit::tool`]:
9//!
10//! - [`ScriptedToolBuilder::build`] -> immutable metadata object
11//! - [`ScriptedToolBuilder::build_service`] -> `tower::Service<Value, Value>`
12//! - [`Tool::execution`] -> validated, single-use [`bashkit::ToolExecution`]
13//! - [`Tool::help`] -> Markdown docs
14//! - [`Tool::system_prompt`] -> terse plain-text instructions
15//!
16//! # Architecture
17//!
18//! ```text
19//! ┌─────────────────────────────────────────┐
20//! │  ScriptedTool  (implements Tool)        │
21//! │                                         │
22//! │  ┌─────────┐ ┌─────────┐ ┌──────────┐  │
23//! │  │get_user │ │get_order│ │inventory │  │
24//! │  │(builtin)│ │(builtin)│ │(builtin) │  │
25//! │  └─────────┘ └─────────┘ └──────────┘  │
26//! │        ↑           ↑           ↑        │
27//! │  bash script: pipes, vars, jq, loops    │
28//! └─────────────────────────────────────────┘
29//! ```
30//!
31//! # Example
32//!
33//! ```rust
34//! use bashkit::Tool;
35//! use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef};
36//!
37//! # tokio_test::block_on(async {
38//! let tool = ScriptedTool::builder("api")
39//!     .tool_fn(
40//!         ToolDef::new("greet", "Greet a user")
41//!             .with_schema(serde_json::json!({
42//!                 "type": "object",
43//!                 "properties": { "name": {"type": "string"} }
44//!             })),
45//!         |args: &ToolArgs| {
46//!             let name = args.param_str("name").unwrap_or("world");
47//!             Ok(format!("hello {name}\n"))
48//!         },
49//!     )
50//!     .build();
51//!
52//! let output = tool
53//!     .execution(serde_json::json!({"commands": "greet --name Alice"}))
54//!     .expect("valid args")
55//!     .execute()
56//!     .await
57//!     .expect("execution succeeds");
58//!
59//! assert_eq!(output.result["stdout"], "hello Alice\n");
60//! assert!(tool.help().contains("## Tool Commands"));
61//! # });
62//! ```
63//!
64//! # Shared context across callbacks
65//!
66//! When multiple tool callbacks need shared resources (HTTP clients, auth tokens,
67//! config), use the standard Rust closure-capture pattern with `Arc`:
68//!
69//! ```rust
70//! use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef};
71//! use std::sync::Arc;
72//!
73//! let api_key = Arc::new("sk-secret-key".to_string());
74//! let base_url = Arc::new("https://api.example.com".to_string());
75//!
76//! let k = api_key.clone();
77//! let u = base_url.clone();
78//! let mut builder = ScriptedTool::builder("api");
79//! builder = builder.tool_fn(
80//!     ToolDef::new("get_user", "Fetch user by ID"),
81//!     move |args: &ToolArgs| {
82//!         let _key = &*k;   // shared API key
83//!         let _url = &*u;   // shared base URL
84//!         Ok(format!("{{\"id\":1}}\n"))
85//!     },
86//! );
87//!
88//! let k2 = api_key.clone();
89//! let u2 = base_url.clone();
90//! builder = builder.tool_fn(
91//!     ToolDef::new("list_orders", "List orders"),
92//!     move |_args: &ToolArgs| {
93//!         let _key = &*k2;
94//!         let _url = &*u2;
95//!         Ok(format!("[]\n"))
96//!     },
97//! );
98//! let _tool = builder.build();
99//! ```
100//!
101//! For mutable shared state, use `Arc<Mutex<T>>`:
102//!
103//! ```rust
104//! use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef};
105//! use std::sync::{Arc, Mutex};
106//!
107//! let call_count = Arc::new(Mutex::new(0u64));
108//! let c = call_count.clone();
109//! let tool = ScriptedTool::builder("api")
110//!     .tool_fn(
111//!         ToolDef::new("tracked", "Counted call"),
112//!         move |_args: &ToolArgs| {
113//!             let mut count = c.lock().unwrap();
114//!             *count += 1;
115//!             Ok(format!("call #{count}\n"))
116//!         },
117//!     )
118//!     .build();
119//! ```
120//!
121//! # State across execute() calls
122//!
123//! Each `execute()` creates a fresh Bash interpreter — no state carries over.
124//! This is a security feature (clean sandbox per call). The LLM carries state
125//! between calls via its context window: it sees stdout from each call and can
126//! pass relevant data from one call's output into the next call's script.
127//!
128//! For persistent state across calls via callbacks, use `Arc` in closures —
129//! the same `Arc<ToolCallback>` instances are reused across `execute()` calls.
130
131mod execute;
132mod extension;
133mod logic_only;
134mod toolset;
135
136pub use extension::{ToolDefExtension, ToolDefExtensionBuilder, ToolDefInvocationTrace};
137pub use toolset::{DiscoverTool, DiscoveryMode, ScriptingToolSet, ScriptingToolSetBuilder};
138
139// Re-export foundational types from tool_def (they used to live here).
140pub 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/// Sync or async callback for a registered tool.
150#[derive(Clone)]
151pub enum CallbackKind {
152    /// Synchronous callback — blocks until complete.
153    Sync(SyncToolExec),
154    /// Asynchronous callback — `.await`ed inside the interpreter.
155    Async(AsyncToolExec),
156}
157
158// ============================================================================
159// Execution trace — inner scripted command/builtin usage
160// ============================================================================
161
162/// Kind of inner command invocation recorded during a `ScriptedTool` execute.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164pub enum ScriptedCommandKind {
165    Tool,
166    Help,
167    Discover,
168}
169
170/// One builtin/tool invocation inside a scripted tool execute.
171#[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/// Inner execution trace captured for the last `ScriptedTool::execute()` call.
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct ScriptedExecutionTrace {
182    pub invocations: Vec<ScriptedCommandInvocation>,
183}
184
185// ============================================================================
186// RegisteredTool — internal definition + callback pair
187// ============================================================================
188
189/// A registered tool: definition + callback.
190#[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    /// Create from a [`ToolImpl`], converting its exec/exec_sync to a
199    /// [`CallbackKind`]. Prefers async when available.
200    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            // Schema-only ToolImpl — wrap as a sync callback that always errors.
207            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
218// ============================================================================
219// ScriptedToolBuilder
220// ============================================================================
221
222/// Builder for [`ScriptedTool`].
223///
224/// ```rust
225/// use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef};
226///
227/// let tool = ScriptedTool::builder("net")
228///     .short_description("Network tools")
229///     .tool_fn(
230///         ToolDef::new("ping", "Ping a host")
231///             .with_schema(serde_json::json!({
232///                 "type": "object",
233///                 "properties": { "host": {"type": "string"} }
234///             })),
235///         |args: &ToolArgs| {
236///             Ok(format!("pong {}\n", args.param_str("host").unwrap_or("?")))
237///         },
238///     )
239///     .build();
240/// ```
241pub struct ScriptedToolBuilder {
242    name: String,
243    locale: String,
244    short_desc: Option<String>,
245    tools: Vec<RegisteredTool>,
246    /// Raw-argv builtins registered alongside the `ToolDef` tools. `Arc` so one
247    /// instance is reused across the fresh shell each `execute()` builds.
248    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    /// When true, callback errors are replaced with a generic message to prevent
254    /// leaking internal details (file paths, connection strings, stack traces).
255    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    /// Set locale for descriptions, help, prompts, and user-facing errors.
275    pub fn locale(mut self, locale: &str) -> Self {
276        self.locale = locale.to_string();
277        self
278    }
279
280    /// One-line description for tool listings.
281    pub fn short_description(mut self, desc: impl Into<String>) -> Self {
282        self.short_desc = Some(desc.into());
283        self
284    }
285
286    /// Register a [`ToolImpl`] (definition + exec functions).
287    ///
288    /// This is the preferred registration method. The `ToolImpl` carries its own
289    /// name, schema, and sync/async exec.
290    pub fn tool(mut self, tool: ToolImpl) -> Self {
291        self.tools.push(RegisteredTool::from_tool_impl(tool));
292        self
293    }
294
295    /// Register a raw-argv builtin alongside the `ToolDef` tools.
296    ///
297    /// `ToolDef` commands are parsed by the schema-driven flag parser, which
298    /// only understands `--key value` and `--key=value`. A command that needs
299    /// positionals, short flags, `--`, or parse-time checking of required
300    /// arguments gets none of that. Registering it here hands the builtin the
301    /// raw `ctx.args` instead, so it can parse them however it likes —
302    /// including with [`ClapBuiltin`](bashkit::ClapBuiltin), the house parser for
303    /// `Bash` builtins.
304    ///
305    /// The builtin runs in the same logic-only shell as the `ToolDef` commands
306    /// and gets the same disabled filesystem, so registering one does not widen
307    /// the scripted catalog's filesystem posture. It is ordinary trusted host
308    /// code otherwise: whatever the builtin itself reaches, the script reaches.
309    ///
310    /// Custom builtins are registered *before* the `ToolDef` commands, so a
311    /// name collision resolves in favour of the registered tool: a custom
312    /// builtin can never shadow a tool command, `help`, or `discover`.
313    ///
314    /// ```rust
315    /// # use async_trait::async_trait;
316    /// # use bashkit::{Builtin, BuiltinContext, ExecResult};
317    /// # use bashkit_scripted_tool::ScriptedTool;
318    /// struct Ping;
319    ///
320    /// #[async_trait]
321    /// impl Builtin for Ping {
322    ///     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
323    ///         Ok(ExecResult::ok(format!("pong {}\n", ctx.args.join(" "))))
324    ///     }
325    /// }
326    ///
327    /// let tool = ScriptedTool::builder("api")
328    ///     .builtin("ping", Box::new(Ping))
329    ///     .build();
330    /// ```
331    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    /// Register a tool with its definition and synchronous exec function.
337    ///
338    /// Convenience shorthand — constructs a [`ToolImpl`] internally.
339    /// The exec receives [`ToolArgs`] with `--key value` flags parsed into
340    /// a JSON object, type-coerced per the schema.
341    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    /// Register a tool with its definition, exec function, and a custom
355    /// `--dry-run` handler. When the tool is invoked with `--dry-run`,
356    /// the custom handler runs instead of the regular callback.
357    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    /// Register a tool with its definition and **async** exec function.
372    ///
373    /// Convenience shorthand — constructs a [`ToolImpl`] internally.
374    /// Same as [`tool_fn()`](Self::tool_fn) but returns a `Future`,
375    /// allowing non-blocking I/O. Takes owned [`ToolArgs`] because the future
376    /// may outlive the borrow.
377    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    /// Set execution limits for the bash interpreter.
392    pub fn limits(mut self, limits: ExecutionLimits) -> Self {
393        self.limits = Some(limits);
394        self
395    }
396
397    /// Apply execution, session, and memory policy from a profile.
398    ///
399    /// Scripted tools always retain their disabled filesystem/network/runtime
400    /// surface; those capability policies are intentionally not widened.
401    pub fn profile(mut self, profile: ExecutionProfile) -> Self {
402        self.profile = Some(profile);
403        self
404    }
405
406    /// Add an environment variable visible inside scripts.
407    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    /// Control whether callback and custom-builtin fatal errors are sanitized
413    /// before appearing in tool output. When `true` (the default), internal error
414    /// details are replaced with a generic "callback failed" message to prevent
415    /// leaking file paths, connection strings, or stack traces to LLM agents.
416    /// Cancellation and resource-limit errors retain their actionable category.
417    // THREAT[TM-INF-030]: Prevent information disclosure through callback errors.
418    pub fn sanitize_errors(mut self, sanitize: bool) -> Self {
419        self.sanitize_errors = sanitize;
420        self
421    }
422
423    /// Emit compact `system_prompt()` that omits full schemas and adds help tip.
424    ///
425    /// When enabled, `system_prompt()` lists only tool names + one-liners and
426    /// instructs the LLM to use `help <tool>` / `help <tool> --json` for details.
427    /// Default: `false` (full schemas in prompt, backward compatible).
428    pub fn compact_prompt(mut self, compact: bool) -> Self {
429        self.compact_prompt = compact;
430        self
431    }
432
433    /// Build the [`ScriptedTool`].
434    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    /// Build a `tower::Service<Value, Response = Value, Error = ToolError>`.
472    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    /// Build an OpenAI-compatible tool definition.
485    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    /// Build the input schema without constructing the full tool.
498    pub fn build_input_schema(&self) -> serde_json::Value {
499        bashkit::tool::tool_request_schema()
500    }
501
502    /// Build the output schema for `ToolOutput::result`.
503    pub fn build_output_schema(&self) -> serde_json::Value {
504        bashkit::tool::tool_response_schema()
505    }
506}
507
508// ============================================================================
509// ScriptedTool
510// ============================================================================
511
512/// A [`Tool`] that orchestrates multiple tools via bash scripts.
513///
514/// Each registered tool (defined by [`ToolDef`] + callback) becomes a bash builtin.
515/// The LLM sends a bash script that can pipe, loop, branch, and compose these
516/// builtins together with standard utilities like `jq`, `grep`, `sed`, etc.
517///
518/// Arguments are passed as `--key value` flags and parsed into typed JSON
519/// per the tool's `input_schema`.
520///
521/// Reusable — `execute()` can be called multiple times. Each call gets a fresh
522/// Bash interpreter with the same set of tool builtins.
523///
524/// Create via [`ScriptedTool::builder`].
525#[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    /// Create a builder with the given tool name.
544    pub fn builder(name: impl Into<String>) -> ScriptedToolBuilder {
545        ScriptedToolBuilder::new(name)
546    }
547
548    /// Return and clear the trace from the most recent execute call.
549    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// ============================================================================
565// Tests
566// ============================================================================
567
568#[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    // -- Builder tests --
620
621    #[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    // -- Execution tests --
752
753    #[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"); // string, not int — no schema
1171    }
1172
1173    // -- Shared context tests (#522) --
1174
1175    #[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    // -- Fresh interpreter isolation test (#524) --
1244
1245    #[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        // Set a variable in call 1
1254        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        // Variable should NOT persist to call 2
1263        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        // Call 1
1291        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        // Call 2 — counter persists via Arc
1300        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    // -- Async callback tests --
1334
1335    #[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    // -- ToolImpl registration --
1423
1424    #[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        // Two separate `build()` calls must NOT share traces — that is the
1537        // cross-tenant isolation contract.
1538        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        // Cloning copies command configuration only; trace sharing requires an
1575        // explicit ToolDefInvocationTrace handle.
1576        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        // 4-byte UTF-8 codepoint (U+1F600 😀); 400 of them = 1600 bytes,
1636        // well over the 1024 cap. Truncation must (a) cap by bytes,
1637        // not chars, and (b) leave a valid UTF-8 string.
1638        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        // Each codepoint is 4 bytes; 1024 / 4 = 256 codepoints fit.
1655        assert_eq!(truncated.chars().count(), 256);
1656    }
1657
1658    // -- Issue #1278: --help flag tests --
1659
1660    #[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        // fail_tool always returns an error, but --help should not invoke it
1688        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        // get_user --help --id 42 should not call the callback with --help in args
1729        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        // Output should be help text, not the callback result
1737        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    // -- Issue #1279: --dry-run flag tests --
1745
1746    #[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        // fail_tool always errors, but --dry-run should succeed
1767        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        // Should return help text, not dry-run JSON
1790        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}