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    /// The metadata of every tool paired with the file it was compiled from.
391    ///
392    /// [`metas`](Self::metas) answers what a tool advertises; a caller listing
393    /// tools for a picker also has to say *where* each one came from, because
394    /// "the agent's own file" and "a global drop-in every agent gets" are
395    /// different answers to whether the tool travels with the agent. Recovering
396    /// the path through [`get`](Self::get) would mean a lookup whose miss arm
397    /// can never be taken.
398    pub fn sources(&self) -> Vec<(ScriptToolMeta, PathBuf)> {
399        self.tools
400            .values()
401            .map(|t| (t.meta.clone(), t.source_path.clone()))
402            .collect()
403    }
404
405    /// Number of tools in the set.
406    pub fn len(&self) -> usize {
407        self.tools.len()
408    }
409
410    /// Whether the set is empty.
411    pub fn is_empty(&self) -> bool {
412        self.tools.is_empty()
413    }
414}
415
416/// Answer "would this text become a tool" for source that has no file yet.
417///
418/// [`ScriptToolSet::discover`] asks the same two questions of files already on
419/// disk: are the annotations parseable, and does Rhai accept the script. An
420/// editor has to be able to ask before saving, and the only alternative was
421/// writing the candidate into a directory every agent executes from and reading
422/// the answer back out of the skipped list.
423///
424/// `label` is what the error message names the source as, since there is no
425/// path to name. A sibling `tool.toml` cannot apply here for the same reason:
426/// nothing is on disk to sit beside.
427pub fn check_source(label: &str, source: &str) -> Result<ScriptToolMeta> {
428    let meta = parse_annotations(source)?;
429    Engine::new()
430        .compile(source)
431        .map_err(|e| Error::CompilationFailed(format!("{label}: {e}")))?;
432    Ok(meta)
433}
434
435/// Compile a single `.rhai` file into a [`ScriptTool`], resolving metadata from a
436/// sibling `tool.toml` when present, else from the script's comment annotations.
437fn compile_tool(engine: &Engine, path: &Path) -> Result<ScriptTool> {
438    let src = std::fs::read_to_string(path)
439        .map_err(|e| Error::ValidationFailed(format!("read {}: {e}", path.display())))?;
440    // tool.toml sibling (`<name>.rhai` → `<name>.toml`)? It overrides annotations.
441    let toml_path = path.with_extension("toml");
442    let meta = match std::fs::read_to_string(&toml_path) {
443        Ok(toml_src) => parse_tool_toml(&toml_src)?,
444        Err(_) => parse_annotations(&src)?,
445    };
446    let ast = engine
447        .compile(&src)
448        .map_err(|e| Error::CompilationFailed(format!("{}: {e}", path.display())))?;
449    Ok(ScriptTool {
450        meta,
451        ast,
452        source_path: path.to_path_buf(),
453    })
454}
455
456// ─── Execution ──────────────────────────────────────────────────────────────
457
458/// Maximum wall-clock a single script tool call may run. Enforced via the Rhai
459/// operation limit already set on the engine; this constant documents intent for
460/// the (blocking) host wrapper.
461pub const SCRIPT_TOOL_MAX_OPERATIONS: u64 = 500_000;
462
463/// Execute a compiled script tool with the model-supplied `args`, returning the
464/// result as a string for the agent. `args` is exposed to the script as the
465/// `params` object-map. The returned Rhai value is serialized to JSON unless it
466/// is already a string (returned verbatim). Any script error becomes an
467/// `[error] …` string.
468///
469/// A panic raised by a native (host) function never unwinds through this call:
470/// it is caught at the native-function boundary by `guard_str`/`guard_dyn`
471/// and arrives here as an ordinary script error. Anything that
472/// still escapes - a panic from Rhai's own internals - is contained one level
473/// up, where the daemon runs this on a `spawn_blocking` task and turns the
474/// resulting `JoinError` into a tool error.
475pub fn execute(tool: &ScriptTool, args: serde_json::Value, host: Arc<dyn ScriptHost>) -> String {
476    let engine = build_tool_engine(host);
477    // Converting a `serde_json::Value` to a Rhai `Dynamic` is infallible (any
478    // JSON maps to a Dynamic); fall back to unit on the impossible error rather
479    // than carry a dead error arm.
480    let params = rhai::serde::to_dynamic(args).unwrap_or(Dynamic::UNIT);
481    let mut scope = Scope::new();
482    scope.push_dynamic("params", params);
483    match engine.eval_ast_with_scope::<Dynamic>(&mut scope, &tool.ast) {
484        Ok(value) => dynamic_to_result_string(value),
485        Err(e) => format!("[error] {}: {}", tool.meta.name, e),
486    }
487}
488
489/// Serialize a script's return value for the agent: strings pass through
490/// verbatim; everything else is JSON-encoded (so an array/map return renders as
491/// JSON). Unit `()` becomes an empty string.
492fn dynamic_to_result_string(value: Dynamic) -> String {
493    if value.is_string() {
494        // `into_string` cannot fail here (checked `is_string`).
495        return value.into_string().unwrap_or_default();
496    }
497    if value.is_unit() {
498        return String::new();
499    }
500    match rhai::serde::from_dynamic::<serde_json::Value>(&value) {
501        // `Value`'s `Display` (to_string) is infallible, unlike `serde_json::to_string`.
502        Ok(json) => json.to_string(),
503        Err(e) => format!("[error] cannot serialize result: {e}"),
504    }
505}
506
507/// A Rhai engine with sandbox limits, the shared Leviath helpers, and the eight
508/// script-tool host functions registered.
509fn build_tool_engine(host: Arc<dyn ScriptHost>) -> Engine {
510    let mut engine = Engine::new();
511    crate::harden(&mut engine, SCRIPT_TOOL_MAX_OPERATIONS);
512    crate::functions::register_functions(&mut engine);
513    crate::types::register_types(&mut engine);
514    register_host_functions(&mut engine, host);
515    engine
516}
517
518/// What a registered native function hands back to Rhai.
519type HostRes<T> = std::result::Result<T, Box<EvalAltResult>>;
520
521/// Turn a host `Result<String, String>` into a Rhai fn result, mapping `Err`
522/// into a runtime exception (which `execute` renders as `[error] …`).
523fn to_rhai(r: std::result::Result<String, String>) -> HostRes<String> {
524    r.map_err(|msg| Box::new(EvalAltResult::ErrorRuntime(msg.into(), Position::NONE)))
525}
526
527/// Turn a caught panic payload into the same runtime exception a normal host
528/// error produces, so Rhai unwinds nothing.
529fn panic_to_rhai(name: &str, payload: Box<dyn std::any::Any + Send>) -> Box<EvalAltResult> {
530    let msg = leviath_core::panic_message(payload.as_ref());
531    tracing::warn!(
532        host_fn = name,
533        panic = %msg,
534        "a script-tool host function panicked; surfacing it as a script error (issue #109)"
535    );
536    Box::new(EvalAltResult::ErrorRuntime(
537        format!("{name} panicked: {msg}").into(),
538        Position::NONE,
539    ))
540}
541
542/// Run a `String`-returning native function so a panic inside it **never**
543/// unwinds into Rhai.
544///
545/// Rhai's `exec_native_fn_call` takes an `ArgBackup` whenever the first
546/// argument is a variable reference (which is every real call shape, e.g.
547/// `http_get(params.url)`), and restores it *after* the call returns. A
548/// panicking native function skips that restore, and `ArgBackup`'s destructor
549/// then asserts during unwinding - a second panic while panicking, which Rust
550/// turns into `abort()`, taking down the whole daemon and every concurrent
551/// run. Catching here means the unwind never reaches Rhai's frame at all.
552///
553/// Deliberately **not generic**: a generic guard monomorphizes per closure, and
554/// each instantiation's panic arm would then need its own test to hold the
555/// workspace's 100% coverage gate. One `&mut dyn FnMut` instantiation keeps
556/// every caller's regions merged into one.
557fn guard_str(name: &str, f: &mut dyn FnMut() -> HostRes<String>) -> HostRes<String> {
558    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
559        Ok(r) => r,
560        Err(payload) => Err(panic_to_rhai(name, payload)),
561    }
562}
563
564/// [`guard_str`] for the one native function that returns a `Dynamic`
565/// (`parse_json`). Same rationale, different return type - kept non-generic for
566/// the same coverage reason.
567fn guard_dyn(name: &str, f: &mut dyn FnMut() -> HostRes<Dynamic>) -> HostRes<Dynamic> {
568    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
569        Ok(r) => r,
570        Err(payload) => Err(panic_to_rhai(name, payload)),
571    }
572}
573
574/// Convert a Rhai object-map of headers into a `BTreeMap<String,String>`, each
575/// value stringified.
576/// Borrows rather than consumes so the guarded `FnMut` wrappers in
577/// [`register_host_functions`] can call it without moving out of a capture.
578fn headers_from_map(map: &Map) -> BTreeMap<String, String> {
579    map.iter()
580        .map(|(k, v)| (k.to_string(), v.to_string()))
581        .collect()
582}
583
584/// Register the eight host functions. Five delegate to [`ScriptHost`]; three
585/// (`parse_json`, `to_json`, `encode_uri`) are pure.
586///
587/// **Every** registration goes through [`guard_str`] / [`guard_dyn`], so a panic
588/// anywhere in a native function becomes an ordinary Rhai runtime error instead
589/// of unwinding into Rhai and aborting the process. The pure
590/// helpers are guarded too - they run on untrusted, model- and network-supplied
591/// input, so "this one can't panic" is not a property worth betting the daemon on.
592fn register_host_functions(engine: &mut Engine, host: Arc<dyn ScriptHost>) {
593    // http_get(url) / http_get(url, headers)
594    let h = host.clone();
595    engine.register_fn("http_get", move |url: &str| {
596        guard_str("http_get", &mut || {
597            to_rhai(h.http_get(url, BTreeMap::new()))
598        })
599    });
600    let h = host.clone();
601    engine.register_fn("http_get", move |url: &str, headers: Map| {
602        guard_str("http_get", &mut || {
603            to_rhai(h.http_get(url, headers_from_map(&headers)))
604        })
605    });
606
607    // http_post(url, body) / http_post(url, body, headers)
608    let h = host.clone();
609    engine.register_fn("http_post", move |url: &str, body: &str| {
610        guard_str("http_post", &mut || {
611            to_rhai(h.http_post(url, body, BTreeMap::new()))
612        })
613    });
614    let h = host.clone();
615    engine.register_fn("http_post", move |url: &str, body: &str, headers: Map| {
616        guard_str("http_post", &mut || {
617            to_rhai(h.http_post(url, body, headers_from_map(&headers)))
618        })
619    });
620
621    // shell(cmd)
622    let h = host.clone();
623    engine.register_fn("shell", move |cmd: &str| {
624        guard_str("shell", &mut || to_rhai(h.shell(cmd)))
625    });
626
627    // read_file(path)
628    let h = host.clone();
629    engine.register_fn("read_file", move |path: &str| {
630        guard_str("read_file", &mut || to_rhai(h.read_file(path)))
631    });
632
633    // write_file(path, content)
634    let h = host.clone();
635    engine.register_fn("write_file", move |path: &str, content: &str| {
636        guard_str("write_file", &mut || to_rhai(h.write_file(path, content)))
637    });
638
639    // env_var(name)
640    let h = host.clone();
641    engine.register_fn("env_var", move |name: &str| {
642        guard_str("env_var", &mut || to_rhai(h.env_var(name)))
643    });
644
645    // Pure helpers. Their bodies live in named free functions (not inline
646    // closures) so they get a single, cleanly-attributed monomorphization under
647    // coverage instrumentation instead of being inlined into rhai's generic
648    // `register_fn` wrapper (a known attribution artifact).
649    engine.register_fn("parse_json", |s: &str| -> HostRes<Dynamic> {
650        guard_dyn("parse_json", &mut || parse_json_fn(s))
651    });
652    engine.register_fn("to_json", |v: Dynamic| -> HostRes<String> {
653        guard_str("to_json", &mut || to_json_fn(&v))
654    });
655    engine.register_fn("encode_uri", |s: &str| -> HostRes<String> {
656        guard_str("encode_uri", &mut || Ok(percent_encode(s)))
657    });
658    engine.register_fn("html_to_text", |s: &str| -> HostRes<String> {
659        guard_str("html_to_text", &mut || Ok(html_to_text(s)))
660    });
661}
662
663/// `parse_json(str)` host function: JSON string → Rhai value.
664fn parse_json_fn(s: &str) -> HostRes<Dynamic> {
665    let value: serde_json::Value = serde_json::from_str(s).map_err(|e| {
666        Box::new(EvalAltResult::ErrorRuntime(
667            format!("parse_json: {e}").into(),
668            Position::NONE,
669        ))
670    })?;
671    rhai::serde::to_dynamic(value)
672}
673
674/// `to_json(value)` host function: Rhai value → JSON string. `from_dynamic`
675/// fails for values with no JSON representation (e.g. a function pointer);
676/// `Value::to_string` (Display) is then infallible.
677fn to_json_fn(v: &Dynamic) -> HostRes<String> {
678    let json: serde_json::Value = rhai::serde::from_dynamic(v)?;
679    Ok(json.to_string())
680}
681
682/// Percent-encode a string for use in a URL query component. Unreserved
683/// characters (`A-Z a-z 0-9 - _ . ~`, per RFC 3986) pass through; every other
684/// byte becomes `%XX`.
685///
686/// Public because the script *provider* engine registers the same `encode_uri`
687/// host function and had a byte-identical copy of this. Two encoders that
688/// scripts reach by the same name is a difference waiting to be discovered by
689/// whoever writes a `.rhai` that works in one and not the other.
690pub fn percent_encode(input: &str) -> String {
691    let mut out = String::with_capacity(input.len());
692    for &byte in input.as_bytes() {
693        match byte {
694            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
695                out.push(byte as char);
696            }
697            _ => {
698                out.push('%');
699                out.push(hex_digit(byte >> 4));
700                out.push(hex_digit(byte & 0x0f));
701            }
702        }
703    }
704    out
705}
706
707/// Map a nibble (0–15) to its uppercase hex digit.
708fn hex_digit(nibble: u8) -> char {
709    match nibble {
710        0..=9 => (b'0' + nibble) as char,
711        _ => (b'A' + (nibble - 10)) as char,
712    }
713}
714
715/// `html_to_text(html)` host function: best-effort HTML → readable plain text.
716/// Drops `<script>`/`<style>` blocks, strips tags, decodes common entities, and
717/// collapses whitespace - so a script tool (e.g. `web_fetch`) can hand the model
718/// prose from a server-rendered page instead of markup. Not a full HTML parser;
719/// content injected by client-side JS is not present in the source and cannot be
720/// recovered here.
721fn html_to_text(html: &str) -> String {
722    let without_raw = strip_raw_text_elements(html);
723    let without_tags = strip_tags(&without_raw);
724    let decoded = decode_entities(&without_tags);
725    collapse_whitespace(&decoded)
726}
727
728/// Remove `<script>…</script>` and `<style>…</style>` element contents (their
729/// text is code/CSS, never prose). Case-insensitive; an unclosed element drops
730/// the remainder.
731fn strip_raw_text_elements(html: &str) -> String {
732    let mut s = html.to_string();
733    for tag in ["script", "style"] {
734        s = strip_element(&s, tag);
735    }
736    s
737}
738
739fn strip_element(html: &str, tag: &str) -> String {
740    let lower = html.to_ascii_lowercase();
741    let open = format!("<{tag}");
742    let close = format!("</{tag}>");
743    let mut out = String::with_capacity(html.len());
744    // The two strings are walked together rather than sharing a byte cursor.
745    // `to_ascii_lowercase` does preserve byte lengths, so a shared index would
746    // be correct, but it is correct by an invariant stated nowhere in the types;
747    // advancing both by the same amount at each step makes it structural.
748    let mut rest = html;
749    let mut lower_rest = lower.as_str();
750    loop {
751        if lower_rest.starts_with(&open) {
752            match lower_rest.find(&close) {
753                Some(rel) => {
754                    let skip = rel + close.len();
755                    rest = split_at_boundary(rest, skip).1;
756                    lower_rest = split_at_boundary(lower_rest, skip).1;
757                    continue;
758                }
759                None => break, // unclosed element - drop the rest
760            }
761        }
762        // Also the loop's ordinary exit, once the input is used up.
763        let Some(ch) = rest.chars().next() else { break };
764        out.push(ch);
765        rest = split_at_boundary(rest, ch.len_utf8()).1;
766        lower_rest = split_at_boundary(lower_rest, ch.len_utf8()).1;
767    }
768    out
769}
770
771/// Strip `<...>` tags. Each tag boundary becomes a space so adjacent words don't
772/// run together. A `<` with no matching `>` drops the remainder (malformed).
773fn strip_tags(html: &str) -> String {
774    let mut out = String::with_capacity(html.len());
775    let mut in_tag = false;
776    for c in html.chars() {
777        match c {
778            '<' => in_tag = true,
779            '>' if in_tag => {
780                in_tag = false;
781                out.push(' ');
782            }
783            _ if !in_tag => out.push(c),
784            _ => {}
785        }
786    }
787    out
788}
789
790/// How many characters past an `&` to look for the closing `;`. The longest
791/// entity this decoder recognises is `&#x10FFFF;` (10 chars); 12 leaves headroom.
792const ENTITY_SCAN_CHARS: usize = 12;
793
794/// Decode common HTML entities (named + numeric decimal/hex). Unknown or
795/// unterminated entities are left verbatim.
796///
797/// The scan is bounded by **characters**, not bytes. Bounding it by bytes
798/// aborts the daemon: `after` begins at an `&`, so a fixed
799/// byte-12 cut-off slices mid-character on any multi-byte text
800/// (`"&日本語日本"` → *"byte index 12 is not a char boundary"*), and
801/// `html_to_text` runs this over every fetched page. Clamping the byte window
802/// down to a boundary would also work, but only because `&` is single-byte -
803/// an unstated invariant that a later edit could quietly break. Indices from
804/// `char_indices` are boundaries by construction, so there is nothing left to
805/// get wrong. Entities are all ASCII, so the two bounds agree on any real one.
806fn decode_entities(s: &str) -> String {
807    let mut out = String::with_capacity(s.len());
808    let mut rest = s;
809    while let Some(amp) = rest.find('&') {
810        // `after` still carries the '&', so something that turns out not to be
811        // an entity can be re-emitted verbatim.
812        let (before, after) = split_at_boundary(rest, amp);
813        out.push_str(before);
814        let semi = after
815            .char_indices()
816            .take(ENTITY_SCAN_CHARS)
817            .find(|&(_, c)| c == ';')
818            .map(|(i, _)| i);
819        match semi {
820            Some(semi) => match decode_one_entity(substring(after, 1, semi)) {
821                Some(ch) => {
822                    out.push(ch);
823                    rest = split_at_boundary(after, semi + 1).1;
824                }
825                None => {
826                    out.push('&');
827                    rest = split_at_boundary(after, 1).1;
828                }
829            },
830            None => {
831                out.push('&');
832                rest = split_at_boundary(after, 1).1;
833            }
834        }
835    }
836    out.push_str(rest);
837    out
838}
839
840fn decode_one_entity(e: &str) -> Option<char> {
841    match e {
842        "amp" => Some('&'),
843        "lt" => Some('<'),
844        "gt" => Some('>'),
845        "quot" => Some('"'),
846        "apos" => Some('\''),
847        "nbsp" => Some(' '),
848        "mdash" => Some('\u{2014}'),
849        "ndash" => Some('–'),
850        "hellip" => Some('…'),
851        _ => {
852            if let Some(hex) = e.strip_prefix("#x").or_else(|| e.strip_prefix("#X")) {
853                u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
854            } else if let Some(dec) = e.strip_prefix('#') {
855                dec.parse::<u32>().ok().and_then(char::from_u32)
856            } else {
857                None
858            }
859        }
860    }
861}
862
863/// Collapse every run of whitespace to a single space and trim.
864fn collapse_whitespace(s: &str) -> String {
865    let mut out = String::with_capacity(s.len());
866    let mut prev_ws = false;
867    for c in s.chars() {
868        if c.is_whitespace() {
869            if !prev_ws {
870                out.push(' ');
871                prev_ws = true;
872            }
873        } else {
874            out.push(c);
875            prev_ws = false;
876        }
877    }
878    out.trim().to_string()
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884    use std::sync::{Mutex, PoisonError};
885
886    /// Serializes the tests that swap the **process-global** panic hook. Without
887    /// it they interleave under the parallel test runner: one test's `set_hook`
888    /// replaces another's silencing closure before that test's panic fires, so
889    /// the closure never runs and reads as uncovered.
890    static PANIC_HOOK_LOCK: Mutex<()> = Mutex::new(());
891
892    // ── A fake host recording calls and returning canned results. ──
893
894    type Headers = BTreeMap<String, String>;
895    /// Recorded `http_get` call: (url, headers).
896    type GetCall = Option<(String, Headers)>;
897    /// Recorded `http_post` call: (url, body, headers).
898    type PostCall = Option<(String, String, Headers)>;
899    type HostResult = std::result::Result<String, String>;
900
901    struct FakeHost {
902        get_response: Mutex<HostResult>,
903        post_response: Mutex<HostResult>,
904        shell_response: Mutex<HostResult>,
905        read_response: Mutex<HostResult>,
906        env_response: Mutex<HostResult>,
907        last_get: Mutex<GetCall>,
908        last_post: Mutex<PostCall>,
909    }
910
911    impl FakeHost {
912        fn arc() -> Arc<FakeHost> {
913            Arc::new(FakeHost {
914                get_response: Mutex::new(Ok("GET-OK".to_string())),
915                post_response: Mutex::new(Ok("POST-OK".to_string())),
916                shell_response: Mutex::new(Ok("SHELL-OK".to_string())),
917                read_response: Mutex::new(Ok("READ-OK".to_string())),
918                env_response: Mutex::new(Ok("ENV-OK".to_string())),
919                last_get: Mutex::new(None),
920                last_post: Mutex::new(None),
921            })
922        }
923    }
924
925    impl ScriptHost for FakeHost {
926        fn http_get(
927            &self,
928            url: &str,
929            headers: BTreeMap<String, String>,
930        ) -> std::result::Result<String, String> {
931            *self.last_get.lock().unwrap() = Some((url.to_string(), headers));
932            self.get_response.lock().unwrap().clone()
933        }
934        fn http_post(
935            &self,
936            url: &str,
937            body: &str,
938            headers: BTreeMap<String, String>,
939        ) -> std::result::Result<String, String> {
940            *self.last_post.lock().unwrap() = Some((url.to_string(), body.to_string(), headers));
941            self.post_response.lock().unwrap().clone()
942        }
943        fn shell(&self, _command: &str) -> std::result::Result<String, String> {
944            self.shell_response.lock().unwrap().clone()
945        }
946        fn read_file(&self, _path: &str) -> std::result::Result<String, String> {
947            self.read_response.lock().unwrap().clone()
948        }
949        fn write_file(&self, path: &str, content: &str) -> std::result::Result<String, String> {
950            Ok(format!("WROTE:{path}={content}"))
951        }
952        fn env_var(&self, _name: &str) -> std::result::Result<String, String> {
953            self.env_response.lock().unwrap().clone()
954        }
955    }
956
957    fn tool_from(src: &str) -> ScriptTool {
958        let engine = Engine::new();
959        let ast = engine.compile(src).expect("compile");
960        ScriptTool {
961            meta: parse_annotations(src).expect("annotations"),
962            ast,
963            source_path: PathBuf::from("mem.rhai"),
964        }
965    }
966
967    // ── parse_annotations ──
968
969    #[test]
970    fn annotations_full() {
971        let src = r#"
972// @tool web_search
973// @description Search the web
974// @param query string required "Search query"
975// @param count integer optional "How many"
97642
977"#;
978        let meta = parse_annotations(src).unwrap();
979        assert_eq!(meta.name, "web_search");
980        assert_eq!(meta.description, "Search the web");
981        assert_eq!(meta.params.len(), 2);
982        assert_eq!(
983            meta.params[0],
984            ParamSpec {
985                name: "query".into(),
986                ty: "string".into(),
987                required: true,
988                description: "Search query".into(),
989                schema: None,
990            }
991        );
992        assert!(!meta.params[1].required);
993        assert!(meta.required_caps.is_empty());
994    }
995
996    #[test]
997    fn annotations_requires_capabilities() {
998        // Space- and comma-separated, repeatable across lines.
999        let src = "// @tool t\n// @requires network, shell\n// @requires filesystem\n1";
1000        let meta = parse_annotations(src).unwrap();
1001        assert_eq!(meta.required_caps, ["network", "shell", "filesystem"]);
1002    }
1003
1004    #[test]
1005    fn annotations_missing_tool_name_errors() {
1006        let err = parse_annotations("// @description no name\n1").unwrap_err();
1007        assert!(err.to_string().contains("missing a `// @tool"));
1008    }
1009
1010    #[test]
1011    fn annotations_empty_tool_name_errors() {
1012        let err = parse_annotations("// @tool   \n1").unwrap_err();
1013        assert!(err.to_string().contains("requires a tool name"));
1014    }
1015
1016    #[test]
1017    fn annotations_ignore_non_comment_and_non_directive_lines() {
1018        let src = "let x = 1; // trailing\n// plain comment\n// @tool t\nx";
1019        let meta = parse_annotations(src).unwrap();
1020        assert_eq!(meta.name, "t");
1021        assert!(meta.params.is_empty());
1022        assert_eq!(meta.description, "");
1023    }
1024
1025    #[test]
1026    fn annotations_unknown_directive_ignored() {
1027        let meta = parse_annotations("// @tool t\n// @bogus whatever\n1").unwrap();
1028        assert_eq!(meta.name, "t");
1029    }
1030
1031    #[test]
1032    fn annotations_directive_with_no_arg_is_handled() {
1033        // A directive keyword with no whitespace/arg (the `None` split arm).
1034        let meta = parse_annotations("// @tool t\n// @description\n1").unwrap();
1035        assert_eq!(meta.description, "");
1036    }
1037
1038    #[test]
1039    fn param_without_description_defaults_empty() {
1040        let meta = parse_annotations("// @tool t\n// @param x string required\n1").unwrap();
1041        assert_eq!(meta.params[0].description, "");
1042        assert!(meta.params[0].required);
1043    }
1044
1045    #[test]
1046    fn param_optional_flag() {
1047        let meta = parse_annotations("// @tool t\n// @param x string optional\n1").unwrap();
1048        assert!(!meta.params[0].required);
1049    }
1050
1051    #[test]
1052    fn param_too_few_tokens_errors() {
1053        let err = parse_annotations("// @tool t\n// @param x string\n1").unwrap_err();
1054        assert!(err.to_string().contains("requires `<name> <type>"));
1055    }
1056
1057    #[test]
1058    fn param_bad_requiredness_errors() {
1059        let err = parse_annotations("// @tool t\n// @param x string maybe\n1").unwrap_err();
1060        assert!(err.to_string().contains("must be `required` or `optional`"));
1061    }
1062
1063    // ── parse_tool_toml ──
1064
1065    #[test]
1066    fn tool_toml_full() {
1067        let src = r#"
1068[tool]
1069name = "fetch"
1070description = "Fetch a URL"
1071[[tool.params]]
1072name = "url"
1073type = "string"
1074required = true
1075description = "The URL"
1076"#;
1077        let meta = parse_tool_toml(src).unwrap();
1078        assert_eq!(meta.name, "fetch");
1079        assert_eq!(meta.description, "Fetch a URL");
1080        assert_eq!(meta.params.len(), 1);
1081        assert!(meta.params[0].required);
1082        assert_eq!(meta.params[0].ty, "string");
1083    }
1084
1085    #[test]
1086    fn tool_toml_requires() {
1087        let meta = parse_tool_toml("[tool]\nname = \"t\"\nrequires = [\"network\"]").unwrap();
1088        assert_eq!(meta.required_caps, ["network"]);
1089    }
1090
1091    #[test]
1092    fn tool_toml_defaults() {
1093        let meta = parse_tool_toml("[tool]\nname = \"t\"").unwrap();
1094        assert_eq!(meta.description, "");
1095        assert!(meta.params.is_empty());
1096        assert!(meta.required_caps.is_empty());
1097    }
1098
1099    #[test]
1100    fn tool_toml_raw_schema_fragment() {
1101        // A param supplying its own `schema` fragment (and no `type`) parses the
1102        // fragment into ParamSpec.schema for verbatim use.
1103        let src = r#"
1104[tool]
1105name = "export"
1106[[tool.params]]
1107name = "format"
1108required = true
1109schema = { type = "string", enum = ["json", "yaml"], description = "Output format" }
1110"#;
1111        let meta = parse_tool_toml(src).unwrap();
1112        assert_eq!(meta.params.len(), 1);
1113        assert!(meta.params[0].required);
1114        // No `type` key was given → the flat `ty` defaulted to empty.
1115        assert_eq!(meta.params[0].ty, "");
1116        let frag = meta.params[0].schema.as_ref().unwrap();
1117        assert_eq!(frag["enum"][0], "json");
1118    }
1119
1120    #[test]
1121    fn tool_toml_invalid_syntax_errors() {
1122        let err = parse_tool_toml("not = valid = toml").unwrap_err();
1123        assert!(err.to_string().contains("invalid tool.toml"));
1124    }
1125
1126    #[test]
1127    fn tool_toml_empty_name_errors() {
1128        let err = parse_tool_toml("[tool]\nname = \"\"").unwrap_err();
1129        assert!(err.to_string().contains("must not be empty"));
1130    }
1131
1132    // ── parameters_schema ──
1133
1134    #[test]
1135    fn parameters_schema_shape() {
1136        let meta = parse_annotations(
1137            "// @tool t\n// @param a string required \"A\"\n// @param b integer optional \"B\"\n1",
1138        )
1139        .unwrap();
1140        let schema = meta.parameters_schema();
1141        assert_eq!(schema["type"], "object");
1142        assert_eq!(schema["properties"]["a"]["type"], "string");
1143        assert_eq!(schema["properties"]["b"]["description"], "B");
1144        let required = schema["required"].as_array().unwrap();
1145        assert_eq!(required.len(), 1);
1146        assert_eq!(required[0], "a");
1147    }
1148
1149    #[test]
1150    fn parameters_schema_uses_raw_fragment_verbatim() {
1151        // A param carrying a raw fragment: the fragment becomes the property
1152        // schema as-is (enum preserved), and `required` still governs the parent
1153        // `required` array.
1154        let meta = parse_tool_toml(
1155            "[tool]\nname = \"t\"\n[[tool.params]]\nname = \"fmt\"\nrequired = true\nschema = { type = \"string\", enum = [\"a\", \"b\"] }\n",
1156        )
1157        .unwrap();
1158        let schema = meta.parameters_schema();
1159        assert_eq!(schema["properties"]["fmt"]["type"], "string");
1160        assert_eq!(schema["properties"]["fmt"]["enum"][1], "b");
1161        // The flat `{type, description}` shape is NOT applied over the fragment.
1162        assert!(schema["properties"]["fmt"].get("description").is_none());
1163        assert_eq!(schema["required"][0], "fmt");
1164    }
1165
1166    // ── discover ──
1167
1168    #[test]
1169    fn discover_compiles_and_collides() {
1170        let dir_a = tempfile::tempdir().unwrap();
1171        let dir_b = tempfile::tempdir().unwrap();
1172        // Same tool name in both dirs; dir_a listed first must win.
1173        std::fs::write(
1174            dir_a.path().join("dup.rhai"),
1175            "// @tool dup\n// @description from A\n1",
1176        )
1177        .unwrap();
1178        std::fs::write(
1179            dir_b.path().join("dup.rhai"),
1180            "// @tool dup\n// @description from B\n2",
1181        )
1182        .unwrap();
1183        std::fs::write(dir_b.path().join("solo.rhai"), "// @tool solo\n3").unwrap();
1184        // A non-.rhai file is ignored; a broken script is skipped.
1185        std::fs::write(dir_b.path().join("note.txt"), "ignored").unwrap();
1186        std::fs::write(
1187            dir_b.path().join("broken.rhai"),
1188            "// no tool directive\nlet",
1189        )
1190        .unwrap();
1191
1192        let (set, skipped) = ScriptToolSet::discover(&[
1193            dir_a.path().to_path_buf(),
1194            dir_b.path().to_path_buf(),
1195            dir_a.path().join("does-not-exist"),
1196        ]);
1197        assert_eq!(set.len(), 2);
1198        assert!(!set.is_empty());
1199        assert!(set.contains("dup"));
1200        assert!(set.contains("solo"));
1201        assert_eq!(set.get("dup").unwrap().meta.description, "from A");
1202        let mut names = set.names();
1203        names.sort();
1204        assert_eq!(names, vec!["dup".to_string(), "solo".to_string()]);
1205        assert_eq!(set.metas().len(), 2);
1206        // The broken.rhai (no @tool directive) was skipped and reported.
1207        assert_eq!(skipped.len(), 1);
1208        assert!(skipped[0].path.ends_with("broken.rhai"));
1209        assert!(!skipped[0].reason.is_empty());
1210    }
1211
1212    /// `sources` pairs each tool with the file it came from, which is what a
1213    /// caller needs to say whether a name is the agent's own script or a global
1214    /// drop-in. The two dirs hold the same tool name, so this also pins that the
1215    /// winner's path is reported rather than the shadowed one's.
1216    #[test]
1217    fn sources_pairs_each_tool_with_the_file_it_came_from() {
1218        let dir_a = tempfile::tempdir().unwrap();
1219        let dir_b = tempfile::tempdir().unwrap();
1220        std::fs::write(dir_a.path().join("dup.rhai"), "// @tool dup\n1").unwrap();
1221        std::fs::write(dir_b.path().join("dup.rhai"), "// @tool dup\n2").unwrap();
1222
1223        let (set, _) =
1224            ScriptToolSet::discover(&[dir_a.path().to_path_buf(), dir_b.path().to_path_buf()]);
1225        let sources = set.sources();
1226        assert_eq!(sources.len(), 1);
1227        assert_eq!(sources[0].0.name, "dup");
1228        assert_eq!(sources[0].1, dir_a.path().join("dup.rhai"));
1229    }
1230
1231    // ── check_source ──
1232
1233    #[test]
1234    fn check_source_accepts_a_script_that_would_become_a_tool() {
1235        let meta = check_source("draft", "// @tool draft\n// @description d\n1").unwrap();
1236        assert_eq!(meta.name, "draft");
1237        assert_eq!(meta.description, "d");
1238    }
1239
1240    #[test]
1241    fn check_source_rejects_missing_annotations() {
1242        let err = check_source("draft", "1").unwrap_err();
1243        assert!(err.to_string().contains("@tool"), "{err}");
1244    }
1245
1246    /// The annotations parse but Rhai does not accept the body, which is the
1247    /// half `parse_annotations` alone would let through.
1248    #[test]
1249    fn check_source_rejects_a_script_rhai_will_not_compile() {
1250        let err = check_source("draft", "// @tool draft\nlet").unwrap_err();
1251        assert!(err.to_string().contains("draft"), "{err}");
1252    }
1253
1254    #[test]
1255    fn discover_uses_tool_toml_override() {
1256        let dir = tempfile::tempdir().unwrap();
1257        // Annotations say name "ann"; tool.toml overrides to "override".
1258        std::fs::write(dir.path().join("t.rhai"), "// @tool ann\n1").unwrap();
1259        std::fs::write(
1260            dir.path().join("t.toml"),
1261            "[tool]\nname = \"override\"\ndescription = \"D\"",
1262        )
1263        .unwrap();
1264        let (set, skipped) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1265        assert!(set.contains("override"));
1266        assert!(!set.contains("ann"));
1267        assert!(skipped.is_empty());
1268    }
1269
1270    #[test]
1271    fn discover_skips_invalid_tool_toml() {
1272        let dir = tempfile::tempdir().unwrap();
1273        // A valid script, but a broken sibling tool.toml → compile_tool errors on
1274        // the `parse_tool_toml(..)?` arm → skipped.
1275        std::fs::write(dir.path().join("t.rhai"), "// @tool t\n1").unwrap();
1276        std::fs::write(dir.path().join("t.toml"), "name = broken").unwrap();
1277        let (set, skipped) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1278        assert!(set.is_empty());
1279        assert_eq!(skipped.len(), 1);
1280        assert!(skipped[0].reason.contains("tool.toml"));
1281    }
1282
1283    #[test]
1284    fn discover_skips_uncompilable_but_valid_annotation() {
1285        let dir = tempfile::tempdir().unwrap();
1286        // Valid annotation, but the body is a syntax error → compile fails → skip.
1287        std::fs::write(dir.path().join("t.rhai"), "// @tool t\nlet x = ;").unwrap();
1288        let (set, _) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1289        assert!(set.is_empty());
1290    }
1291
1292    #[test]
1293    fn default_set_is_empty() {
1294        let set = ScriptToolSet::default();
1295        assert!(set.is_empty());
1296        assert!(set.get("x").is_none());
1297    }
1298
1299    // ── execute ──
1300
1301    #[test]
1302    fn execute_returns_string_verbatim() {
1303        let tool = tool_from("// @tool t\n\"hello \" + params.name");
1304        let out = execute(&tool, serde_json::json!({"name": "world"}), FakeHost::arc());
1305        assert_eq!(out, "hello world");
1306    }
1307
1308    #[test]
1309    fn execute_serializes_non_string_result() {
1310        let tool = tool_from("// @tool t\n[1, 2, 3]");
1311        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1312        assert_eq!(out, "[1,2,3]");
1313    }
1314
1315    #[test]
1316    fn execute_unserializable_result_errors() {
1317        // A script returning a function pointer has no JSON representation, so
1318        // dynamic_to_result_string hits its `Err` arm.
1319        let tool = tool_from("// @tool t\n|| 1");
1320        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1321        assert!(out.contains("cannot serialize result"), "got: {out}");
1322    }
1323
1324    #[test]
1325    fn execute_unit_result_is_empty() {
1326        let tool = tool_from("// @tool t\nlet x = 1;");
1327        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1328        assert_eq!(out, "");
1329    }
1330
1331    #[test]
1332    fn execute_html_to_text_host_fn_via_script() {
1333        // Exercises the registered `html_to_text` engine binding (not just the
1334        // free function): a script strips markup to prose.
1335        let tool = tool_from("// @tool t\nhtml_to_text(\"<p>Hi&amp;<b>bye</b></p>\")");
1336        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1337        assert_eq!(out, "Hi& bye");
1338    }
1339
1340    #[test]
1341    fn execute_missing_optional_param_reads_as_unit() {
1342        // Mirrors the issue's `params.count == ()` idiom.
1343        let tool = tool_from("// @tool t\nif params.count == () { \"default\" } else { \"set\" }");
1344        let out = execute(&tool, serde_json::json!({"query": "x"}), FakeHost::arc());
1345        assert_eq!(out, "default");
1346    }
1347
1348    #[test]
1349    fn execute_script_error_is_prefixed() {
1350        let tool = tool_from("// @tool t\nthrow \"boom\"");
1351        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1352        assert!(out.starts_with("[error] t:"), "got: {out}");
1353        assert!(out.contains("boom"));
1354    }
1355
1356    /// Controls what kind of panic payload a [`PanickingHost`] produces.
1357    enum PanicPayload {
1358        /// `panic!("{}", msg)` → `String` payload (downcast_ref::<String>).
1359        Formatted(&'static str),
1360        /// `panic!("…")` → `&'static str` payload (downcast_ref::<&str>).
1361        Literal,
1362        /// `panic_any(42i32)` → non-string payload (falls through to
1363        /// "unknown panic").
1364        NonString,
1365    }
1366
1367    /// A [`ScriptHost`] where every method panics unconditionally, using
1368    /// the payload kind specified by `payload`. This avoids dead
1369    /// `Ok(…)` branches that would show up as uncovered.
1370    struct PanickingHost {
1371        payload: PanicPayload,
1372    }
1373
1374    impl PanickingHost {
1375        fn do_panic(&self) -> ! {
1376            match &self.payload {
1377                PanicPayload::Formatted(msg) => panic!("{}", msg),
1378                PanicPayload::Literal => panic!("literal str panic"),
1379                PanicPayload::NonString => std::panic::panic_any(42_i32),
1380            }
1381        }
1382    }
1383
1384    impl ScriptHost for PanickingHost {
1385        fn http_get(
1386            &self,
1387            _u: &str,
1388            _h: BTreeMap<String, String>,
1389        ) -> std::result::Result<String, String> {
1390            self.do_panic();
1391        }
1392        fn http_post(
1393            &self,
1394            _u: &str,
1395            _b: &str,
1396            _h: BTreeMap<String, String>,
1397        ) -> std::result::Result<String, String> {
1398            self.do_panic();
1399        }
1400        fn shell(&self, _c: &str) -> std::result::Result<String, String> {
1401            self.do_panic();
1402        }
1403        fn read_file(&self, _p: &str) -> std::result::Result<String, String> {
1404            self.do_panic();
1405        }
1406        fn write_file(&self, _p: &str, _c: &str) -> std::result::Result<String, String> {
1407            self.do_panic();
1408        }
1409        fn env_var(&self, _n: &str) -> std::result::Result<String, String> {
1410            self.do_panic();
1411        }
1412    }
1413
1414    /// Run a script whose only host call panics and return the tool's output,
1415    /// with the process panic hook silenced for the duration (the panic is
1416    /// expected; its default backtrace would just spam the test log).
1417    fn execute_with_panicking_host(payload: PanicPayload, script: &str) -> String {
1418        let host: Arc<dyn ScriptHost> = Arc::new(PanickingHost { payload });
1419        let tool = tool_from(script);
1420        let _guard = PANIC_HOOK_LOCK
1421            .lock()
1422            .unwrap_or_else(PoisonError::into_inner);
1423        let prev = std::panic::take_hook();
1424        std::panic::set_hook(Box::new(|_| {}));
1425        let out = execute(&tool, serde_json::json!({}), host);
1426        std::panic::set_hook(prev);
1427        out
1428    }
1429
1430    /// Assert the tool reported a guarded panic from `host_fn` carrying `detail`.
1431    fn assert_guarded_panic(out: &str, tool_name: &str, host_fn: &str, detail: &str) {
1432        assert!(
1433            out.starts_with(&format!("[error] {tool_name}:")),
1434            "got: {out}"
1435        );
1436        assert!(out.contains(&format!("{host_fn} panicked")), "got: {out}");
1437        assert!(out.contains(detail), "got: {out}");
1438    }
1439
1440    #[test]
1441    fn every_host_fn_panic_becomes_a_script_error() {
1442        // A panicking host function must NEVER unwind into Rhai: rhai's
1443        // `exec_native_fn_call` holds an `ArgBackup` whose destructor asserts,
1444        // so unwinding through it is a double panic → `abort()` → the whole
1445        // daemon dies (issue #109). Each of these would have aborted the test
1446        // process before the guards existed.
1447        for (host_fn, tool_name, script) in [
1448            ("http_get", "t", "// @tool t\nhttp_get(\"http://x\")"),
1449            (
1450                "http_get",
1451                "t",
1452                "// @tool t\nhttp_get(\"http://x\", #{ \"A\": \"b\" })",
1453            ),
1454            (
1455                "http_post",
1456                "t",
1457                "// @tool t\nhttp_post(\"http://x\", \"b\")",
1458            ),
1459            (
1460                "http_post",
1461                "t",
1462                "// @tool t\nhttp_post(\"http://x\", \"b\", #{ \"A\": \"b\" })",
1463            ),
1464            ("shell", "sh", "// @tool sh\nshell(\"ls\")"),
1465            ("read_file", "rf", "// @tool rf\nread_file(\"x.txt\")"),
1466            (
1467                "write_file",
1468                "wf",
1469                "// @tool wf\nwrite_file(\"out.txt\", \"data\")",
1470            ),
1471            ("env_var", "ev", "// @tool ev\nenv_var(\"HOME\")"),
1472        ] {
1473            let out =
1474                execute_with_panicking_host(PanicPayload::Formatted("TLS init failed"), script);
1475            assert_guarded_panic(&out, tool_name, host_fn, "TLS init failed");
1476        }
1477    }
1478
1479    #[test]
1480    fn guarded_panic_renders_str_and_non_string_payloads() {
1481        let out = execute_with_panicking_host(
1482            PanicPayload::Literal,
1483            "// @tool t\nhttp_get(\"http://x\")",
1484        );
1485        assert_guarded_panic(&out, "t", "http_get", "literal str panic");
1486
1487        let out = execute_with_panicking_host(
1488            PanicPayload::NonString,
1489            "// @tool t\nhttp_get(\"http://x\")",
1490        );
1491        assert_guarded_panic(&out, "t", "http_get", "unknown panic");
1492    }
1493
1494    #[test]
1495    fn guards_pass_through_success_and_convert_panics() {
1496        // Both guards, both arms, called directly: the pure host functions
1497        // (`parse_json` / `html_to_text` / …) can't be made to panic through a
1498        // script, so their panic arm is exercised here.
1499        let _guard = PANIC_HOOK_LOCK
1500            .lock()
1501            .unwrap_or_else(PoisonError::into_inner);
1502        assert_eq!(
1503            guard_str("ok_str", &mut || Ok("value".to_string())).unwrap(),
1504            "value"
1505        );
1506        assert!(
1507            guard_dyn("ok_dyn", &mut || Ok(Dynamic::from(7_i64)))
1508                .unwrap()
1509                .is_int()
1510        );
1511
1512        let prev = std::panic::take_hook();
1513        std::panic::set_hook(Box::new(|_| {}));
1514        let str_err = guard_str("boom_str", &mut || panic!("string arm")).unwrap_err();
1515        let dyn_err = guard_dyn("boom_dyn", &mut || panic!("dynamic arm")).unwrap_err();
1516        std::panic::set_hook(prev);
1517        assert!(
1518            str_err
1519                .to_string()
1520                .contains("boom_str panicked: string arm")
1521        );
1522        assert!(
1523            dyn_err
1524                .to_string()
1525                .contains("boom_dyn panicked: dynamic arm")
1526        );
1527    }
1528
1529    #[test]
1530    fn execute_scalar_args_run() {
1531        // Any JSON (including a scalar) converts to a `params` Dynamic; the
1532        // script simply ignores it here.
1533        let tool = tool_from("// @tool t\n\"ok\"");
1534        let out = execute(&tool, serde_json::json!(5), FakeHost::arc());
1535        assert_eq!(out, "ok");
1536    }
1537
1538    #[test]
1539    fn execute_print_and_debug_are_noop() {
1540        // Exercises the no-op `on_print`/`on_debug` closures on the tool engine.
1541        let tool = tool_from("// @tool t\nprint(\"p\"); debug(\"d\"); \"done\"");
1542        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1543        assert_eq!(out, "done");
1544    }
1545
1546    #[test]
1547    fn compile_tool_read_error() {
1548        let engine = Engine::new();
1549        let err = compile_tool(&engine, Path::new("/no/such/dir/tool.rhai")).unwrap_err();
1550        assert!(err.to_string().contains("read"));
1551    }
1552
1553    #[test]
1554    fn to_json_on_unserializable_value_errors() {
1555        // A function pointer has no JSON representation → from_dynamic errors,
1556        // surfacing as a script `[error]`.
1557        let tool = tool_from("// @tool t\nlet f = || 1; to_json(f)");
1558        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1559        assert!(out.starts_with("[error]"), "got: {out}");
1560    }
1561
1562    // ── host functions via a script ──
1563
1564    #[test]
1565    fn http_get_no_headers() {
1566        let host = FakeHost::arc();
1567        let tool = tool_from("// @tool t\nhttp_get(\"http://x\")");
1568        let out = execute(&tool, serde_json::json!({}), host.clone());
1569        assert_eq!(out, "GET-OK");
1570        let (url, headers) = host.last_get.lock().unwrap().clone().unwrap();
1571        assert_eq!(url, "http://x");
1572        assert!(headers.is_empty());
1573    }
1574
1575    #[test]
1576    fn http_get_with_headers() {
1577        let host = FakeHost::arc();
1578        let tool = tool_from("// @tool t\nhttp_get(\"http://x\", #{ \"K\": \"V\" })");
1579        let out = execute(&tool, serde_json::json!({}), host.clone());
1580        assert_eq!(out, "GET-OK");
1581        let (_, headers) = host.last_get.lock().unwrap().clone().unwrap();
1582        assert_eq!(headers.get("K").map(String::as_str), Some("V"));
1583    }
1584
1585    #[test]
1586    fn http_get_error_surfaces() {
1587        let host = FakeHost::arc();
1588        *host.get_response.lock().unwrap() = Err("[denied] http_get".to_string());
1589        let tool = tool_from("// @tool t\nhttp_get(\"http://x\")");
1590        let out = execute(&tool, serde_json::json!({}), host);
1591        assert!(out.contains("[denied] http_get"));
1592    }
1593
1594    #[test]
1595    fn http_post_variants() {
1596        let host = FakeHost::arc();
1597        let tool = tool_from("// @tool t\nhttp_post(\"http://x\", \"body\")");
1598        assert_eq!(
1599            execute(&tool, serde_json::json!({}), host.clone()),
1600            "POST-OK"
1601        );
1602        let (_, body, headers) = host.last_post.lock().unwrap().clone().unwrap();
1603        assert_eq!(body, "body");
1604        assert!(headers.is_empty());
1605
1606        let tool2 = tool_from("// @tool t\nhttp_post(\"http://x\", \"b\", #{ \"H\": \"1\" })");
1607        assert_eq!(
1608            execute(&tool2, serde_json::json!({}), host.clone()),
1609            "POST-OK"
1610        );
1611        let (_, _, headers2) = host.last_post.lock().unwrap().clone().unwrap();
1612        assert_eq!(headers2.get("H").map(String::as_str), Some("1"));
1613    }
1614
1615    #[test]
1616    fn shell_read_env_hosts() {
1617        let host = FakeHost::arc();
1618        assert_eq!(
1619            execute(
1620                &tool_from("// @tool t\nshell(\"ls\")"),
1621                serde_json::json!({}),
1622                host.clone()
1623            ),
1624            "SHELL-OK"
1625        );
1626        assert_eq!(
1627            execute(
1628                &tool_from("// @tool t\nread_file(\"a\")"),
1629                serde_json::json!({}),
1630                host.clone()
1631            ),
1632            "READ-OK"
1633        );
1634        assert_eq!(
1635            execute(
1636                &tool_from("// @tool t\nenv_var(\"A\")"),
1637                serde_json::json!({}),
1638                host.clone()
1639            ),
1640            "ENV-OK"
1641        );
1642        assert_eq!(
1643            execute(
1644                &tool_from("// @tool t\nwrite_file(\"out.txt\", \"body\")"),
1645                serde_json::json!({}),
1646                host
1647            ),
1648            "WROTE:out.txt=body"
1649        );
1650    }
1651
1652    // ── pure host functions ──
1653
1654    #[test]
1655    fn parse_and_to_json_roundtrip() {
1656        let host = FakeHost::arc();
1657        let tool = tool_from("// @tool t\nlet d = parse_json(\"{\\\"a\\\": 1}\"); to_json(d)");
1658        let out = execute(&tool, serde_json::json!({}), host);
1659        assert_eq!(out, "{\"a\":1}");
1660    }
1661
1662    #[test]
1663    fn parse_json_invalid_errors() {
1664        let tool = tool_from("// @tool t\nparse_json(\"not json\")");
1665        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1666        assert!(out.contains("parse_json"));
1667    }
1668
1669    #[test]
1670    fn parse_json_result_used_as_value() {
1671        // parse_json returns a Dynamic map; access a field, return it (string).
1672        let tool = tool_from("// @tool t\nlet d = parse_json(\"{\\\"k\\\": \\\"v\\\"}\"); d.k");
1673        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1674        assert_eq!(out, "v");
1675    }
1676
1677    #[test]
1678    fn encode_uri_encodes_reserved_and_passes_unreserved() {
1679        let tool = tool_from("// @tool t\nencode_uri(\"a b&c-_.~\")");
1680        let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1681        assert_eq!(out, "a%20b%26c-_.~");
1682    }
1683
1684    #[test]
1685    fn to_json_fn_direct_success_and_failure() {
1686        // Direct calls give clean coverage attribution for the named helper,
1687        // independent of rhai's generic `register_fn` wrapper.
1688        let mut map = Map::new();
1689        map.insert("a".into(), Dynamic::from(1_i64));
1690        assert_eq!(to_json_fn(&Dynamic::from_map(map)).unwrap(), "{\"a\":1}");
1691        // A function pointer has no JSON representation → Err.
1692        let engine = Engine::new();
1693        let fnptr: Dynamic = engine.eval("|| 1").unwrap();
1694        assert!(to_json_fn(&fnptr).is_err());
1695    }
1696
1697    #[test]
1698    fn parse_json_fn_direct_success_and_failure() {
1699        let d = parse_json_fn("{\"k\": \"v\"}").unwrap();
1700        assert!(d.is_map());
1701        assert!(parse_json_fn("not json").is_err());
1702    }
1703
1704    #[test]
1705    fn encode_uri_non_ascii() {
1706        // '€' (U+20AC) is 3 UTF-8 bytes E2 82 AC.
1707        assert_eq!(percent_encode("€"), "%E2%82%AC");
1708    }
1709
1710    #[test]
1711    fn hex_digit_covers_both_arms() {
1712        assert_eq!(hex_digit(9), '9');
1713        assert_eq!(hex_digit(15), 'F');
1714        assert_eq!(hex_digit(0), '0');
1715    }
1716
1717    #[test]
1718    fn headers_from_map_stringifies_values() {
1719        let mut m = Map::new();
1720        m.insert("n".into(), Dynamic::from(42_i64));
1721        let headers = headers_from_map(&m);
1722        assert_eq!(headers.get("n").map(String::as_str), Some("42"));
1723    }
1724
1725    #[test]
1726    fn html_to_text_full_pipeline() {
1727        let html = "<html><head><style>.a{color:red}</style></head>\
1728            <body><h1>Tit&amp;le</h1><script>var x=1<2;</script>\
1729            <p>Hello&nbsp;world &#39;quoted&#39; &#x2014; done.</p></body></html>";
1730        let text = html_to_text(html);
1731        assert!(text.contains("Tit&le"), "entity decoded: {text}");
1732        assert!(
1733            text.contains("Hello world 'quoted' \u{2014} done."),
1734            "got: {text}"
1735        );
1736        assert!(!text.contains("color:red"), "style content dropped");
1737        assert!(!text.contains("var x"), "script content dropped");
1738        assert!(!text.contains('<'), "tags stripped");
1739    }
1740
1741    #[test]
1742    fn strip_element_handles_case_unclosed_and_utf8() {
1743        // Case-insensitive open + close.
1744        assert_eq!(strip_element("a<SCRIPT>x</script>b", "script"), "ab");
1745        // Unclosed element drops the remainder.
1746        assert_eq!(strip_element("keep<style>rest", "style"), "keep");
1747        // Non-matching content (incl. multi-byte chars) passes through.
1748        assert_eq!(strip_element("café < 3", "script"), "café < 3");
1749    }
1750
1751    #[test]
1752    fn strip_tags_edges() {
1753        assert_eq!(strip_tags("<b>hi</b>").trim(), "hi");
1754        // '>' outside a tag is kept.
1755        assert_eq!(strip_tags("2 > 1").trim(), "2 > 1");
1756        // Unclosed '<' drops the rest.
1757        assert_eq!(strip_tags("ok <broken").trim(), "ok");
1758    }
1759
1760    #[test]
1761    fn decode_entities_named_numeric_and_unknown() {
1762        assert_eq!(decode_entities("a&amp;b"), "a&b");
1763        assert_eq!(decode_entities("&lt;&gt;&quot;&apos;"), "<>\"'");
1764        assert_eq!(decode_entities("x&nbsp;y"), "x y");
1765        assert_eq!(decode_entities("&mdash;&ndash;&hellip;"), "\u{2014}–…");
1766        assert_eq!(decode_entities("&#65;&#x42;&#X43;"), "ABC");
1767        // Unknown entity kept verbatim.
1768        assert_eq!(decode_entities("&bogus;"), "&bogus;");
1769        // No terminating ';' within the window → '&' kept, scan continues.
1770        assert_eq!(decode_entities("a & b"), "a & b");
1771        // Invalid numeric → kept verbatim.
1772        assert_eq!(decode_entities("&#zz;"), "&#zz;"); // decimal parse Err
1773        assert_eq!(decode_entities("&#xZZ;"), "&#xZZ;"); // hex from_str_radix Err
1774        assert_eq!(decode_entities("&#x110000;"), "&#x110000;"); // hex out of range (from_u32 None)
1775        assert_eq!(decode_entities("&#99999999;"), "&#99999999;"); // decimal out of range (from_u32 None)
1776        // No ampersand at all.
1777        assert_eq!(decode_entities("plain"), "plain");
1778    }
1779
1780    #[test]
1781    fn decode_entities_survives_multibyte_after_an_ampersand() {
1782        // Regression for issue #109: the entity-scan window is a byte count, so
1783        // a bare '&' followed by multi-byte text can slice mid-character and
1784        // panic ("byte index 12 is not a char boundary") - inside a Rhai native
1785        // fn, which aborts the daemon. Every one of these is a real shape from
1786        // fetched HTML.
1787        assert_eq!(decode_entities("&日本語日本"), "&日本語日本");
1788        assert_eq!(decode_entities("R&D 日本語です"), "R&D 日本語です");
1789        assert_eq!(decode_entities("&🎉🎉🎉🎉"), "&🎉🎉🎉🎉");
1790        assert_eq!(
1791            decode_entities("&\u{2014}\u{2014}\u{2014}\u{2014}"),
1792            "&\u{2014}\u{2014}\u{2014}\u{2014}"
1793        );
1794        // A real entity immediately followed by multi-byte text still decodes.
1795        assert_eq!(decode_entities("&amp;日本語"), "&日本語");
1796        // Trailing '&' at the very end of the string (window == 1).
1797        assert_eq!(decode_entities("tail&"), "tail&");
1798        // Issue #115 re-reported the same crash with a flag emoji. '&' plus nine
1799        // ASCII bytes puts the four-byte regional indicator at bytes 10..14, so
1800        // a fixed byte-12 window cuts straight through it. Pinned verbatim so
1801        // the reported input, not just an equivalent one, stays covered.
1802        assert_eq!(decode_entities("&abcdefghi🇸"), "&abcdefghi🇸");
1803    }
1804
1805    #[test]
1806    fn collapse_whitespace_runs_and_trims() {
1807        assert_eq!(collapse_whitespace("  a \n\t b  "), "a b");
1808        assert_eq!(collapse_whitespace(""), "");
1809    }
1810}