use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rhai::{AST, Dynamic, Engine, EvalAltResult, Map, Position, Scope};
use serde::Deserialize;
use crate::{Error, Result};
#[derive(Debug, Clone, PartialEq)]
pub struct ParamSpec {
pub name: String,
pub ty: String,
pub required: bool,
pub description: String,
pub schema: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ScriptToolMeta {
pub name: String,
pub description: String,
pub params: Vec<ParamSpec>,
pub required_caps: Vec<String>,
}
impl ScriptToolMeta {
pub fn parameters_schema(&self) -> serde_json::Value {
let mut properties = serde_json::Map::new();
let mut required: Vec<serde_json::Value> = Vec::new();
for p in &self.params {
let property = match &p.schema {
Some(fragment) => fragment.clone(),
None => serde_json::json!({ "type": p.ty, "description": p.description }),
};
properties.insert(p.name.clone(), property);
if p.required {
required.push(serde_json::Value::String(p.name.clone()));
}
}
serde_json::json!({
"type": "object",
"properties": serde_json::Value::Object(properties),
"required": serde_json::Value::Array(required),
})
}
}
pub fn parse_annotations(src: &str) -> Result<ScriptToolMeta> {
let mut name: Option<String> = None;
let mut description = String::new();
let mut params: Vec<ParamSpec> = Vec::new();
let mut required_caps: Vec<String> = Vec::new();
for line in src.lines() {
let trimmed = line.trim();
let Some(rest) = trimmed.strip_prefix("//") else {
continue;
};
let rest = rest.trim();
let Some(directive) = rest.strip_prefix('@') else {
continue;
};
let (keyword, arg) = match directive.split_once(char::is_whitespace) {
Some((k, a)) => (k, a.trim()),
None => (directive, ""),
};
match keyword {
"tool" => {
if arg.is_empty() {
return Err(Error::ValidationFailed(
"@tool directive requires a tool name".to_string(),
));
}
name = Some(arg.to_string());
}
"description" => description = arg.to_string(),
"param" => params.push(parse_param_directive(arg)?),
"requires" => required_caps.extend(
arg.split([' ', ',', '\t'])
.filter(|c| !c.is_empty())
.map(str::to_string),
),
_ => {} }
}
let name = name.ok_or_else(|| {
Error::ValidationFailed("script tool is missing a `// @tool <name>` directive".to_string())
})?;
Ok(ScriptToolMeta {
name,
description,
params,
required_caps,
})
}
fn parse_param_directive(arg: &str) -> Result<ParamSpec> {
let mut it = arg.splitn(4, char::is_whitespace).map(str::trim);
let name = it.next().filter(|s| !s.is_empty());
let ty = it.next().filter(|s| !s.is_empty());
let requiredness = it.next().filter(|s| !s.is_empty());
let (name, ty, requiredness) = match (name, ty, requiredness) {
(Some(n), Some(t), Some(r)) => (n, t, r),
_ => {
return Err(Error::ValidationFailed(format!(
"@param requires `<name> <type> <required|optional>`, got: `{arg}`"
)));
}
};
let required = match requiredness {
"required" => true,
"optional" => false,
other => {
return Err(Error::ValidationFailed(format!(
"@param requiredness must be `required` or `optional`, got: `{other}`"
)));
}
};
let description = it
.next()
.map(|d| d.trim().trim_matches('"').to_string())
.unwrap_or_default();
Ok(ParamSpec {
name: name.to_string(),
ty: ty.to_string(),
required,
description,
schema: None,
})
}
#[derive(Debug, Deserialize)]
struct ToolTomlDoc {
tool: ToolTomlTool,
}
#[derive(Debug, Deserialize)]
struct ToolTomlTool {
name: String,
#[serde(default)]
description: String,
#[serde(default)]
params: Vec<ToolTomlParam>,
#[serde(default)]
requires: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ToolTomlParam {
name: String,
#[serde(default, rename = "type")]
ty: String,
#[serde(default)]
required: bool,
#[serde(default)]
description: String,
#[serde(default)]
schema: Option<serde_json::Value>,
}
pub fn parse_tool_toml(src: &str) -> Result<ScriptToolMeta> {
let doc: ToolTomlDoc = toml::from_str(src)
.map_err(|e| Error::ValidationFailed(format!("invalid tool.toml: {e}")))?;
if doc.tool.name.trim().is_empty() {
return Err(Error::ValidationFailed(
"tool.toml `[tool] name` must not be empty".to_string(),
));
}
let params = doc
.tool
.params
.into_iter()
.map(|p| ParamSpec {
name: p.name,
ty: p.ty,
required: p.required,
description: p.description,
schema: p.schema,
})
.collect();
Ok(ScriptToolMeta {
name: doc.tool.name,
description: doc.tool.description,
params,
required_caps: doc.tool.requires,
})
}
pub trait ScriptHost: Send + Sync {
fn http_get(
&self,
url: &str,
headers: BTreeMap<String, String>,
) -> std::result::Result<String, String>;
fn http_post(
&self,
url: &str,
body: &str,
headers: BTreeMap<String, String>,
) -> std::result::Result<String, String>;
fn shell(&self, command: &str) -> std::result::Result<String, String>;
fn read_file(&self, path: &str) -> std::result::Result<String, String>;
fn write_file(&self, path: &str, content: &str) -> std::result::Result<String, String>;
fn env_var(&self, name: &str) -> std::result::Result<String, String>;
}
#[derive(Clone, Debug)]
pub struct ScriptTool {
pub meta: ScriptToolMeta,
pub ast: AST,
pub source_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct SkippedTool {
pub path: PathBuf,
pub reason: String,
}
#[derive(Clone, Default)]
pub struct ScriptToolSet {
tools: BTreeMap<String, ScriptTool>,
}
impl ScriptToolSet {
pub fn discover(dirs: &[PathBuf]) -> (Self, Vec<SkippedTool>) {
let mut tools: BTreeMap<String, ScriptTool> = BTreeMap::new();
let mut skipped: Vec<SkippedTool> = Vec::new();
let engine = Engine::new();
for dir in dirs {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => continue, };
let mut paths: Vec<PathBuf> = entries
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|ext| ext == "rhai"))
.collect();
paths.sort();
for path in paths {
match compile_tool(&engine, &path) {
Ok(tool) => {
tools.entry(tool.meta.name.clone()).or_insert(tool);
}
Err(e) => skipped.push(SkippedTool {
path,
reason: e.to_string(),
}),
}
}
}
(Self { tools }, skipped)
}
pub fn contains(&self, name: &str) -> bool {
self.tools.contains_key(name)
}
pub fn get(&self, name: &str) -> Option<&ScriptTool> {
self.tools.get(name)
}
pub fn names(&self) -> Vec<String> {
self.tools.keys().cloned().collect()
}
pub fn metas(&self) -> Vec<ScriptToolMeta> {
self.tools.values().map(|t| t.meta.clone()).collect()
}
pub fn len(&self) -> usize {
self.tools.len()
}
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
}
fn compile_tool(engine: &Engine, path: &Path) -> Result<ScriptTool> {
let src = std::fs::read_to_string(path)
.map_err(|e| Error::ValidationFailed(format!("read {}: {e}", path.display())))?;
let toml_path = path.with_extension("toml");
let meta = match std::fs::read_to_string(&toml_path) {
Ok(toml_src) => parse_tool_toml(&toml_src)?,
Err(_) => parse_annotations(&src)?,
};
let ast = engine
.compile(&src)
.map_err(|e| Error::CompilationFailed(format!("{}: {e}", path.display())))?;
Ok(ScriptTool {
meta,
ast,
source_path: path.to_path_buf(),
})
}
pub const SCRIPT_TOOL_MAX_OPERATIONS: u64 = 500_000;
pub fn execute(tool: &ScriptTool, args: serde_json::Value, host: Arc<dyn ScriptHost>) -> String {
let engine = build_tool_engine(host);
let params = rhai::serde::to_dynamic(args).unwrap_or(Dynamic::UNIT);
let mut scope = Scope::new();
scope.push_dynamic("params", params);
match engine.eval_ast_with_scope::<Dynamic>(&mut scope, &tool.ast) {
Ok(value) => dynamic_to_result_string(value),
Err(e) => format!("[error] {}: {}", tool.meta.name, e),
}
}
fn dynamic_to_result_string(value: Dynamic) -> String {
if value.is_string() {
return value.into_string().unwrap_or_default();
}
if value.is_unit() {
return String::new();
}
match rhai::serde::from_dynamic::<serde_json::Value>(&value) {
Ok(json) => json.to_string(),
Err(e) => format!("[error] cannot serialize result: {e}"),
}
}
fn build_tool_engine(host: Arc<dyn ScriptHost>) -> Engine {
let mut engine = Engine::new();
crate::harden(&mut engine, SCRIPT_TOOL_MAX_OPERATIONS);
crate::functions::register_functions(&mut engine);
crate::types::register_types(&mut engine);
register_host_functions(&mut engine, host);
engine
}
type HostRes<T> = std::result::Result<T, Box<EvalAltResult>>;
fn to_rhai(r: std::result::Result<String, String>) -> HostRes<String> {
r.map_err(|msg| Box::new(EvalAltResult::ErrorRuntime(msg.into(), Position::NONE)))
}
fn panic_to_rhai(name: &str, payload: Box<dyn std::any::Any + Send>) -> Box<EvalAltResult> {
let msg = leviath_core::panic_message(payload.as_ref());
tracing::warn!(
host_fn = name,
panic = %msg,
"a script-tool host function panicked; surfacing it as a script error (issue #109)"
);
Box::new(EvalAltResult::ErrorRuntime(
format!("{name} panicked: {msg}").into(),
Position::NONE,
))
}
fn guard_str(name: &str, f: &mut dyn FnMut() -> HostRes<String>) -> HostRes<String> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(r) => r,
Err(payload) => Err(panic_to_rhai(name, payload)),
}
}
fn guard_dyn(name: &str, f: &mut dyn FnMut() -> HostRes<Dynamic>) -> HostRes<Dynamic> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(r) => r,
Err(payload) => Err(panic_to_rhai(name, payload)),
}
}
fn headers_from_map(map: &Map) -> BTreeMap<String, String> {
map.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn register_host_functions(engine: &mut Engine, host: Arc<dyn ScriptHost>) {
let h = host.clone();
engine.register_fn("http_get", move |url: &str| {
guard_str("http_get", &mut || {
to_rhai(h.http_get(url, BTreeMap::new()))
})
});
let h = host.clone();
engine.register_fn("http_get", move |url: &str, headers: Map| {
guard_str("http_get", &mut || {
to_rhai(h.http_get(url, headers_from_map(&headers)))
})
});
let h = host.clone();
engine.register_fn("http_post", move |url: &str, body: &str| {
guard_str("http_post", &mut || {
to_rhai(h.http_post(url, body, BTreeMap::new()))
})
});
let h = host.clone();
engine.register_fn("http_post", move |url: &str, body: &str, headers: Map| {
guard_str("http_post", &mut || {
to_rhai(h.http_post(url, body, headers_from_map(&headers)))
})
});
let h = host.clone();
engine.register_fn("shell", move |cmd: &str| {
guard_str("shell", &mut || to_rhai(h.shell(cmd)))
});
let h = host.clone();
engine.register_fn("read_file", move |path: &str| {
guard_str("read_file", &mut || to_rhai(h.read_file(path)))
});
let h = host.clone();
engine.register_fn("write_file", move |path: &str, content: &str| {
guard_str("write_file", &mut || to_rhai(h.write_file(path, content)))
});
let h = host.clone();
engine.register_fn("env_var", move |name: &str| {
guard_str("env_var", &mut || to_rhai(h.env_var(name)))
});
engine.register_fn("parse_json", |s: &str| -> HostRes<Dynamic> {
guard_dyn("parse_json", &mut || parse_json_fn(s))
});
engine.register_fn("to_json", |v: Dynamic| -> HostRes<String> {
guard_str("to_json", &mut || to_json_fn(&v))
});
engine.register_fn("encode_uri", |s: &str| -> HostRes<String> {
guard_str("encode_uri", &mut || Ok(percent_encode(s)))
});
engine.register_fn("html_to_text", |s: &str| -> HostRes<String> {
guard_str("html_to_text", &mut || Ok(html_to_text(s)))
});
}
fn parse_json_fn(s: &str) -> HostRes<Dynamic> {
let value: serde_json::Value = serde_json::from_str(s).map_err(|e| {
Box::new(EvalAltResult::ErrorRuntime(
format!("parse_json: {e}").into(),
Position::NONE,
))
})?;
rhai::serde::to_dynamic(value)
}
fn to_json_fn(v: &Dynamic) -> HostRes<String> {
let json: serde_json::Value = rhai::serde::from_dynamic(v)?;
Ok(json.to_string())
}
fn percent_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for &byte in input.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => {
out.push('%');
out.push(hex_digit(byte >> 4));
out.push(hex_digit(byte & 0x0f));
}
}
}
out
}
fn hex_digit(nibble: u8) -> char {
match nibble {
0..=9 => (b'0' + nibble) as char,
_ => (b'A' + (nibble - 10)) as char,
}
}
fn html_to_text(html: &str) -> String {
let without_raw = strip_raw_text_elements(html);
let without_tags = strip_tags(&without_raw);
let decoded = decode_entities(&without_tags);
collapse_whitespace(&decoded)
}
fn strip_raw_text_elements(html: &str) -> String {
let mut s = html.to_string();
for tag in ["script", "style"] {
s = strip_element(&s, tag);
}
s
}
#[expect(
clippy::string_slice,
reason = "`i` only ever advances by `ch.len_utf8()` and `rel` comes from `find`, so both are \
char boundaries; `to_ascii_lowercase` preserves byte lengths, so `lower` and `html` \
share them"
)]
fn strip_element(html: &str, tag: &str) -> String {
let lower = html.to_ascii_lowercase();
let open = format!("<{tag}");
let close = format!("</{tag}>");
let mut out = String::with_capacity(html.len());
let mut i = 0;
while i < html.len() {
if lower[i..].starts_with(&open) {
match lower[i..].find(&close) {
Some(rel) => {
i += rel + close.len();
continue;
}
None => break, }
}
let ch = html[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}
fn strip_tags(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut in_tag = false;
for c in html.chars() {
match c {
'<' => in_tag = true,
'>' if in_tag => {
in_tag = false;
out.push(' ');
}
_ if !in_tag => out.push(c),
_ => {}
}
}
out
}
const ENTITY_SCAN_CHARS: usize = 12;
#[expect(
clippy::string_slice,
reason = "`amp` comes from `find` and `semi` from `char_indices`, so every index here is a \
char boundary; `after[1..]` is safe because `after` starts at the single-byte '&'"
)]
fn decode_entities(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let after = &rest[amp..];
let semi = after
.char_indices()
.take(ENTITY_SCAN_CHARS)
.find(|&(_, c)| c == ';')
.map(|(i, _)| i);
match semi {
Some(semi) => match decode_one_entity(&after[1..semi]) {
Some(ch) => {
out.push(ch);
rest = &after[semi + 1..];
}
None => {
out.push('&');
rest = &after[1..];
}
},
None => {
out.push('&');
rest = &after[1..];
}
}
}
out.push_str(rest);
out
}
fn decode_one_entity(e: &str) -> Option<char> {
match e {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some(' '),
"mdash" => Some('\u{2014}'),
"ndash" => Some('–'),
"hellip" => Some('…'),
_ => {
if let Some(hex) = e.strip_prefix("#x").or_else(|| e.strip_prefix("#X")) {
u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
} else if let Some(dec) = e.strip_prefix('#') {
dec.parse::<u32>().ok().and_then(char::from_u32)
} else {
None
}
}
}
}
fn collapse_whitespace(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_ws = false;
for c in s.chars() {
if c.is_whitespace() {
if !prev_ws {
out.push(' ');
prev_ws = true;
}
} else {
out.push(c);
prev_ws = false;
}
}
out.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, PoisonError};
static PANIC_HOOK_LOCK: Mutex<()> = Mutex::new(());
type Headers = BTreeMap<String, String>;
type GetCall = Option<(String, Headers)>;
type PostCall = Option<(String, String, Headers)>;
type HostResult = std::result::Result<String, String>;
struct FakeHost {
get_response: Mutex<HostResult>,
post_response: Mutex<HostResult>,
shell_response: Mutex<HostResult>,
read_response: Mutex<HostResult>,
env_response: Mutex<HostResult>,
last_get: Mutex<GetCall>,
last_post: Mutex<PostCall>,
}
impl FakeHost {
fn arc() -> Arc<FakeHost> {
Arc::new(FakeHost {
get_response: Mutex::new(Ok("GET-OK".to_string())),
post_response: Mutex::new(Ok("POST-OK".to_string())),
shell_response: Mutex::new(Ok("SHELL-OK".to_string())),
read_response: Mutex::new(Ok("READ-OK".to_string())),
env_response: Mutex::new(Ok("ENV-OK".to_string())),
last_get: Mutex::new(None),
last_post: Mutex::new(None),
})
}
}
impl ScriptHost for FakeHost {
fn http_get(
&self,
url: &str,
headers: BTreeMap<String, String>,
) -> std::result::Result<String, String> {
*self.last_get.lock().unwrap() = Some((url.to_string(), headers));
self.get_response.lock().unwrap().clone()
}
fn http_post(
&self,
url: &str,
body: &str,
headers: BTreeMap<String, String>,
) -> std::result::Result<String, String> {
*self.last_post.lock().unwrap() = Some((url.to_string(), body.to_string(), headers));
self.post_response.lock().unwrap().clone()
}
fn shell(&self, _command: &str) -> std::result::Result<String, String> {
self.shell_response.lock().unwrap().clone()
}
fn read_file(&self, _path: &str) -> std::result::Result<String, String> {
self.read_response.lock().unwrap().clone()
}
fn write_file(&self, path: &str, content: &str) -> std::result::Result<String, String> {
Ok(format!("WROTE:{path}={content}"))
}
fn env_var(&self, _name: &str) -> std::result::Result<String, String> {
self.env_response.lock().unwrap().clone()
}
}
fn tool_from(src: &str) -> ScriptTool {
let engine = Engine::new();
let ast = engine.compile(src).expect("compile");
ScriptTool {
meta: parse_annotations(src).expect("annotations"),
ast,
source_path: PathBuf::from("mem.rhai"),
}
}
#[test]
fn annotations_full() {
let src = r#"
// @tool web_search
// @description Search the web
// @param query string required "Search query"
// @param count integer optional "How many"
42
"#;
let meta = parse_annotations(src).unwrap();
assert_eq!(meta.name, "web_search");
assert_eq!(meta.description, "Search the web");
assert_eq!(meta.params.len(), 2);
assert_eq!(
meta.params[0],
ParamSpec {
name: "query".into(),
ty: "string".into(),
required: true,
description: "Search query".into(),
schema: None,
}
);
assert!(!meta.params[1].required);
assert!(meta.required_caps.is_empty());
}
#[test]
fn annotations_requires_capabilities() {
let src = "// @tool t\n// @requires network, shell\n// @requires filesystem\n1";
let meta = parse_annotations(src).unwrap();
assert_eq!(meta.required_caps, ["network", "shell", "filesystem"]);
}
#[test]
fn annotations_missing_tool_name_errors() {
let err = parse_annotations("// @description no name\n1").unwrap_err();
assert!(err.to_string().contains("missing a `// @tool"));
}
#[test]
fn annotations_empty_tool_name_errors() {
let err = parse_annotations("// @tool \n1").unwrap_err();
assert!(err.to_string().contains("requires a tool name"));
}
#[test]
fn annotations_ignore_non_comment_and_non_directive_lines() {
let src = "let x = 1; // trailing\n// plain comment\n// @tool t\nx";
let meta = parse_annotations(src).unwrap();
assert_eq!(meta.name, "t");
assert!(meta.params.is_empty());
assert_eq!(meta.description, "");
}
#[test]
fn annotations_unknown_directive_ignored() {
let meta = parse_annotations("// @tool t\n// @bogus whatever\n1").unwrap();
assert_eq!(meta.name, "t");
}
#[test]
fn annotations_directive_with_no_arg_is_handled() {
let meta = parse_annotations("// @tool t\n// @description\n1").unwrap();
assert_eq!(meta.description, "");
}
#[test]
fn param_without_description_defaults_empty() {
let meta = parse_annotations("// @tool t\n// @param x string required\n1").unwrap();
assert_eq!(meta.params[0].description, "");
assert!(meta.params[0].required);
}
#[test]
fn param_optional_flag() {
let meta = parse_annotations("// @tool t\n// @param x string optional\n1").unwrap();
assert!(!meta.params[0].required);
}
#[test]
fn param_too_few_tokens_errors() {
let err = parse_annotations("// @tool t\n// @param x string\n1").unwrap_err();
assert!(err.to_string().contains("requires `<name> <type>"));
}
#[test]
fn param_bad_requiredness_errors() {
let err = parse_annotations("// @tool t\n// @param x string maybe\n1").unwrap_err();
assert!(err.to_string().contains("must be `required` or `optional`"));
}
#[test]
fn tool_toml_full() {
let src = r#"
[tool]
name = "fetch"
description = "Fetch a URL"
[[tool.params]]
name = "url"
type = "string"
required = true
description = "The URL"
"#;
let meta = parse_tool_toml(src).unwrap();
assert_eq!(meta.name, "fetch");
assert_eq!(meta.description, "Fetch a URL");
assert_eq!(meta.params.len(), 1);
assert!(meta.params[0].required);
assert_eq!(meta.params[0].ty, "string");
}
#[test]
fn tool_toml_requires() {
let meta = parse_tool_toml("[tool]\nname = \"t\"\nrequires = [\"network\"]").unwrap();
assert_eq!(meta.required_caps, ["network"]);
}
#[test]
fn tool_toml_defaults() {
let meta = parse_tool_toml("[tool]\nname = \"t\"").unwrap();
assert_eq!(meta.description, "");
assert!(meta.params.is_empty());
assert!(meta.required_caps.is_empty());
}
#[test]
fn tool_toml_raw_schema_fragment() {
let src = r#"
[tool]
name = "export"
[[tool.params]]
name = "format"
required = true
schema = { type = "string", enum = ["json", "yaml"], description = "Output format" }
"#;
let meta = parse_tool_toml(src).unwrap();
assert_eq!(meta.params.len(), 1);
assert!(meta.params[0].required);
assert_eq!(meta.params[0].ty, "");
let frag = meta.params[0].schema.as_ref().unwrap();
assert_eq!(frag["enum"][0], "json");
}
#[test]
fn tool_toml_invalid_syntax_errors() {
let err = parse_tool_toml("not = valid = toml").unwrap_err();
assert!(err.to_string().contains("invalid tool.toml"));
}
#[test]
fn tool_toml_empty_name_errors() {
let err = parse_tool_toml("[tool]\nname = \"\"").unwrap_err();
assert!(err.to_string().contains("must not be empty"));
}
#[test]
fn parameters_schema_shape() {
let meta = parse_annotations(
"// @tool t\n// @param a string required \"A\"\n// @param b integer optional \"B\"\n1",
)
.unwrap();
let schema = meta.parameters_schema();
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["a"]["type"], "string");
assert_eq!(schema["properties"]["b"]["description"], "B");
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "a");
}
#[test]
fn parameters_schema_uses_raw_fragment_verbatim() {
let meta = parse_tool_toml(
"[tool]\nname = \"t\"\n[[tool.params]]\nname = \"fmt\"\nrequired = true\nschema = { type = \"string\", enum = [\"a\", \"b\"] }\n",
)
.unwrap();
let schema = meta.parameters_schema();
assert_eq!(schema["properties"]["fmt"]["type"], "string");
assert_eq!(schema["properties"]["fmt"]["enum"][1], "b");
assert!(schema["properties"]["fmt"].get("description").is_none());
assert_eq!(schema["required"][0], "fmt");
}
#[test]
fn discover_compiles_and_collides() {
let dir_a = tempfile::tempdir().unwrap();
let dir_b = tempfile::tempdir().unwrap();
std::fs::write(
dir_a.path().join("dup.rhai"),
"// @tool dup\n// @description from A\n1",
)
.unwrap();
std::fs::write(
dir_b.path().join("dup.rhai"),
"// @tool dup\n// @description from B\n2",
)
.unwrap();
std::fs::write(dir_b.path().join("solo.rhai"), "// @tool solo\n3").unwrap();
std::fs::write(dir_b.path().join("note.txt"), "ignored").unwrap();
std::fs::write(
dir_b.path().join("broken.rhai"),
"// no tool directive\nlet",
)
.unwrap();
let (set, skipped) = ScriptToolSet::discover(&[
dir_a.path().to_path_buf(),
dir_b.path().to_path_buf(),
dir_a.path().join("does-not-exist"),
]);
assert_eq!(set.len(), 2);
assert!(!set.is_empty());
assert!(set.contains("dup"));
assert!(set.contains("solo"));
assert_eq!(set.get("dup").unwrap().meta.description, "from A");
let mut names = set.names();
names.sort();
assert_eq!(names, vec!["dup".to_string(), "solo".to_string()]);
assert_eq!(set.metas().len(), 2);
assert_eq!(skipped.len(), 1);
assert!(skipped[0].path.ends_with("broken.rhai"));
assert!(!skipped[0].reason.is_empty());
}
#[test]
fn discover_uses_tool_toml_override() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("t.rhai"), "// @tool ann\n1").unwrap();
std::fs::write(
dir.path().join("t.toml"),
"[tool]\nname = \"override\"\ndescription = \"D\"",
)
.unwrap();
let (set, skipped) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
assert!(set.contains("override"));
assert!(!set.contains("ann"));
assert!(skipped.is_empty());
}
#[test]
fn discover_skips_invalid_tool_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("t.rhai"), "// @tool t\n1").unwrap();
std::fs::write(dir.path().join("t.toml"), "name = broken").unwrap();
let (set, skipped) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
assert!(set.is_empty());
assert_eq!(skipped.len(), 1);
assert!(skipped[0].reason.contains("tool.toml"));
}
#[test]
fn discover_skips_uncompilable_but_valid_annotation() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("t.rhai"), "// @tool t\nlet x = ;").unwrap();
let (set, _) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
assert!(set.is_empty());
}
#[test]
fn default_set_is_empty() {
let set = ScriptToolSet::default();
assert!(set.is_empty());
assert!(set.get("x").is_none());
}
#[test]
fn execute_returns_string_verbatim() {
let tool = tool_from("// @tool t\n\"hello \" + params.name");
let out = execute(&tool, serde_json::json!({"name": "world"}), FakeHost::arc());
assert_eq!(out, "hello world");
}
#[test]
fn execute_serializes_non_string_result() {
let tool = tool_from("// @tool t\n[1, 2, 3]");
let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
assert_eq!(out, "[1,2,3]");
}
#[test]
fn execute_unserializable_result_errors() {
let tool = tool_from("// @tool t\n|| 1");
let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
assert!(out.contains("cannot serialize result"), "got: {out}");
}
#[test]
fn execute_unit_result_is_empty() {
let tool = tool_from("// @tool t\nlet x = 1;");
let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
assert_eq!(out, "");
}
#[test]
fn execute_html_to_text_host_fn_via_script() {
let tool = tool_from("// @tool t\nhtml_to_text(\"<p>Hi&<b>bye</b></p>\")");
let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
assert_eq!(out, "Hi& bye");
}
#[test]
fn execute_missing_optional_param_reads_as_unit() {
let tool = tool_from("// @tool t\nif params.count == () { \"default\" } else { \"set\" }");
let out = execute(&tool, serde_json::json!({"query": "x"}), FakeHost::arc());
assert_eq!(out, "default");
}
#[test]
fn execute_script_error_is_prefixed() {
let tool = tool_from("// @tool t\nthrow \"boom\"");
let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
assert!(out.starts_with("[error] t:"), "got: {out}");
assert!(out.contains("boom"));
}
enum PanicPayload {
Formatted(&'static str),
Literal,
NonString,
}
struct PanickingHost {
payload: PanicPayload,
}
impl PanickingHost {
fn do_panic(&self) -> ! {
match &self.payload {
PanicPayload::Formatted(msg) => panic!("{}", msg),
PanicPayload::Literal => panic!("literal str panic"),
PanicPayload::NonString => std::panic::panic_any(42_i32),
}
}
}
impl ScriptHost for PanickingHost {
fn http_get(
&self,
_u: &str,
_h: BTreeMap<String, String>,
) -> std::result::Result<String, String> {
self.do_panic();
}
fn http_post(
&self,
_u: &str,
_b: &str,
_h: BTreeMap<String, String>,
) -> std::result::Result<String, String> {
self.do_panic();
}
fn shell(&self, _c: &str) -> std::result::Result<String, String> {
self.do_panic();
}
fn read_file(&self, _p: &str) -> std::result::Result<String, String> {
self.do_panic();
}
fn write_file(&self, _p: &str, _c: &str) -> std::result::Result<String, String> {
self.do_panic();
}
fn env_var(&self, _n: &str) -> std::result::Result<String, String> {
self.do_panic();
}
}
fn execute_with_panicking_host(payload: PanicPayload, script: &str) -> String {
let host: Arc<dyn ScriptHost> = Arc::new(PanickingHost { payload });
let tool = tool_from(script);
let _guard = PANIC_HOOK_LOCK
.lock()
.unwrap_or_else(PoisonError::into_inner);
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let out = execute(&tool, serde_json::json!({}), host);
std::panic::set_hook(prev);
out
}
fn assert_guarded_panic(out: &str, tool_name: &str, host_fn: &str, detail: &str) {
assert!(
out.starts_with(&format!("[error] {tool_name}:")),
"got: {out}"
);
assert!(out.contains(&format!("{host_fn} panicked")), "got: {out}");
assert!(out.contains(detail), "got: {out}");
}
#[test]
fn every_host_fn_panic_becomes_a_script_error() {
for (host_fn, tool_name, script) in [
("http_get", "t", "// @tool t\nhttp_get(\"http://x\")"),
(
"http_get",
"t",
"// @tool t\nhttp_get(\"http://x\", #{ \"A\": \"b\" })",
),
(
"http_post",
"t",
"// @tool t\nhttp_post(\"http://x\", \"b\")",
),
(
"http_post",
"t",
"// @tool t\nhttp_post(\"http://x\", \"b\", #{ \"A\": \"b\" })",
),
("shell", "sh", "// @tool sh\nshell(\"ls\")"),
("read_file", "rf", "// @tool rf\nread_file(\"x.txt\")"),
(
"write_file",
"wf",
"// @tool wf\nwrite_file(\"out.txt\", \"data\")",
),
("env_var", "ev", "// @tool ev\nenv_var(\"HOME\")"),
] {
let out =
execute_with_panicking_host(PanicPayload::Formatted("TLS init failed"), script);
assert_guarded_panic(&out, tool_name, host_fn, "TLS init failed");
}
}
#[test]
fn guarded_panic_renders_str_and_non_string_payloads() {
let out = execute_with_panicking_host(
PanicPayload::Literal,
"// @tool t\nhttp_get(\"http://x\")",
);
assert_guarded_panic(&out, "t", "http_get", "literal str panic");
let out = execute_with_panicking_host(
PanicPayload::NonString,
"// @tool t\nhttp_get(\"http://x\")",
);
assert_guarded_panic(&out, "t", "http_get", "unknown panic");
}
#[test]
fn guards_pass_through_success_and_convert_panics() {
let _guard = PANIC_HOOK_LOCK
.lock()
.unwrap_or_else(PoisonError::into_inner);
assert_eq!(
guard_str("ok_str", &mut || Ok("value".to_string())).unwrap(),
"value"
);
assert!(
guard_dyn("ok_dyn", &mut || Ok(Dynamic::from(7_i64)))
.unwrap()
.is_int()
);
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let str_err = guard_str("boom_str", &mut || panic!("string arm")).unwrap_err();
let dyn_err = guard_dyn("boom_dyn", &mut || panic!("dynamic arm")).unwrap_err();
std::panic::set_hook(prev);
assert!(
str_err
.to_string()
.contains("boom_str panicked: string arm")
);
assert!(
dyn_err
.to_string()
.contains("boom_dyn panicked: dynamic arm")
);
}
#[test]
fn execute_scalar_args_run() {
let tool = tool_from("// @tool t\n\"ok\"");
let out = execute(&tool, serde_json::json!(5), FakeHost::arc());
assert_eq!(out, "ok");
}
#[test]
fn execute_print_and_debug_are_noop() {
let tool = tool_from("// @tool t\nprint(\"p\"); debug(\"d\"); \"done\"");
let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
assert_eq!(out, "done");
}
#[test]
fn compile_tool_read_error() {
let engine = Engine::new();
let err = compile_tool(&engine, Path::new("/no/such/dir/tool.rhai")).unwrap_err();
assert!(err.to_string().contains("read"));
}
#[test]
fn to_json_on_unserializable_value_errors() {
let tool = tool_from("// @tool t\nlet f = || 1; to_json(f)");
let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
assert!(out.starts_with("[error]"), "got: {out}");
}
#[test]
fn http_get_no_headers() {
let host = FakeHost::arc();
let tool = tool_from("// @tool t\nhttp_get(\"http://x\")");
let out = execute(&tool, serde_json::json!({}), host.clone());
assert_eq!(out, "GET-OK");
let (url, headers) = host.last_get.lock().unwrap().clone().unwrap();
assert_eq!(url, "http://x");
assert!(headers.is_empty());
}
#[test]
fn http_get_with_headers() {
let host = FakeHost::arc();
let tool = tool_from("// @tool t\nhttp_get(\"http://x\", #{ \"K\": \"V\" })");
let out = execute(&tool, serde_json::json!({}), host.clone());
assert_eq!(out, "GET-OK");
let (_, headers) = host.last_get.lock().unwrap().clone().unwrap();
assert_eq!(headers.get("K").map(String::as_str), Some("V"));
}
#[test]
fn http_get_error_surfaces() {
let host = FakeHost::arc();
*host.get_response.lock().unwrap() = Err("[denied] http_get".to_string());
let tool = tool_from("// @tool t\nhttp_get(\"http://x\")");
let out = execute(&tool, serde_json::json!({}), host);
assert!(out.contains("[denied] http_get"));
}
#[test]
fn http_post_variants() {
let host = FakeHost::arc();
let tool = tool_from("// @tool t\nhttp_post(\"http://x\", \"body\")");
assert_eq!(
execute(&tool, serde_json::json!({}), host.clone()),
"POST-OK"
);
let (_, body, headers) = host.last_post.lock().unwrap().clone().unwrap();
assert_eq!(body, "body");
assert!(headers.is_empty());
let tool2 = tool_from("// @tool t\nhttp_post(\"http://x\", \"b\", #{ \"H\": \"1\" })");
assert_eq!(
execute(&tool2, serde_json::json!({}), host.clone()),
"POST-OK"
);
let (_, _, headers2) = host.last_post.lock().unwrap().clone().unwrap();
assert_eq!(headers2.get("H").map(String::as_str), Some("1"));
}
#[test]
fn shell_read_env_hosts() {
let host = FakeHost::arc();
assert_eq!(
execute(
&tool_from("// @tool t\nshell(\"ls\")"),
serde_json::json!({}),
host.clone()
),
"SHELL-OK"
);
assert_eq!(
execute(
&tool_from("// @tool t\nread_file(\"a\")"),
serde_json::json!({}),
host.clone()
),
"READ-OK"
);
assert_eq!(
execute(
&tool_from("// @tool t\nenv_var(\"A\")"),
serde_json::json!({}),
host.clone()
),
"ENV-OK"
);
assert_eq!(
execute(
&tool_from("// @tool t\nwrite_file(\"out.txt\", \"body\")"),
serde_json::json!({}),
host
),
"WROTE:out.txt=body"
);
}
#[test]
fn parse_and_to_json_roundtrip() {
let host = FakeHost::arc();
let tool = tool_from("// @tool t\nlet d = parse_json(\"{\\\"a\\\": 1}\"); to_json(d)");
let out = execute(&tool, serde_json::json!({}), host);
assert_eq!(out, "{\"a\":1}");
}
#[test]
fn parse_json_invalid_errors() {
let tool = tool_from("// @tool t\nparse_json(\"not json\")");
let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
assert!(out.contains("parse_json"));
}
#[test]
fn parse_json_result_used_as_value() {
let tool = tool_from("// @tool t\nlet d = parse_json(\"{\\\"k\\\": \\\"v\\\"}\"); d.k");
let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
assert_eq!(out, "v");
}
#[test]
fn encode_uri_encodes_reserved_and_passes_unreserved() {
let tool = tool_from("// @tool t\nencode_uri(\"a b&c-_.~\")");
let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
assert_eq!(out, "a%20b%26c-_.~");
}
#[test]
fn to_json_fn_direct_success_and_failure() {
let mut map = Map::new();
map.insert("a".into(), Dynamic::from(1_i64));
assert_eq!(to_json_fn(&Dynamic::from_map(map)).unwrap(), "{\"a\":1}");
let engine = Engine::new();
let fnptr: Dynamic = engine.eval("|| 1").unwrap();
assert!(to_json_fn(&fnptr).is_err());
}
#[test]
fn parse_json_fn_direct_success_and_failure() {
let d = parse_json_fn("{\"k\": \"v\"}").unwrap();
assert!(d.is_map());
assert!(parse_json_fn("not json").is_err());
}
#[test]
fn encode_uri_non_ascii() {
assert_eq!(percent_encode("€"), "%E2%82%AC");
}
#[test]
fn hex_digit_covers_both_arms() {
assert_eq!(hex_digit(9), '9');
assert_eq!(hex_digit(15), 'F');
assert_eq!(hex_digit(0), '0');
}
#[test]
fn headers_from_map_stringifies_values() {
let mut m = Map::new();
m.insert("n".into(), Dynamic::from(42_i64));
let headers = headers_from_map(&m);
assert_eq!(headers.get("n").map(String::as_str), Some("42"));
}
#[test]
fn html_to_text_full_pipeline() {
let html = "<html><head><style>.a{color:red}</style></head>\
<body><h1>Tit&le</h1><script>var x=1<2;</script>\
<p>Hello world 'quoted' — done.</p></body></html>";
let text = html_to_text(html);
assert!(text.contains("Tit&le"), "entity decoded: {text}");
assert!(
text.contains("Hello world 'quoted' \u{2014} done."),
"got: {text}"
);
assert!(!text.contains("color:red"), "style content dropped");
assert!(!text.contains("var x"), "script content dropped");
assert!(!text.contains('<'), "tags stripped");
}
#[test]
fn strip_element_handles_case_unclosed_and_utf8() {
assert_eq!(strip_element("a<SCRIPT>x</script>b", "script"), "ab");
assert_eq!(strip_element("keep<style>rest", "style"), "keep");
assert_eq!(strip_element("café < 3", "script"), "café < 3");
}
#[test]
fn strip_tags_edges() {
assert_eq!(strip_tags("<b>hi</b>").trim(), "hi");
assert_eq!(strip_tags("2 > 1").trim(), "2 > 1");
assert_eq!(strip_tags("ok <broken").trim(), "ok");
}
#[test]
fn decode_entities_named_numeric_and_unknown() {
assert_eq!(decode_entities("a&b"), "a&b");
assert_eq!(decode_entities("<>"'"), "<>\"'");
assert_eq!(decode_entities("x y"), "x y");
assert_eq!(decode_entities("—–…"), "\u{2014}–…");
assert_eq!(decode_entities("ABC"), "ABC");
assert_eq!(decode_entities("&bogus;"), "&bogus;");
assert_eq!(decode_entities("a & b"), "a & b");
assert_eq!(decode_entities("&#zz;"), "&#zz;"); assert_eq!(decode_entities("&#xZZ;"), "&#xZZ;"); assert_eq!(decode_entities("�"), "�"); assert_eq!(decode_entities("�"), "�"); assert_eq!(decode_entities("plain"), "plain");
}
#[test]
fn decode_entities_survives_multibyte_after_an_ampersand() {
assert_eq!(decode_entities("&日本語日本"), "&日本語日本");
assert_eq!(decode_entities("R&D 日本語です"), "R&D 日本語です");
assert_eq!(decode_entities("&🎉🎉🎉🎉"), "&🎉🎉🎉🎉");
assert_eq!(
decode_entities("&\u{2014}\u{2014}\u{2014}\u{2014}"),
"&\u{2014}\u{2014}\u{2014}\u{2014}"
);
assert_eq!(decode_entities("&日本語"), "&日本語");
assert_eq!(decode_entities("tail&"), "tail&");
assert_eq!(decode_entities("&abcdefghi🇸"), "&abcdefghi🇸");
}
#[test]
fn collapse_whitespace_runs_and_trims() {
assert_eq!(collapse_whitespace(" a \n\t b "), "a b");
assert_eq!(collapse_whitespace(""), "");
}
}