1use 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#[derive(Debug, Clone, PartialEq)]
33pub struct ParamSpec {
34 pub name: String,
36 pub ty: String,
39 pub required: bool,
41 pub description: String,
44 pub schema: Option<serde_json::Value>,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54pub struct ScriptToolMeta {
55 pub name: String,
57 pub description: String,
59 pub params: Vec<ParamSpec>,
61 pub required_caps: Vec<String>,
65}
66
67impl ScriptToolMeta {
68 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 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
96pub 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 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" => required_caps.extend(
142 arg.split([' ', ',', '\t'])
143 .filter(|c| !c.is_empty())
144 .map(str::to_string),
145 ),
146 _ => {} }
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
161fn 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 schema: None,
200 })
201}
202
203#[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 #[serde(default)]
218 requires: Vec<String>,
219}
220
221#[derive(Debug, Deserialize)]
222struct ToolTomlParam {
223 name: String,
224 #[serde(default, rename = "type")]
227 ty: String,
228 #[serde(default)]
229 required: bool,
230 #[serde(default)]
231 description: String,
232 #[serde(default)]
235 schema: Option<serde_json::Value>,
236}
237
238pub 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
268pub trait ScriptHost: Send + Sync {
276 fn http_get(
278 &self,
279 url: &str,
280 headers: BTreeMap<String, String>,
281 ) -> std::result::Result<String, String>;
282 fn http_post(
284 &self,
285 url: &str,
286 body: &str,
287 headers: BTreeMap<String, String>,
288 ) -> std::result::Result<String, String>;
289 fn shell(&self, command: &str) -> std::result::Result<String, String>;
291 fn read_file(&self, path: &str) -> std::result::Result<String, String>;
293 fn write_file(&self, path: &str, content: &str) -> std::result::Result<String, String>;
296 fn env_var(&self, name: &str) -> std::result::Result<String, String>;
298}
299
300#[derive(Clone, Debug)]
305pub struct ScriptTool {
306 pub meta: ScriptToolMeta,
308 pub ast: AST,
310 pub source_path: PathBuf,
312}
313
314#[derive(Debug, Clone)]
318pub struct SkippedTool {
319 pub path: PathBuf,
321 pub reason: String,
323}
324
325#[derive(Clone, Default)]
327pub struct ScriptToolSet {
328 tools: BTreeMap<String, ScriptTool>,
329}
330
331impl ScriptToolSet {
332 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 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, };
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 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 pub fn contains(&self, name: &str) -> bool {
372 self.tools.contains_key(name)
373 }
374
375 pub fn get(&self, name: &str) -> Option<&ScriptTool> {
377 self.tools.get(name)
378 }
379
380 pub fn names(&self) -> Vec<String> {
382 self.tools.keys().cloned().collect()
383 }
384
385 pub fn metas(&self) -> Vec<ScriptToolMeta> {
387 self.tools.values().map(|t| t.meta.clone()).collect()
388 }
389
390 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 pub fn len(&self) -> usize {
407 self.tools.len()
408 }
409
410 pub fn is_empty(&self) -> bool {
412 self.tools.is_empty()
413 }
414}
415
416pub 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
435fn 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 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
456pub const SCRIPT_TOOL_MAX_OPERATIONS: u64 = 500_000;
462
463pub fn execute(tool: &ScriptTool, args: serde_json::Value, host: Arc<dyn ScriptHost>) -> String {
476 let engine = build_tool_engine(host);
477 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
489fn dynamic_to_result_string(value: Dynamic) -> String {
493 if value.is_string() {
494 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 Ok(json) => json.to_string(),
503 Err(e) => format!("[error] cannot serialize result: {e}"),
504 }
505}
506
507fn 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
518type HostRes<T> = std::result::Result<T, Box<EvalAltResult>>;
520
521fn 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
527fn 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
542fn 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
564fn 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
574fn 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
584fn register_host_functions(engine: &mut Engine, host: Arc<dyn ScriptHost>) {
593 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 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 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 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 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 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 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
663fn 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
674fn 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
682pub 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
707fn 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
715fn 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
728fn 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 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, }
761 }
762 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
771fn 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
790const ENTITY_SCAN_CHARS: usize = 12;
793
794fn 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 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
863fn 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 static PANIC_HOOK_LOCK: Mutex<()> = Mutex::new(());
891
892 type Headers = BTreeMap<String, String>;
895 type GetCall = Option<(String, Headers)>;
897 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 #[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 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 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 #[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 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 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 #[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 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 assert!(schema["properties"]["fmt"].get("description").is_none());
1163 assert_eq!(schema["required"][0], "fmt");
1164 }
1165
1166 #[test]
1169 fn discover_compiles_and_collides() {
1170 let dir_a = tempfile::tempdir().unwrap();
1171 let dir_b = tempfile::tempdir().unwrap();
1172 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 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 assert_eq!(skipped.len(), 1);
1208 assert!(skipped[0].path.ends_with("broken.rhai"));
1209 assert!(!skipped[0].reason.is_empty());
1210 }
1211
1212 #[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 #[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 #[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 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 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 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 #[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 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 let tool = tool_from("// @tool t\nhtml_to_text(\"<p>Hi&<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 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 enum PanicPayload {
1358 Formatted(&'static str),
1360 Literal,
1362 NonString,
1365 }
1366
1367 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 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 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 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 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 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 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 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 #[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 #[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 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 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 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 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&le</h1><script>var x=1<2;</script>\
1729 <p>Hello world 'quoted' — 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 assert_eq!(strip_element("a<SCRIPT>x</script>b", "script"), "ab");
1745 assert_eq!(strip_element("keep<style>rest", "style"), "keep");
1747 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 assert_eq!(strip_tags("2 > 1").trim(), "2 > 1");
1756 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&b"), "a&b");
1763 assert_eq!(decode_entities("<>"'"), "<>\"'");
1764 assert_eq!(decode_entities("x y"), "x y");
1765 assert_eq!(decode_entities("—–…"), "\u{2014}–…");
1766 assert_eq!(decode_entities("ABC"), "ABC");
1767 assert_eq!(decode_entities("&bogus;"), "&bogus;");
1769 assert_eq!(decode_entities("a & b"), "a & b");
1771 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");
1778 }
1779
1780 #[test]
1781 fn decode_entities_survives_multibyte_after_an_ampersand() {
1782 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 assert_eq!(decode_entities("&日本語"), "&日本語");
1796 assert_eq!(decode_entities("tail&"), "tail&");
1798 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}