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