Skip to main content

leviath_scripting/
tool.rs

1//! Rhai *script tools* - drop-in tool definitions for agent blueprints.
2//!
3//! A `.rhai` file in an agent's `tools/` directory (or the global
4//! `~/.leviath/tools/`) defines one custom tool. Its metadata comes from comment
5//! annotations at the top of the file (`// @tool`, `// @description`, `// @param`)
6//! or an optional sibling `tool.toml` (which, when present, overrides the
7//! annotations). Each script is compiled to a Rhai [`AST`] once at agent boot.
8//!
9//! Scripts run sandboxed: the only way they reach the outside world is the small,
10//! controlled set of host functions registered on the tool engine. Five of
11//! them (`http_get`, `http_post`, `shell`, `read_file`, `env_var`) do I/O and go
12//! through a [`ScriptHost`] trait object so the host can enforce permissions and
13//! tests can inject a fake; the other three (`parse_json`, `to_json`,
14//! `encode_uri`) are pure and defined here.
15//!
16//! Errors never bubble as a `Result` to the agent - [`execute`] always returns a
17//! `String`, using the `[error] …` prefix convention the rest of the tool layer
18//! uses, so a failing script surfaces to the model the same way a built-in
19//! tool's error does.
20
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24
25use rhai::{AST, Dynamic, Engine, EvalAltResult, Map, Position, Scope};
26use serde::Deserialize;
27
28use crate::{Error, Result};
29
30/// One declared parameter of a script tool.
31#[derive(Debug, Clone, PartialEq)]
32pub struct ParamSpec {
33    /// Parameter name (the key the script reads from `params`).
34    pub name: String,
35    /// JSON-schema type: `string`, `integer`, `number`, `boolean`, `array`, `object`.
36    /// Ignored when [`schema`](Self::schema) is set.
37    pub ty: String,
38    /// Whether the model must supply this parameter.
39    pub required: bool,
40    /// Human description shown to the model. Ignored when [`schema`](Self::schema)
41    /// is set (the raw fragment supplies its own).
42    pub description: String,
43    /// An optional raw JSON-Schema fragment for this parameter, used verbatim as
44    /// the property's schema instead of the flat `{ type, description }`. Lets a
45    /// `tool.toml` author express what annotations can't - enums, array `items`,
46    /// numeric bounds, nested object shapes, formats, defaults - matching the
47    /// richness built-in and MCP tools advertise. `None` = the flat default.
48    pub schema: Option<serde_json::Value>,
49}
50
51/// Metadata describing a script tool: its name, description, and parameters.
52#[derive(Debug, Clone, PartialEq)]
53pub struct ScriptToolMeta {
54    /// Tool name advertised to the model (must match a blueprint `available_tools` entry).
55    pub name: String,
56    /// One-line description of what the tool does.
57    pub description: String,
58    /// Declared parameters, in declaration order.
59    pub params: Vec<ParamSpec>,
60    /// Platform capabilities the tool declares it needs (e.g. `network`, `shell`,
61    /// `filesystem`). The host drops the tool when the platform can't provide one -
62    /// a script self-declares what it depends on. Empty = always available.
63    pub required_caps: Vec<String>,
64}
65
66impl ScriptToolMeta {
67    /// Build the JSON-schema `parameters` object advertised to the model, from
68    /// the declared [`ParamSpec`]s. Mirrors the hand-written schemas in
69    /// `leviath-tools` (`{ type: object, properties, required }`).
70    pub fn parameters_schema(&self) -> serde_json::Value {
71        let mut properties = serde_json::Map::new();
72        let mut required: Vec<serde_json::Value> = Vec::new();
73        for p in &self.params {
74            // A raw fragment (from `tool.toml`) is used verbatim; otherwise the
75            // flat `{ type, description }` default. `required` is governed by the
76            // param's `required` flag either way (it lives in the parent schema,
77            // not the property).
78            let property = match &p.schema {
79                Some(fragment) => fragment.clone(),
80                None => serde_json::json!({ "type": p.ty, "description": p.description }),
81            };
82            properties.insert(p.name.clone(), property);
83            if p.required {
84                required.push(serde_json::Value::String(p.name.clone()));
85            }
86        }
87        serde_json::json!({
88            "type": "object",
89            "properties": serde_json::Value::Object(properties),
90            "required": serde_json::Value::Array(required),
91        })
92    }
93}
94
95// ─── Metadata parsing ───────────────────────────────────────────────────────
96
97/// Parse a script tool's metadata from its `.rhai` source comment annotations.
98///
99/// Recognized leading `//`-comment directives (order-independent):
100/// - `// @tool <name>` - required; names the tool.
101/// - `// @description <text>` - optional one-liner.
102/// - `// @param <name> <type> <required|optional> "<description>"` - repeatable.
103/// - `// @requires <cap> [<cap>...]` - platform capabilities the tool needs
104///   (`network`, `shell`, `filesystem`); comma/space-separated, repeatable.
105///
106/// Non-comment / unrecognized lines are ignored, so a script can mix ordinary
107/// comments with directives. A missing `@tool` name is an error.
108pub fn parse_annotations(src: &str) -> Result<ScriptToolMeta> {
109    let mut name: Option<String> = None;
110    let mut description = String::new();
111    let mut params: Vec<ParamSpec> = Vec::new();
112    let mut required_caps: Vec<String> = Vec::new();
113
114    for line in src.lines() {
115        let trimmed = line.trim();
116        let Some(rest) = trimmed.strip_prefix("//") else {
117            continue;
118        };
119        let rest = rest.trim();
120        let Some(directive) = rest.strip_prefix('@') else {
121            continue;
122        };
123        // Split the directive keyword from its argument text.
124        let (keyword, arg) = match directive.split_once(char::is_whitespace) {
125            Some((k, a)) => (k, a.trim()),
126            None => (directive, ""),
127        };
128        match keyword {
129            "tool" => {
130                if arg.is_empty() {
131                    return Err(Error::ValidationFailed(
132                        "@tool directive requires a tool name".to_string(),
133                    ));
134                }
135                name = Some(arg.to_string());
136            }
137            "description" => description = arg.to_string(),
138            "param" => params.push(parse_param_directive(arg)?),
139            // `@requires <cap> [<cap>...]` - whitespace/comma-separated, repeatable.
140            "requires" => required_caps.extend(
141                arg.split([' ', ',', '\t'])
142                    .filter(|c| !c.is_empty())
143                    .map(str::to_string),
144            ),
145            _ => {} // unknown directive - ignore
146        }
147    }
148
149    let name = name.ok_or_else(|| {
150        Error::ValidationFailed("script tool is missing a `// @tool <name>` directive".to_string())
151    })?;
152    Ok(ScriptToolMeta {
153        name,
154        description,
155        params,
156        required_caps,
157    })
158}
159
160/// Parse the argument of a `@param` directive:
161/// `<name> <type> <required|optional> "<description>"`.
162///
163/// The description (everything after the third token) is optional and its
164/// surrounding double quotes are stripped when present.
165fn parse_param_directive(arg: &str) -> Result<ParamSpec> {
166    let mut it = arg.splitn(4, char::is_whitespace).map(str::trim);
167    let name = it.next().filter(|s| !s.is_empty());
168    let ty = it.next().filter(|s| !s.is_empty());
169    let requiredness = it.next().filter(|s| !s.is_empty());
170    let (name, ty, requiredness) = match (name, ty, requiredness) {
171        (Some(n), Some(t), Some(r)) => (n, t, r),
172        _ => {
173            return Err(Error::ValidationFailed(format!(
174                "@param requires `<name> <type> <required|optional>`, got: `{arg}`"
175            )));
176        }
177    };
178    let required = match requiredness {
179        "required" => true,
180        "optional" => false,
181        other => {
182            return Err(Error::ValidationFailed(format!(
183                "@param requiredness must be `required` or `optional`, got: `{other}`"
184            )));
185        }
186    };
187    let description = it
188        .next()
189        .map(|d| d.trim().trim_matches('"').to_string())
190        .unwrap_or_default();
191    Ok(ParamSpec {
192        name: name.to_string(),
193        ty: ty.to_string(),
194        required,
195        description,
196        // Comment annotations have no syntax for a raw schema fragment; that
197        // richness is `tool.toml`-only.
198        schema: None,
199    })
200}
201
202/// Serde shape of an optional `tool.toml` sibling manifest.
203#[derive(Debug, Deserialize)]
204struct ToolTomlDoc {
205    tool: ToolTomlTool,
206}
207
208#[derive(Debug, Deserialize)]
209struct ToolTomlTool {
210    name: String,
211    #[serde(default)]
212    description: String,
213    #[serde(default)]
214    params: Vec<ToolTomlParam>,
215    /// Platform capabilities the tool requires (`network`, `shell`, `filesystem`).
216    #[serde(default)]
217    requires: Vec<String>,
218}
219
220#[derive(Debug, Deserialize)]
221struct ToolTomlParam {
222    name: String,
223    /// The scalar type for the flat default. Optional: a param that supplies its
224    /// own `schema` fragment doesn't need it.
225    #[serde(default, rename = "type")]
226    ty: String,
227    #[serde(default)]
228    required: bool,
229    #[serde(default)]
230    description: String,
231    /// Optional raw JSON-Schema fragment, used verbatim as this param's property
232    /// schema (enums, `items`, bounds, nested objects, …).
233    #[serde(default)]
234    schema: Option<serde_json::Value>,
235}
236
237/// Parse a `tool.toml` manifest into [`ScriptToolMeta`]. When a `tool.toml` sits
238/// beside a script it takes precedence over the script's comment annotations.
239pub fn parse_tool_toml(src: &str) -> Result<ScriptToolMeta> {
240    let doc: ToolTomlDoc = toml::from_str(src)
241        .map_err(|e| Error::ValidationFailed(format!("invalid tool.toml: {e}")))?;
242    if doc.tool.name.trim().is_empty() {
243        return Err(Error::ValidationFailed(
244            "tool.toml `[tool] name` must not be empty".to_string(),
245        ));
246    }
247    let params = doc
248        .tool
249        .params
250        .into_iter()
251        .map(|p| ParamSpec {
252            name: p.name,
253            ty: p.ty,
254            required: p.required,
255            description: p.description,
256            schema: p.schema,
257        })
258        .collect();
259    Ok(ScriptToolMeta {
260        name: doc.tool.name,
261        description: doc.tool.description,
262        params,
263        required_caps: doc.tool.requires,
264    })
265}
266
267// ─── Host seam ──────────────────────────────────────────────────────────────
268
269/// The side-effecting host functions a script tool can call. Implemented by the
270/// daemon (with permission enforcement + real I/O) and by tests (with canned
271/// responses). Every method returns `Result<String, String>`; an `Err(msg)` is
272/// turned into a Rhai exception by the tool engine, which surfaces to the
273/// agent as an `[error] …` result.
274pub trait ScriptHost: Send + Sync {
275    /// HTTP GET `url` with the given request headers, returning the response body.
276    fn http_get(
277        &self,
278        url: &str,
279        headers: BTreeMap<String, String>,
280    ) -> std::result::Result<String, String>;
281    /// HTTP POST `body` to `url` with the given headers, returning the response body.
282    fn http_post(
283        &self,
284        url: &str,
285        body: &str,
286        headers: BTreeMap<String, String>,
287    ) -> std::result::Result<String, String>;
288    /// Run a shell command, returning its combined output.
289    fn shell(&self, command: &str) -> std::result::Result<String, String>;
290    /// Read a file (confined to the agent workdir by the implementor).
291    fn read_file(&self, path: &str) -> std::result::Result<String, String>;
292    /// Write `content` to a file (confined to the agent workdir by the
293    /// implementor), returning a short confirmation.
294    fn write_file(&self, path: &str, content: &str) -> std::result::Result<String, String>;
295    /// Read an environment variable.
296    fn env_var(&self, name: &str) -> std::result::Result<String, String>;
297}
298
299// ─── Compiled tool + tool set ───────────────────────────────────────────────
300
301/// A discovered, compiled script tool: its metadata plus the Rhai AST (compiled
302/// once) and the path it came from.
303#[derive(Clone, Debug)]
304pub struct ScriptTool {
305    /// Tool metadata (name/description/params).
306    pub meta: ScriptToolMeta,
307    /// Compiled script AST, evaluated on each call.
308    pub ast: AST,
309    /// Source `.rhai` path (for diagnostics).
310    pub source_path: PathBuf,
311}
312
313/// A `.rhai` file that could not be turned into a tool (bad annotations,
314/// `tool.toml`, or a compile error). Surfaced so the caller can log it - the
315/// library itself does no logging, keeping that policy decision in the host.
316#[derive(Debug, Clone)]
317pub struct SkippedTool {
318    /// The offending file.
319    pub path: PathBuf,
320    /// Why it was skipped.
321    pub reason: String,
322}
323
324/// The set of script tools available to one agent, keyed by tool name.
325#[derive(Clone, Default)]
326pub struct ScriptToolSet {
327    tools: BTreeMap<String, ScriptTool>,
328}
329
330impl ScriptToolSet {
331    /// Discover and compile every `*.rhai` tool in `dirs`, in order. Earlier
332    /// directories win on a name collision (so a per-agent `tools/` shadows the
333    /// global one). A file that fails to parse (bad annotations/`tool.toml`) or
334    /// compile is skipped and reported in the returned [`SkippedTool`] list,
335    /// never failing the whole agent. A `tool.toml` sitting beside
336    /// `<name>.rhai` overrides that script's annotations.
337    pub fn discover(dirs: &[PathBuf]) -> (Self, Vec<SkippedTool>) {
338        let mut tools: BTreeMap<String, ScriptTool> = BTreeMap::new();
339        let mut skipped: Vec<SkippedTool> = Vec::new();
340        // A bare engine is enough to compile (produce an AST); host functions are
341        // only needed at eval time.
342        let engine = Engine::new();
343        for dir in dirs {
344            let entries = match std::fs::read_dir(dir) {
345                Ok(e) => e,
346                Err(_) => continue, // missing dir is normal (agent has no tools/)
347            };
348            let mut paths: Vec<PathBuf> = entries
349                .filter_map(|e| e.ok().map(|e| e.path()))
350                .filter(|p| p.extension().is_some_and(|ext| ext == "rhai"))
351                .collect();
352            paths.sort();
353            for path in paths {
354                match compile_tool(&engine, &path) {
355                    Ok(tool) => {
356                        // Earlier dir wins: only insert if not already present.
357                        tools.entry(tool.meta.name.clone()).or_insert(tool);
358                    }
359                    Err(e) => skipped.push(SkippedTool {
360                        path,
361                        reason: e.to_string(),
362                    }),
363                }
364            }
365        }
366        (Self { tools }, skipped)
367    }
368
369    /// Whether a tool of this name exists in the set.
370    pub fn contains(&self, name: &str) -> bool {
371        self.tools.contains_key(name)
372    }
373
374    /// Look up a compiled tool by name.
375    pub fn get(&self, name: &str) -> Option<&ScriptTool> {
376        self.tools.get(name)
377    }
378
379    /// The names of all tools in the set.
380    pub fn names(&self) -> Vec<String> {
381        self.tools.keys().cloned().collect()
382    }
383
384    /// The metadata of every tool, for building `Tool` defs in the caller.
385    pub fn metas(&self) -> Vec<ScriptToolMeta> {
386        self.tools.values().map(|t| t.meta.clone()).collect()
387    }
388
389    /// Number of tools in the set.
390    pub fn len(&self) -> usize {
391        self.tools.len()
392    }
393
394    /// Whether the set is empty.
395    pub fn is_empty(&self) -> bool {
396        self.tools.is_empty()
397    }
398}
399
400/// Compile a single `.rhai` file into a [`ScriptTool`], resolving metadata from a
401/// sibling `tool.toml` when present, else from the script's comment annotations.
402fn compile_tool(engine: &Engine, path: &Path) -> Result<ScriptTool> {
403    let src = std::fs::read_to_string(path)
404        .map_err(|e| Error::ValidationFailed(format!("read {}: {e}", path.display())))?;
405    // tool.toml sibling (`<name>.rhai` → `<name>.toml`)? It overrides annotations.
406    let toml_path = path.with_extension("toml");
407    let meta = match std::fs::read_to_string(&toml_path) {
408        Ok(toml_src) => parse_tool_toml(&toml_src)?,
409        Err(_) => parse_annotations(&src)?,
410    };
411    let ast = engine
412        .compile(&src)
413        .map_err(|e| Error::CompilationFailed(format!("{}: {e}", path.display())))?;
414    Ok(ScriptTool {
415        meta,
416        ast,
417        source_path: path.to_path_buf(),
418    })
419}
420
421// ─── Execution ──────────────────────────────────────────────────────────────
422
423/// Maximum wall-clock a single script tool call may run. Enforced via the Rhai
424/// operation limit already set on the engine; this constant documents intent for
425/// the (blocking) host wrapper.
426pub const SCRIPT_TOOL_MAX_OPERATIONS: u64 = 500_000;
427
428/// Execute a compiled script tool with the model-supplied `args`, returning the
429/// result as a string for the agent. `args` is exposed to the script as the
430/// `params` object-map. The returned Rhai value is serialized to JSON unless it
431/// is already a string (returned verbatim). Any script error becomes an
432/// `[error] …` string.
433///
434/// A panic raised by a native (host) function never unwinds through this call:
435/// it is caught at the native-function boundary by `guard_str`/`guard_dyn`
436/// and arrives here as an ordinary script error. Anything that
437/// still escapes - a panic from Rhai's own internals - is contained one level
438/// up, where the daemon runs this on a `spawn_blocking` task and turns the
439/// resulting `JoinError` into a tool error.
440pub fn execute(tool: &ScriptTool, args: serde_json::Value, host: Arc<dyn ScriptHost>) -> String {
441    let engine = build_tool_engine(host);
442    // Converting a `serde_json::Value` to a Rhai `Dynamic` is infallible (any
443    // JSON maps to a Dynamic); fall back to unit on the impossible error rather
444    // than carry a dead error arm.
445    let params = rhai::serde::to_dynamic(args).unwrap_or(Dynamic::UNIT);
446    let mut scope = Scope::new();
447    scope.push_dynamic("params", params);
448    match engine.eval_ast_with_scope::<Dynamic>(&mut scope, &tool.ast) {
449        Ok(value) => dynamic_to_result_string(value),
450        Err(e) => format!("[error] {}: {}", tool.meta.name, e),
451    }
452}
453
454/// Serialize a script's return value for the agent: strings pass through
455/// verbatim; everything else is JSON-encoded (so an array/map return renders as
456/// JSON). Unit `()` becomes an empty string.
457fn dynamic_to_result_string(value: Dynamic) -> String {
458    if value.is_string() {
459        // `into_string` cannot fail here (checked `is_string`).
460        return value.into_string().unwrap_or_default();
461    }
462    if value.is_unit() {
463        return String::new();
464    }
465    match rhai::serde::from_dynamic::<serde_json::Value>(&value) {
466        // `Value`'s `Display` (to_string) is infallible, unlike `serde_json::to_string`.
467        Ok(json) => json.to_string(),
468        Err(e) => format!("[error] cannot serialize result: {e}"),
469    }
470}
471
472/// A Rhai engine with sandbox limits, the shared Leviath helpers, and the eight
473/// script-tool host functions registered.
474fn build_tool_engine(host: Arc<dyn ScriptHost>) -> Engine {
475    let mut engine = Engine::new();
476    crate::harden(&mut engine, SCRIPT_TOOL_MAX_OPERATIONS);
477    crate::functions::register_functions(&mut engine);
478    crate::types::register_types(&mut engine);
479    register_host_functions(&mut engine, host);
480    engine
481}
482
483/// What a registered native function hands back to Rhai.
484type HostRes<T> = std::result::Result<T, Box<EvalAltResult>>;
485
486/// Turn a host `Result<String, String>` into a Rhai fn result, mapping `Err`
487/// into a runtime exception (which `execute` renders as `[error] …`).
488fn to_rhai(r: std::result::Result<String, String>) -> HostRes<String> {
489    r.map_err(|msg| Box::new(EvalAltResult::ErrorRuntime(msg.into(), Position::NONE)))
490}
491
492/// Turn a caught panic payload into the same runtime exception a normal host
493/// error produces, so Rhai unwinds nothing.
494fn panic_to_rhai(name: &str, payload: Box<dyn std::any::Any + Send>) -> Box<EvalAltResult> {
495    let msg = leviath_core::panic_message(payload.as_ref());
496    tracing::warn!(
497        host_fn = name,
498        panic = %msg,
499        "a script-tool host function panicked; surfacing it as a script error (issue #109)"
500    );
501    Box::new(EvalAltResult::ErrorRuntime(
502        format!("{name} panicked: {msg}").into(),
503        Position::NONE,
504    ))
505}
506
507/// Run a `String`-returning native function so a panic inside it **never**
508/// unwinds into Rhai.
509///
510/// Rhai's `exec_native_fn_call` takes an `ArgBackup` whenever the first
511/// argument is a variable reference (which is every real call shape, e.g.
512/// `http_get(params.url)`), and restores it *after* the call returns. A
513/// panicking native function skips that restore, and `ArgBackup`'s destructor
514/// then asserts during unwinding - a second panic while panicking, which Rust
515/// turns into `abort()`, taking down the whole daemon and every concurrent
516/// run. Catching here means the unwind never reaches Rhai's frame at all.
517///
518/// Deliberately **not generic**: a generic guard monomorphizes per closure, and
519/// each instantiation's panic arm would then need its own test to hold the
520/// workspace's 100% coverage gate. One `&mut dyn FnMut` instantiation keeps
521/// every caller's regions merged into one.
522fn guard_str(name: &str, f: &mut dyn FnMut() -> HostRes<String>) -> HostRes<String> {
523    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
524        Ok(r) => r,
525        Err(payload) => Err(panic_to_rhai(name, payload)),
526    }
527}
528
529/// [`guard_str`] for the one native function that returns a `Dynamic`
530/// (`parse_json`). Same rationale, different return type - kept non-generic for
531/// the same coverage reason.
532fn guard_dyn(name: &str, f: &mut dyn FnMut() -> HostRes<Dynamic>) -> HostRes<Dynamic> {
533    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
534        Ok(r) => r,
535        Err(payload) => Err(panic_to_rhai(name, payload)),
536    }
537}
538
539/// Convert a Rhai object-map of headers into a `BTreeMap<String,String>`, each
540/// value stringified.
541/// Borrows rather than consumes so the guarded `FnMut` wrappers in
542/// [`register_host_functions`] can call it without moving out of a capture.
543fn headers_from_map(map: &Map) -> BTreeMap<String, String> {
544    map.iter()
545        .map(|(k, v)| (k.to_string(), v.to_string()))
546        .collect()
547}
548
549/// Register the eight host functions. Five delegate to [`ScriptHost`]; three
550/// (`parse_json`, `to_json`, `encode_uri`) are pure.
551///
552/// **Every** registration goes through [`guard_str`] / [`guard_dyn`], so a panic
553/// anywhere in a native function becomes an ordinary Rhai runtime error instead
554/// of unwinding into Rhai and aborting the process. The pure
555/// helpers are guarded too - they run on untrusted, model- and network-supplied
556/// input, so "this one can't panic" is not a property worth betting the daemon on.
557fn register_host_functions(engine: &mut Engine, host: Arc<dyn ScriptHost>) {
558    // http_get(url) / http_get(url, headers)
559    let h = host.clone();
560    engine.register_fn("http_get", move |url: &str| {
561        guard_str("http_get", &mut || {
562            to_rhai(h.http_get(url, BTreeMap::new()))
563        })
564    });
565    let h = host.clone();
566    engine.register_fn("http_get", move |url: &str, headers: Map| {
567        guard_str("http_get", &mut || {
568            to_rhai(h.http_get(url, headers_from_map(&headers)))
569        })
570    });
571
572    // http_post(url, body) / http_post(url, body, headers)
573    let h = host.clone();
574    engine.register_fn("http_post", move |url: &str, body: &str| {
575        guard_str("http_post", &mut || {
576            to_rhai(h.http_post(url, body, BTreeMap::new()))
577        })
578    });
579    let h = host.clone();
580    engine.register_fn("http_post", move |url: &str, body: &str, headers: Map| {
581        guard_str("http_post", &mut || {
582            to_rhai(h.http_post(url, body, headers_from_map(&headers)))
583        })
584    });
585
586    // shell(cmd)
587    let h = host.clone();
588    engine.register_fn("shell", move |cmd: &str| {
589        guard_str("shell", &mut || to_rhai(h.shell(cmd)))
590    });
591
592    // read_file(path)
593    let h = host.clone();
594    engine.register_fn("read_file", move |path: &str| {
595        guard_str("read_file", &mut || to_rhai(h.read_file(path)))
596    });
597
598    // write_file(path, content)
599    let h = host.clone();
600    engine.register_fn("write_file", move |path: &str, content: &str| {
601        guard_str("write_file", &mut || to_rhai(h.write_file(path, content)))
602    });
603
604    // env_var(name)
605    let h = host.clone();
606    engine.register_fn("env_var", move |name: &str| {
607        guard_str("env_var", &mut || to_rhai(h.env_var(name)))
608    });
609
610    // Pure helpers. Their bodies live in named free functions (not inline
611    // closures) so they get a single, cleanly-attributed monomorphization under
612    // coverage instrumentation instead of being inlined into rhai's generic
613    // `register_fn` wrapper (a known attribution artifact).
614    engine.register_fn("parse_json", |s: &str| -> HostRes<Dynamic> {
615        guard_dyn("parse_json", &mut || parse_json_fn(s))
616    });
617    engine.register_fn("to_json", |v: Dynamic| -> HostRes<String> {
618        guard_str("to_json", &mut || to_json_fn(&v))
619    });
620    engine.register_fn("encode_uri", |s: &str| -> HostRes<String> {
621        guard_str("encode_uri", &mut || Ok(percent_encode(s)))
622    });
623    engine.register_fn("html_to_text", |s: &str| -> HostRes<String> {
624        guard_str("html_to_text", &mut || Ok(html_to_text(s)))
625    });
626}
627
628/// `parse_json(str)` host function: JSON string → Rhai value.
629fn parse_json_fn(s: &str) -> HostRes<Dynamic> {
630    let value: serde_json::Value = serde_json::from_str(s).map_err(|e| {
631        Box::new(EvalAltResult::ErrorRuntime(
632            format!("parse_json: {e}").into(),
633            Position::NONE,
634        ))
635    })?;
636    rhai::serde::to_dynamic(value)
637}
638
639/// `to_json(value)` host function: Rhai value → JSON string. `from_dynamic`
640/// fails for values with no JSON representation (e.g. a function pointer);
641/// `Value::to_string` (Display) is then infallible.
642fn to_json_fn(v: &Dynamic) -> HostRes<String> {
643    let json: serde_json::Value = rhai::serde::from_dynamic(v)?;
644    Ok(json.to_string())
645}
646
647/// Percent-encode a string for use in a URL query component. Unreserved
648/// characters (`A-Z a-z 0-9 - _ . ~`, per RFC 3986) pass through; every other
649/// byte becomes `%XX`.
650fn percent_encode(input: &str) -> String {
651    let mut out = String::with_capacity(input.len());
652    for &byte in input.as_bytes() {
653        match byte {
654            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
655                out.push(byte as char);
656            }
657            _ => {
658                out.push('%');
659                out.push(hex_digit(byte >> 4));
660                out.push(hex_digit(byte & 0x0f));
661            }
662        }
663    }
664    out
665}
666
667/// Map a nibble (0–15) to its uppercase hex digit.
668fn hex_digit(nibble: u8) -> char {
669    match nibble {
670        0..=9 => (b'0' + nibble) as char,
671        _ => (b'A' + (nibble - 10)) as char,
672    }
673}
674
675/// `html_to_text(html)` host function: best-effort HTML → readable plain text.
676/// Drops `<script>`/`<style>` blocks, strips tags, decodes common entities, and
677/// collapses whitespace - so a script tool (e.g. `web_fetch`) can hand the model
678/// prose from a server-rendered page instead of markup. Not a full HTML parser;
679/// content injected by client-side JS is not present in the source and cannot be
680/// recovered here.
681fn html_to_text(html: &str) -> String {
682    let without_raw = strip_raw_text_elements(html);
683    let without_tags = strip_tags(&without_raw);
684    let decoded = decode_entities(&without_tags);
685    collapse_whitespace(&decoded)
686}
687
688/// Remove `<script>…</script>` and `<style>…</style>` element contents (their
689/// text is code/CSS, never prose). Case-insensitive; an unclosed element drops
690/// the remainder.
691fn strip_raw_text_elements(html: &str) -> String {
692    let mut s = html.to_string();
693    for tag in ["script", "style"] {
694        s = strip_element(&s, tag);
695    }
696    s
697}
698
699#[expect(
700    clippy::string_slice,
701    reason = "`i` only ever advances by `ch.len_utf8()` and `rel` comes from `find`, so both are \
702              char boundaries; `to_ascii_lowercase` preserves byte lengths, so `lower` and `html` \
703              share them"
704)]
705fn strip_element(html: &str, tag: &str) -> String {
706    let lower = html.to_ascii_lowercase();
707    let open = format!("<{tag}");
708    let close = format!("</{tag}>");
709    let mut out = String::with_capacity(html.len());
710    let mut i = 0;
711    while i < html.len() {
712        if lower[i..].starts_with(&open) {
713            match lower[i..].find(&close) {
714                Some(rel) => {
715                    i += rel + close.len();
716                    continue;
717                }
718                None => break, // unclosed element - drop the rest
719            }
720        }
721        let ch = html[i..].chars().next().unwrap();
722        out.push(ch);
723        i += ch.len_utf8();
724    }
725    out
726}
727
728/// Strip `<...>` tags. Each tag boundary becomes a space so adjacent words don't
729/// run together. A `<` with no matching `>` drops the remainder (malformed).
730fn strip_tags(html: &str) -> String {
731    let mut out = String::with_capacity(html.len());
732    let mut in_tag = false;
733    for c in html.chars() {
734        match c {
735            '<' => in_tag = true,
736            '>' if in_tag => {
737                in_tag = false;
738                out.push(' ');
739            }
740            _ if !in_tag => out.push(c),
741            _ => {}
742        }
743    }
744    out
745}
746
747/// How many characters past an `&` to look for the closing `;`. The longest
748/// entity this decoder recognises is `&#x10FFFF;` (10 chars); 12 leaves headroom.
749const ENTITY_SCAN_CHARS: usize = 12;
750
751/// Decode common HTML entities (named + numeric decimal/hex). Unknown or
752/// unterminated entities are left verbatim.
753///
754/// The scan is bounded by **characters**, not bytes. Bounding it by bytes
755/// aborts the daemon: `after` begins at an `&`, so a fixed
756/// byte-12 cut-off slices mid-character on any multi-byte text
757/// (`"&日本語日本"` → *"byte index 12 is not a char boundary"*), and
758/// `html_to_text` runs this over every fetched page. Clamping the byte window
759/// down to a boundary would also work, but only because `&` is single-byte -
760/// an unstated invariant that a later edit could quietly break. Indices from
761/// `char_indices` are boundaries by construction, so there is nothing left to
762/// get wrong. Entities are all ASCII, so the two bounds agree on any real one.
763#[expect(
764    clippy::string_slice,
765    reason = "`amp` comes from `find` and `semi` from `char_indices`, so every index here is a \
766              char boundary; `after[1..]` is safe because `after` starts at the single-byte '&'"
767)]
768fn decode_entities(s: &str) -> String {
769    let mut out = String::with_capacity(s.len());
770    let mut rest = s;
771    while let Some(amp) = rest.find('&') {
772        out.push_str(&rest[..amp]);
773        let after = &rest[amp..];
774        let semi = after
775            .char_indices()
776            .take(ENTITY_SCAN_CHARS)
777            .find(|&(_, c)| c == ';')
778            .map(|(i, _)| i);
779        match semi {
780            Some(semi) => match decode_one_entity(&after[1..semi]) {
781                Some(ch) => {
782                    out.push(ch);
783                    rest = &after[semi + 1..];
784                }
785                None => {
786                    out.push('&');
787                    rest = &after[1..];
788                }
789            },
790            None => {
791                out.push('&');
792                rest = &after[1..];
793            }
794        }
795    }
796    out.push_str(rest);
797    out
798}
799
800fn decode_one_entity(e: &str) -> Option<char> {
801    match e {
802        "amp" => Some('&'),
803        "lt" => Some('<'),
804        "gt" => Some('>'),
805        "quot" => Some('"'),
806        "apos" => Some('\''),
807        "nbsp" => Some(' '),
808        "mdash" => Some('\u{2014}'),
809        "ndash" => Some('–'),
810        "hellip" => Some('…'),
811        _ => {
812            if let Some(hex) = e.strip_prefix("#x").or_else(|| e.strip_prefix("#X")) {
813                u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
814            } else if let Some(dec) = e.strip_prefix('#') {
815                dec.parse::<u32>().ok().and_then(char::from_u32)
816            } else {
817                None
818            }
819        }
820    }
821}
822
823/// Collapse every run of whitespace to a single space and trim.
824fn collapse_whitespace(s: &str) -> String {
825    let mut out = String::with_capacity(s.len());
826    let mut prev_ws = false;
827    for c in s.chars() {
828        if c.is_whitespace() {
829            if !prev_ws {
830                out.push(' ');
831                prev_ws = true;
832            }
833        } else {
834            out.push(c);
835            prev_ws = false;
836        }
837    }
838    out.trim().to_string()
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844    use std::sync::{Mutex, PoisonError};
845
846    /// Serializes the tests that swap the **process-global** panic hook. Without
847    /// it they interleave under the parallel test runner: one test's `set_hook`
848    /// replaces another's silencing closure before that test's panic fires, so
849    /// the closure never runs and reads as uncovered.
850    static PANIC_HOOK_LOCK: Mutex<()> = Mutex::new(());
851
852    // ── A fake host recording calls and returning canned results. ──
853
854    type Headers = BTreeMap<String, String>;
855    /// Recorded `http_get` call: (url, headers).
856    type GetCall = Option<(String, Headers)>;
857    /// Recorded `http_post` call: (url, body, headers).
858    type PostCall = Option<(String, String, Headers)>;
859    type HostResult = std::result::Result<String, String>;
860
861    struct FakeHost {
862        get_response: Mutex<HostResult>,
863        post_response: Mutex<HostResult>,
864        shell_response: Mutex<HostResult>,
865        read_response: Mutex<HostResult>,
866        env_response: Mutex<HostResult>,
867        last_get: Mutex<GetCall>,
868        last_post: Mutex<PostCall>,
869    }
870
871    impl FakeHost {
872        fn arc() -> Arc<FakeHost> {
873            Arc::new(FakeHost {
874                get_response: Mutex::new(Ok("GET-OK".to_string())),
875                post_response: Mutex::new(Ok("POST-OK".to_string())),
876                shell_response: Mutex::new(Ok("SHELL-OK".to_string())),
877                read_response: Mutex::new(Ok("READ-OK".to_string())),
878                env_response: Mutex::new(Ok("ENV-OK".to_string())),
879                last_get: Mutex::new(None),
880                last_post: Mutex::new(None),
881            })
882        }
883    }
884
885    impl ScriptHost for FakeHost {
886        fn http_get(
887            &self,
888            url: &str,
889            headers: BTreeMap<String, String>,
890        ) -> std::result::Result<String, String> {
891            *self.last_get.lock().unwrap() = Some((url.to_string(), headers));
892            self.get_response.lock().unwrap().clone()
893        }
894        fn http_post(
895            &self,
896            url: &str,
897            body: &str,
898            headers: BTreeMap<String, String>,
899        ) -> std::result::Result<String, String> {
900            *self.last_post.lock().unwrap() = Some((url.to_string(), body.to_string(), headers));
901            self.post_response.lock().unwrap().clone()
902        }
903        fn shell(&self, _command: &str) -> std::result::Result<String, String> {
904            self.shell_response.lock().unwrap().clone()
905        }
906        fn read_file(&self, _path: &str) -> std::result::Result<String, String> {
907            self.read_response.lock().unwrap().clone()
908        }
909        fn write_file(&self, path: &str, content: &str) -> std::result::Result<String, String> {
910            Ok(format!("WROTE:{path}={content}"))
911        }
912        fn env_var(&self, _name: &str) -> std::result::Result<String, String> {
913            self.env_response.lock().unwrap().clone()
914        }
915    }
916
917    fn tool_from(src: &str) -> ScriptTool {
918        let engine = Engine::new();
919        let ast = engine.compile(src).expect("compile");
920        ScriptTool {
921            meta: parse_annotations(src).expect("annotations"),
922            ast,
923            source_path: PathBuf::from("mem.rhai"),
924        }
925    }
926
927    // ── parse_annotations ──
928
929    #[test]
930    fn annotations_full() {
931        let src = r#"
932// @tool web_search
933// @description Search the web
934// @param query string required "Search query"
935// @param count integer optional "How many"
93642
937"#;
938        let meta = parse_annotations(src).unwrap();
939        assert_eq!(meta.name, "web_search");
940        assert_eq!(meta.description, "Search the web");
941        assert_eq!(meta.params.len(), 2);
942        assert_eq!(
943            meta.params[0],
944            ParamSpec {
945                name: "query".into(),
946                ty: "string".into(),
947                required: true,
948                description: "Search query".into(),
949                schema: None,
950            }
951        );
952        assert!(!meta.params[1].required);
953        assert!(meta.required_caps.is_empty());
954    }
955
956    #[test]
957    fn annotations_requires_capabilities() {
958        // Space- and comma-separated, repeatable across lines.
959        let src = "// @tool t\n// @requires network, shell\n// @requires filesystem\n1";
960        let meta = parse_annotations(src).unwrap();
961        assert_eq!(meta.required_caps, ["network", "shell", "filesystem"]);
962    }
963
964    #[test]
965    fn annotations_missing_tool_name_errors() {
966        let err = parse_annotations("// @description no name\n1").unwrap_err();
967        assert!(err.to_string().contains("missing a `// @tool"));
968    }
969
970    #[test]
971    fn annotations_empty_tool_name_errors() {
972        let err = parse_annotations("// @tool   \n1").unwrap_err();
973        assert!(err.to_string().contains("requires a tool name"));
974    }
975
976    #[test]
977    fn annotations_ignore_non_comment_and_non_directive_lines() {
978        let src = "let x = 1; // trailing\n// plain comment\n// @tool t\nx";
979        let meta = parse_annotations(src).unwrap();
980        assert_eq!(meta.name, "t");
981        assert!(meta.params.is_empty());
982        assert_eq!(meta.description, "");
983    }
984
985    #[test]
986    fn annotations_unknown_directive_ignored() {
987        let meta = parse_annotations("// @tool t\n// @bogus whatever\n1").unwrap();
988        assert_eq!(meta.name, "t");
989    }
990
991    #[test]
992    fn annotations_directive_with_no_arg_is_handled() {
993        // A directive keyword with no whitespace/arg (the `None` split arm).
994        let meta = parse_annotations("// @tool t\n// @description\n1").unwrap();
995        assert_eq!(meta.description, "");
996    }
997
998    #[test]
999    fn param_without_description_defaults_empty() {
1000        let meta = parse_annotations("// @tool t\n// @param x string required\n1").unwrap();
1001        assert_eq!(meta.params[0].description, "");
1002        assert!(meta.params[0].required);
1003    }
1004
1005    #[test]
1006    fn param_optional_flag() {
1007        let meta = parse_annotations("// @tool t\n// @param x string optional\n1").unwrap();
1008        assert!(!meta.params[0].required);
1009    }
1010
1011    #[test]
1012    fn param_too_few_tokens_errors() {
1013        let err = parse_annotations("// @tool t\n// @param x string\n1").unwrap_err();
1014        assert!(err.to_string().contains("requires `<name> <type>"));
1015    }
1016
1017    #[test]
1018    fn param_bad_requiredness_errors() {
1019        let err = parse_annotations("// @tool t\n// @param x string maybe\n1").unwrap_err();
1020        assert!(err.to_string().contains("must be `required` or `optional`"));
1021    }
1022
1023    // ── parse_tool_toml ──
1024
1025    #[test]
1026    fn tool_toml_full() {
1027        let src = r#"
1028[tool]
1029name = "fetch"
1030description = "Fetch a URL"
1031[[tool.params]]
1032name = "url"
1033type = "string"
1034required = true
1035description = "The URL"
1036"#;
1037        let meta = parse_tool_toml(src).unwrap();
1038        assert_eq!(meta.name, "fetch");
1039        assert_eq!(meta.description, "Fetch a URL");
1040        assert_eq!(meta.params.len(), 1);
1041        assert!(meta.params[0].required);
1042        assert_eq!(meta.params[0].ty, "string");
1043    }
1044
1045    #[test]
1046    fn tool_toml_requires() {
1047        let meta = parse_tool_toml("[tool]\nname = \"t\"\nrequires = [\"network\"]").unwrap();
1048        assert_eq!(meta.required_caps, ["network"]);
1049    }
1050
1051    #[test]
1052    fn tool_toml_defaults() {
1053        let meta = parse_tool_toml("[tool]\nname = \"t\"").unwrap();
1054        assert_eq!(meta.description, "");
1055        assert!(meta.params.is_empty());
1056        assert!(meta.required_caps.is_empty());
1057    }
1058
1059    #[test]
1060    fn tool_toml_raw_schema_fragment() {
1061        // A param supplying its own `schema` fragment (and no `type`) parses the
1062        // fragment into ParamSpec.schema for verbatim use.
1063        let src = r#"
1064[tool]
1065name = "export"
1066[[tool.params]]
1067name = "format"
1068required = true
1069schema = { type = "string", enum = ["json", "yaml"], description = "Output format" }
1070"#;
1071        let meta = parse_tool_toml(src).unwrap();
1072        assert_eq!(meta.params.len(), 1);
1073        assert!(meta.params[0].required);
1074        // No `type` key was given → the flat `ty` defaulted to empty.
1075        assert_eq!(meta.params[0].ty, "");
1076        let frag = meta.params[0].schema.as_ref().unwrap();
1077        assert_eq!(frag["enum"][0], "json");
1078    }
1079
1080    #[test]
1081    fn tool_toml_invalid_syntax_errors() {
1082        let err = parse_tool_toml("not = valid = toml").unwrap_err();
1083        assert!(err.to_string().contains("invalid tool.toml"));
1084    }
1085
1086    #[test]
1087    fn tool_toml_empty_name_errors() {
1088        let err = parse_tool_toml("[tool]\nname = \"\"").unwrap_err();
1089        assert!(err.to_string().contains("must not be empty"));
1090    }
1091
1092    // ── parameters_schema ──
1093
1094    #[test]
1095    fn parameters_schema_shape() {
1096        let meta = parse_annotations(
1097            "// @tool t\n// @param a string required \"A\"\n// @param b integer optional \"B\"\n1",
1098        )
1099        .unwrap();
1100        let schema = meta.parameters_schema();
1101        assert_eq!(schema["type"], "object");
1102        assert_eq!(schema["properties"]["a"]["type"], "string");
1103        assert_eq!(schema["properties"]["b"]["description"], "B");
1104        let required = schema["required"].as_array().unwrap();
1105        assert_eq!(required.len(), 1);
1106        assert_eq!(required[0], "a");
1107    }
1108
1109    #[test]
1110    fn parameters_schema_uses_raw_fragment_verbatim() {
1111        // A param carrying a raw fragment: the fragment becomes the property
1112        // schema as-is (enum preserved), and `required` still governs the parent
1113        // `required` array.
1114        let meta = parse_tool_toml(
1115            "[tool]\nname = \"t\"\n[[tool.params]]\nname = \"fmt\"\nrequired = true\nschema = { type = \"string\", enum = [\"a\", \"b\"] }\n",
1116        )
1117        .unwrap();
1118        let schema = meta.parameters_schema();
1119        assert_eq!(schema["properties"]["fmt"]["type"], "string");
1120        assert_eq!(schema["properties"]["fmt"]["enum"][1], "b");
1121        // The flat `{type, description}` shape is NOT applied over the fragment.
1122        assert!(schema["properties"]["fmt"].get("description").is_none());
1123        assert_eq!(schema["required"][0], "fmt");
1124    }
1125
1126    // ── discover ──
1127
1128    #[test]
1129    fn discover_compiles_and_collides() {
1130        let dir_a = tempfile::tempdir().unwrap();
1131        let dir_b = tempfile::tempdir().unwrap();
1132        // Same tool name in both dirs; dir_a listed first must win.
1133        std::fs::write(
1134            dir_a.path().join("dup.rhai"),
1135            "// @tool dup\n// @description from A\n1",
1136        )
1137        .unwrap();
1138        std::fs::write(
1139            dir_b.path().join("dup.rhai"),
1140            "// @tool dup\n// @description from B\n2",
1141        )
1142        .unwrap();
1143        std::fs::write(dir_b.path().join("solo.rhai"), "// @tool solo\n3").unwrap();
1144        // A non-.rhai file is ignored; a broken script is skipped.
1145        std::fs::write(dir_b.path().join("note.txt"), "ignored").unwrap();
1146        std::fs::write(
1147            dir_b.path().join("broken.rhai"),
1148            "// no tool directive\nlet",
1149        )
1150        .unwrap();
1151
1152        let (set, skipped) = ScriptToolSet::discover(&[
1153            dir_a.path().to_path_buf(),
1154            dir_b.path().to_path_buf(),
1155            dir_a.path().join("does-not-exist"),
1156        ]);
1157        assert_eq!(set.len(), 2);
1158        assert!(!set.is_empty());
1159        assert!(set.contains("dup"));
1160        assert!(set.contains("solo"));
1161        assert_eq!(set.get("dup").unwrap().meta.description, "from A");
1162        let mut names = set.names();
1163        names.sort();
1164        assert_eq!(names, vec!["dup".to_string(), "solo".to_string()]);
1165        assert_eq!(set.metas().len(), 2);
1166        // The broken.rhai (no @tool directive) was skipped and reported.
1167        assert_eq!(skipped.len(), 1);
1168        assert!(skipped[0].path.ends_with("broken.rhai"));
1169        assert!(!skipped[0].reason.is_empty());
1170    }
1171
1172    #[test]
1173    fn discover_uses_tool_toml_override() {
1174        let dir = tempfile::tempdir().unwrap();
1175        // Annotations say name "ann"; tool.toml overrides to "override".
1176        std::fs::write(dir.path().join("t.rhai"), "// @tool ann\n1").unwrap();
1177        std::fs::write(
1178            dir.path().join("t.toml"),
1179            "[tool]\nname = \"override\"\ndescription = \"D\"",
1180        )
1181        .unwrap();
1182        let (set, skipped) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1183        assert!(set.contains("override"));
1184        assert!(!set.contains("ann"));
1185        assert!(skipped.is_empty());
1186    }
1187
1188    #[test]
1189    fn discover_skips_invalid_tool_toml() {
1190        let dir = tempfile::tempdir().unwrap();
1191        // A valid script, but a broken sibling tool.toml → compile_tool errors on
1192        // the `parse_tool_toml(..)?` arm → skipped.
1193        std::fs::write(dir.path().join("t.rhai"), "// @tool t\n1").unwrap();
1194        std::fs::write(dir.path().join("t.toml"), "name = broken").unwrap();
1195        let (set, skipped) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1196        assert!(set.is_empty());
1197        assert_eq!(skipped.len(), 1);
1198        assert!(skipped[0].reason.contains("tool.toml"));
1199    }
1200
1201    #[test]
1202    fn discover_skips_uncompilable_but_valid_annotation() {
1203        let dir = tempfile::tempdir().unwrap();
1204        // Valid annotation, but the body is a syntax error → compile fails → skip.
1205        std::fs::write(dir.path().join("t.rhai"), "// @tool t\nlet x = ;").unwrap();
1206        let (set, _) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1207        assert!(set.is_empty());
1208    }
1209
1210    #[test]
1211    fn default_set_is_empty() {
1212        let set = ScriptToolSet::default();
1213        assert!(set.is_empty());
1214        assert!(set.get("x").is_none());
1215    }
1216
1217    // ── execute ──
1218
1219    #[test]
1220    fn execute_returns_string_verbatim() {
1221        let tool = tool_from("// @tool t\n\"hello \" + params.name");
1222        let out = execute(&tool, serde_json::json!({"name": "world"}), FakeHost::arc());
1223        assert_eq!(out, "hello world");
1224    }
1225
1226    #[test]
1227    fn execute_serializes_non_string_result() {
1228        let tool = tool_from("// @tool t\n[1, 2, 3]");
1229        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1230        assert_eq!(out, "[1,2,3]");
1231    }
1232
1233    #[test]
1234    fn execute_unserializable_result_errors() {
1235        // A script returning a function pointer has no JSON representation, so
1236        // dynamic_to_result_string hits its `Err` arm.
1237        let tool = tool_from("// @tool t\n|| 1");
1238        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1239        assert!(out.contains("cannot serialize result"), "got: {out}");
1240    }
1241
1242    #[test]
1243    fn execute_unit_result_is_empty() {
1244        let tool = tool_from("// @tool t\nlet x = 1;");
1245        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1246        assert_eq!(out, "");
1247    }
1248
1249    #[test]
1250    fn execute_html_to_text_host_fn_via_script() {
1251        // Exercises the registered `html_to_text` engine binding (not just the
1252        // free function): a script strips markup to prose.
1253        let tool = tool_from("// @tool t\nhtml_to_text(\"<p>Hi&amp;<b>bye</b></p>\")");
1254        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1255        assert_eq!(out, "Hi& bye");
1256    }
1257
1258    #[test]
1259    fn execute_missing_optional_param_reads_as_unit() {
1260        // Mirrors the issue's `params.count == ()` idiom.
1261        let tool = tool_from("// @tool t\nif params.count == () { \"default\" } else { \"set\" }");
1262        let out = execute(&tool, serde_json::json!({"query": "x"}), FakeHost::arc());
1263        assert_eq!(out, "default");
1264    }
1265
1266    #[test]
1267    fn execute_script_error_is_prefixed() {
1268        let tool = tool_from("// @tool t\nthrow \"boom\"");
1269        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1270        assert!(out.starts_with("[error] t:"), "got: {out}");
1271        assert!(out.contains("boom"));
1272    }
1273
1274    /// Controls what kind of panic payload a [`PanickingHost`] produces.
1275    enum PanicPayload {
1276        /// `panic!("{}", msg)` → `String` payload (downcast_ref::<String>).
1277        Formatted(&'static str),
1278        /// `panic!("…")` → `&'static str` payload (downcast_ref::<&str>).
1279        Literal,
1280        /// `panic_any(42i32)` → non-string payload (falls through to
1281        /// "unknown panic").
1282        NonString,
1283    }
1284
1285    /// A [`ScriptHost`] where every method panics unconditionally, using
1286    /// the payload kind specified by `payload`. This avoids dead
1287    /// `Ok(…)` branches that would show up as uncovered.
1288    struct PanickingHost {
1289        payload: PanicPayload,
1290    }
1291
1292    impl PanickingHost {
1293        fn do_panic(&self) -> ! {
1294            match &self.payload {
1295                PanicPayload::Formatted(msg) => panic!("{}", msg),
1296                PanicPayload::Literal => panic!("literal str panic"),
1297                PanicPayload::NonString => std::panic::panic_any(42_i32),
1298            }
1299        }
1300    }
1301
1302    impl ScriptHost for PanickingHost {
1303        fn http_get(
1304            &self,
1305            _u: &str,
1306            _h: BTreeMap<String, String>,
1307        ) -> std::result::Result<String, String> {
1308            self.do_panic();
1309        }
1310        fn http_post(
1311            &self,
1312            _u: &str,
1313            _b: &str,
1314            _h: BTreeMap<String, String>,
1315        ) -> std::result::Result<String, String> {
1316            self.do_panic();
1317        }
1318        fn shell(&self, _c: &str) -> std::result::Result<String, String> {
1319            self.do_panic();
1320        }
1321        fn read_file(&self, _p: &str) -> std::result::Result<String, String> {
1322            self.do_panic();
1323        }
1324        fn write_file(&self, _p: &str, _c: &str) -> std::result::Result<String, String> {
1325            self.do_panic();
1326        }
1327        fn env_var(&self, _n: &str) -> std::result::Result<String, String> {
1328            self.do_panic();
1329        }
1330    }
1331
1332    /// Run a script whose only host call panics and return the tool's output,
1333    /// with the process panic hook silenced for the duration (the panic is
1334    /// expected; its default backtrace would just spam the test log).
1335    fn execute_with_panicking_host(payload: PanicPayload, script: &str) -> String {
1336        let host: Arc<dyn ScriptHost> = Arc::new(PanickingHost { payload });
1337        let tool = tool_from(script);
1338        let _guard = PANIC_HOOK_LOCK
1339            .lock()
1340            .unwrap_or_else(PoisonError::into_inner);
1341        let prev = std::panic::take_hook();
1342        std::panic::set_hook(Box::new(|_| {}));
1343        let out = execute(&tool, serde_json::json!({}), host);
1344        std::panic::set_hook(prev);
1345        out
1346    }
1347
1348    /// Assert the tool reported a guarded panic from `host_fn` carrying `detail`.
1349    fn assert_guarded_panic(out: &str, tool_name: &str, host_fn: &str, detail: &str) {
1350        assert!(
1351            out.starts_with(&format!("[error] {tool_name}:")),
1352            "got: {out}"
1353        );
1354        assert!(out.contains(&format!("{host_fn} panicked")), "got: {out}");
1355        assert!(out.contains(detail), "got: {out}");
1356    }
1357
1358    #[test]
1359    fn every_host_fn_panic_becomes_a_script_error() {
1360        // A panicking host function must NEVER unwind into Rhai: rhai's
1361        // `exec_native_fn_call` holds an `ArgBackup` whose destructor asserts,
1362        // so unwinding through it is a double panic → `abort()` → the whole
1363        // daemon dies (issue #109). Each of these would have aborted the test
1364        // process before the guards existed.
1365        for (host_fn, tool_name, script) in [
1366            ("http_get", "t", "// @tool t\nhttp_get(\"http://x\")"),
1367            (
1368                "http_get",
1369                "t",
1370                "// @tool t\nhttp_get(\"http://x\", #{ \"A\": \"b\" })",
1371            ),
1372            (
1373                "http_post",
1374                "t",
1375                "// @tool t\nhttp_post(\"http://x\", \"b\")",
1376            ),
1377            (
1378                "http_post",
1379                "t",
1380                "// @tool t\nhttp_post(\"http://x\", \"b\", #{ \"A\": \"b\" })",
1381            ),
1382            ("shell", "sh", "// @tool sh\nshell(\"ls\")"),
1383            ("read_file", "rf", "// @tool rf\nread_file(\"x.txt\")"),
1384            (
1385                "write_file",
1386                "wf",
1387                "// @tool wf\nwrite_file(\"out.txt\", \"data\")",
1388            ),
1389            ("env_var", "ev", "// @tool ev\nenv_var(\"HOME\")"),
1390        ] {
1391            let out =
1392                execute_with_panicking_host(PanicPayload::Formatted("TLS init failed"), script);
1393            assert_guarded_panic(&out, tool_name, host_fn, "TLS init failed");
1394        }
1395    }
1396
1397    #[test]
1398    fn guarded_panic_renders_str_and_non_string_payloads() {
1399        let out = execute_with_panicking_host(
1400            PanicPayload::Literal,
1401            "// @tool t\nhttp_get(\"http://x\")",
1402        );
1403        assert_guarded_panic(&out, "t", "http_get", "literal str panic");
1404
1405        let out = execute_with_panicking_host(
1406            PanicPayload::NonString,
1407            "// @tool t\nhttp_get(\"http://x\")",
1408        );
1409        assert_guarded_panic(&out, "t", "http_get", "unknown panic");
1410    }
1411
1412    #[test]
1413    fn guards_pass_through_success_and_convert_panics() {
1414        // Both guards, both arms, called directly: the pure host functions
1415        // (`parse_json` / `html_to_text` / …) can't be made to panic through a
1416        // script, so their panic arm is exercised here.
1417        let _guard = PANIC_HOOK_LOCK
1418            .lock()
1419            .unwrap_or_else(PoisonError::into_inner);
1420        assert_eq!(
1421            guard_str("ok_str", &mut || Ok("value".to_string())).unwrap(),
1422            "value"
1423        );
1424        assert!(
1425            guard_dyn("ok_dyn", &mut || Ok(Dynamic::from(7_i64)))
1426                .unwrap()
1427                .is_int()
1428        );
1429
1430        let prev = std::panic::take_hook();
1431        std::panic::set_hook(Box::new(|_| {}));
1432        let str_err = guard_str("boom_str", &mut || panic!("string arm")).unwrap_err();
1433        let dyn_err = guard_dyn("boom_dyn", &mut || panic!("dynamic arm")).unwrap_err();
1434        std::panic::set_hook(prev);
1435        assert!(
1436            str_err
1437                .to_string()
1438                .contains("boom_str panicked: string arm")
1439        );
1440        assert!(
1441            dyn_err
1442                .to_string()
1443                .contains("boom_dyn panicked: dynamic arm")
1444        );
1445    }
1446
1447    #[test]
1448    fn execute_scalar_args_run() {
1449        // Any JSON (including a scalar) converts to a `params` Dynamic; the
1450        // script simply ignores it here.
1451        let tool = tool_from("// @tool t\n\"ok\"");
1452        let out = execute(&tool, serde_json::json!(5), FakeHost::arc());
1453        assert_eq!(out, "ok");
1454    }
1455
1456    #[test]
1457    fn execute_print_and_debug_are_noop() {
1458        // Exercises the no-op `on_print`/`on_debug` closures on the tool engine.
1459        let tool = tool_from("// @tool t\nprint(\"p\"); debug(\"d\"); \"done\"");
1460        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1461        assert_eq!(out, "done");
1462    }
1463
1464    #[test]
1465    fn compile_tool_read_error() {
1466        let engine = Engine::new();
1467        let err = compile_tool(&engine, Path::new("/no/such/dir/tool.rhai")).unwrap_err();
1468        assert!(err.to_string().contains("read"));
1469    }
1470
1471    #[test]
1472    fn to_json_on_unserializable_value_errors() {
1473        // A function pointer has no JSON representation → from_dynamic errors,
1474        // surfacing as a script `[error]`.
1475        let tool = tool_from("// @tool t\nlet f = || 1; to_json(f)");
1476        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1477        assert!(out.starts_with("[error]"), "got: {out}");
1478    }
1479
1480    // ── host functions via a script ──
1481
1482    #[test]
1483    fn http_get_no_headers() {
1484        let host = FakeHost::arc();
1485        let tool = tool_from("// @tool t\nhttp_get(\"http://x\")");
1486        let out = execute(&tool, serde_json::json!({}), host.clone());
1487        assert_eq!(out, "GET-OK");
1488        let (url, headers) = host.last_get.lock().unwrap().clone().unwrap();
1489        assert_eq!(url, "http://x");
1490        assert!(headers.is_empty());
1491    }
1492
1493    #[test]
1494    fn http_get_with_headers() {
1495        let host = FakeHost::arc();
1496        let tool = tool_from("// @tool t\nhttp_get(\"http://x\", #{ \"K\": \"V\" })");
1497        let out = execute(&tool, serde_json::json!({}), host.clone());
1498        assert_eq!(out, "GET-OK");
1499        let (_, headers) = host.last_get.lock().unwrap().clone().unwrap();
1500        assert_eq!(headers.get("K").map(String::as_str), Some("V"));
1501    }
1502
1503    #[test]
1504    fn http_get_error_surfaces() {
1505        let host = FakeHost::arc();
1506        *host.get_response.lock().unwrap() = Err("[denied] http_get".to_string());
1507        let tool = tool_from("// @tool t\nhttp_get(\"http://x\")");
1508        let out = execute(&tool, serde_json::json!({}), host);
1509        assert!(out.contains("[denied] http_get"));
1510    }
1511
1512    #[test]
1513    fn http_post_variants() {
1514        let host = FakeHost::arc();
1515        let tool = tool_from("// @tool t\nhttp_post(\"http://x\", \"body\")");
1516        assert_eq!(
1517            execute(&tool, serde_json::json!({}), host.clone()),
1518            "POST-OK"
1519        );
1520        let (_, body, headers) = host.last_post.lock().unwrap().clone().unwrap();
1521        assert_eq!(body, "body");
1522        assert!(headers.is_empty());
1523
1524        let tool2 = tool_from("// @tool t\nhttp_post(\"http://x\", \"b\", #{ \"H\": \"1\" })");
1525        assert_eq!(
1526            execute(&tool2, serde_json::json!({}), host.clone()),
1527            "POST-OK"
1528        );
1529        let (_, _, headers2) = host.last_post.lock().unwrap().clone().unwrap();
1530        assert_eq!(headers2.get("H").map(String::as_str), Some("1"));
1531    }
1532
1533    #[test]
1534    fn shell_read_env_hosts() {
1535        let host = FakeHost::arc();
1536        assert_eq!(
1537            execute(
1538                &tool_from("// @tool t\nshell(\"ls\")"),
1539                serde_json::json!({}),
1540                host.clone()
1541            ),
1542            "SHELL-OK"
1543        );
1544        assert_eq!(
1545            execute(
1546                &tool_from("// @tool t\nread_file(\"a\")"),
1547                serde_json::json!({}),
1548                host.clone()
1549            ),
1550            "READ-OK"
1551        );
1552        assert_eq!(
1553            execute(
1554                &tool_from("// @tool t\nenv_var(\"A\")"),
1555                serde_json::json!({}),
1556                host.clone()
1557            ),
1558            "ENV-OK"
1559        );
1560        assert_eq!(
1561            execute(
1562                &tool_from("// @tool t\nwrite_file(\"out.txt\", \"body\")"),
1563                serde_json::json!({}),
1564                host
1565            ),
1566            "WROTE:out.txt=body"
1567        );
1568    }
1569
1570    // ── pure host functions ──
1571
1572    #[test]
1573    fn parse_and_to_json_roundtrip() {
1574        let host = FakeHost::arc();
1575        let tool = tool_from("// @tool t\nlet d = parse_json(\"{\\\"a\\\": 1}\"); to_json(d)");
1576        let out = execute(&tool, serde_json::json!({}), host);
1577        assert_eq!(out, "{\"a\":1}");
1578    }
1579
1580    #[test]
1581    fn parse_json_invalid_errors() {
1582        let tool = tool_from("// @tool t\nparse_json(\"not json\")");
1583        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1584        assert!(out.contains("parse_json"));
1585    }
1586
1587    #[test]
1588    fn parse_json_result_used_as_value() {
1589        // parse_json returns a Dynamic map; access a field, return it (string).
1590        let tool = tool_from("// @tool t\nlet d = parse_json(\"{\\\"k\\\": \\\"v\\\"}\"); d.k");
1591        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1592        assert_eq!(out, "v");
1593    }
1594
1595    #[test]
1596    fn encode_uri_encodes_reserved_and_passes_unreserved() {
1597        let tool = tool_from("// @tool t\nencode_uri(\"a b&c-_.~\")");
1598        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1599        assert_eq!(out, "a%20b%26c-_.~");
1600    }
1601
1602    #[test]
1603    fn to_json_fn_direct_success_and_failure() {
1604        // Direct calls give clean coverage attribution for the named helper,
1605        // independent of rhai's generic `register_fn` wrapper.
1606        let mut map = Map::new();
1607        map.insert("a".into(), Dynamic::from(1_i64));
1608        assert_eq!(to_json_fn(&Dynamic::from_map(map)).unwrap(), "{\"a\":1}");
1609        // A function pointer has no JSON representation → Err.
1610        let engine = Engine::new();
1611        let fnptr: Dynamic = engine.eval("|| 1").unwrap();
1612        assert!(to_json_fn(&fnptr).is_err());
1613    }
1614
1615    #[test]
1616    fn parse_json_fn_direct_success_and_failure() {
1617        let d = parse_json_fn("{\"k\": \"v\"}").unwrap();
1618        assert!(d.is_map());
1619        assert!(parse_json_fn("not json").is_err());
1620    }
1621
1622    #[test]
1623    fn encode_uri_non_ascii() {
1624        // '€' (U+20AC) is 3 UTF-8 bytes E2 82 AC.
1625        assert_eq!(percent_encode("€"), "%E2%82%AC");
1626    }
1627
1628    #[test]
1629    fn hex_digit_covers_both_arms() {
1630        assert_eq!(hex_digit(9), '9');
1631        assert_eq!(hex_digit(15), 'F');
1632        assert_eq!(hex_digit(0), '0');
1633    }
1634
1635    #[test]
1636    fn headers_from_map_stringifies_values() {
1637        let mut m = Map::new();
1638        m.insert("n".into(), Dynamic::from(42_i64));
1639        let headers = headers_from_map(&m);
1640        assert_eq!(headers.get("n").map(String::as_str), Some("42"));
1641    }
1642
1643    #[test]
1644    fn html_to_text_full_pipeline() {
1645        let html = "<html><head><style>.a{color:red}</style></head>\
1646            <body><h1>Tit&amp;le</h1><script>var x=1<2;</script>\
1647            <p>Hello&nbsp;world &#39;quoted&#39; &#x2014; done.</p></body></html>";
1648        let text = html_to_text(html);
1649        assert!(text.contains("Tit&le"), "entity decoded: {text}");
1650        assert!(
1651            text.contains("Hello world 'quoted' \u{2014} done."),
1652            "got: {text}"
1653        );
1654        assert!(!text.contains("color:red"), "style content dropped");
1655        assert!(!text.contains("var x"), "script content dropped");
1656        assert!(!text.contains('<'), "tags stripped");
1657    }
1658
1659    #[test]
1660    fn strip_element_handles_case_unclosed_and_utf8() {
1661        // Case-insensitive open + close.
1662        assert_eq!(strip_element("a<SCRIPT>x</script>b", "script"), "ab");
1663        // Unclosed element drops the remainder.
1664        assert_eq!(strip_element("keep<style>rest", "style"), "keep");
1665        // Non-matching content (incl. multi-byte chars) passes through.
1666        assert_eq!(strip_element("café < 3", "script"), "café < 3");
1667    }
1668
1669    #[test]
1670    fn strip_tags_edges() {
1671        assert_eq!(strip_tags("<b>hi</b>").trim(), "hi");
1672        // '>' outside a tag is kept.
1673        assert_eq!(strip_tags("2 > 1").trim(), "2 > 1");
1674        // Unclosed '<' drops the rest.
1675        assert_eq!(strip_tags("ok <broken").trim(), "ok");
1676    }
1677
1678    #[test]
1679    fn decode_entities_named_numeric_and_unknown() {
1680        assert_eq!(decode_entities("a&amp;b"), "a&b");
1681        assert_eq!(decode_entities("&lt;&gt;&quot;&apos;"), "<>\"'");
1682        assert_eq!(decode_entities("x&nbsp;y"), "x y");
1683        assert_eq!(decode_entities("&mdash;&ndash;&hellip;"), "\u{2014}–…");
1684        assert_eq!(decode_entities("&#65;&#x42;&#X43;"), "ABC");
1685        // Unknown entity kept verbatim.
1686        assert_eq!(decode_entities("&bogus;"), "&bogus;");
1687        // No terminating ';' within the window → '&' kept, scan continues.
1688        assert_eq!(decode_entities("a & b"), "a & b");
1689        // Invalid numeric → kept verbatim.
1690        assert_eq!(decode_entities("&#zz;"), "&#zz;"); // decimal parse Err
1691        assert_eq!(decode_entities("&#xZZ;"), "&#xZZ;"); // hex from_str_radix Err
1692        assert_eq!(decode_entities("&#x110000;"), "&#x110000;"); // hex out of range (from_u32 None)
1693        assert_eq!(decode_entities("&#99999999;"), "&#99999999;"); // decimal out of range (from_u32 None)
1694        // No ampersand at all.
1695        assert_eq!(decode_entities("plain"), "plain");
1696    }
1697
1698    #[test]
1699    fn decode_entities_survives_multibyte_after_an_ampersand() {
1700        // Regression for issue #109: the entity-scan window is a byte count, so
1701        // a bare '&' followed by multi-byte text can slice mid-character and
1702        // panic ("byte index 12 is not a char boundary") - inside a Rhai native
1703        // fn, which aborts the daemon. Every one of these is a real shape from
1704        // fetched HTML.
1705        assert_eq!(decode_entities("&日本語日本"), "&日本語日本");
1706        assert_eq!(decode_entities("R&D 日本語です"), "R&D 日本語です");
1707        assert_eq!(decode_entities("&🎉🎉🎉🎉"), "&🎉🎉🎉🎉");
1708        assert_eq!(
1709            decode_entities("&\u{2014}\u{2014}\u{2014}\u{2014}"),
1710            "&\u{2014}\u{2014}\u{2014}\u{2014}"
1711        );
1712        // A real entity immediately followed by multi-byte text still decodes.
1713        assert_eq!(decode_entities("&amp;日本語"), "&日本語");
1714        // Trailing '&' at the very end of the string (window == 1).
1715        assert_eq!(decode_entities("tail&"), "tail&");
1716        // Issue #115 re-reported the same crash with a flag emoji. '&' plus nine
1717        // ASCII bytes puts the four-byte regional indicator at bytes 10..14, so
1718        // a fixed byte-12 window cuts straight through it. Pinned verbatim so
1719        // the reported input, not just an equivalent one, stays covered.
1720        assert_eq!(decode_entities("&abcdefghi🇸"), "&abcdefghi🇸");
1721    }
1722
1723    #[test]
1724    fn collapse_whitespace_runs_and_trims() {
1725        assert_eq!(collapse_whitespace("  a \n\t b  "), "a b");
1726        assert_eq!(collapse_whitespace(""), "");
1727    }
1728}