1use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24
25use rhai::{AST, Dynamic, Engine, EvalAltResult, Map, Position, Scope};
26use serde::Deserialize;
27
28use crate::{Error, Result};
29
30#[derive(Debug, Clone, PartialEq)]
32pub struct ParamSpec {
33 pub name: String,
35 pub ty: String,
38 pub required: bool,
40 pub description: String,
43 pub schema: Option<serde_json::Value>,
49}
50
51#[derive(Debug, Clone, PartialEq)]
53pub struct ScriptToolMeta {
54 pub name: String,
56 pub description: String,
58 pub params: Vec<ParamSpec>,
60 pub required_caps: Vec<String>,
64}
65
66impl ScriptToolMeta {
67 pub fn parameters_schema(&self) -> serde_json::Value {
71 let mut properties = serde_json::Map::new();
72 let mut required: Vec<serde_json::Value> = Vec::new();
73 for p in &self.params {
74 let property = match &p.schema {
79 Some(fragment) => fragment.clone(),
80 None => serde_json::json!({ "type": p.ty, "description": p.description }),
81 };
82 properties.insert(p.name.clone(), property);
83 if p.required {
84 required.push(serde_json::Value::String(p.name.clone()));
85 }
86 }
87 serde_json::json!({
88 "type": "object",
89 "properties": serde_json::Value::Object(properties),
90 "required": serde_json::Value::Array(required),
91 })
92 }
93}
94
95pub fn parse_annotations(src: &str) -> Result<ScriptToolMeta> {
109 let mut name: Option<String> = None;
110 let mut description = String::new();
111 let mut params: Vec<ParamSpec> = Vec::new();
112 let mut required_caps: Vec<String> = Vec::new();
113
114 for line in src.lines() {
115 let trimmed = line.trim();
116 let Some(rest) = trimmed.strip_prefix("//") else {
117 continue;
118 };
119 let rest = rest.trim();
120 let Some(directive) = rest.strip_prefix('@') else {
121 continue;
122 };
123 let (keyword, arg) = match directive.split_once(char::is_whitespace) {
125 Some((k, a)) => (k, a.trim()),
126 None => (directive, ""),
127 };
128 match keyword {
129 "tool" => {
130 if arg.is_empty() {
131 return Err(Error::ValidationFailed(
132 "@tool directive requires a tool name".to_string(),
133 ));
134 }
135 name = Some(arg.to_string());
136 }
137 "description" => description = arg.to_string(),
138 "param" => params.push(parse_param_directive(arg)?),
139 "requires" => required_caps.extend(
141 arg.split([' ', ',', '\t'])
142 .filter(|c| !c.is_empty())
143 .map(str::to_string),
144 ),
145 _ => {} }
147 }
148
149 let name = name.ok_or_else(|| {
150 Error::ValidationFailed("script tool is missing a `// @tool <name>` directive".to_string())
151 })?;
152 Ok(ScriptToolMeta {
153 name,
154 description,
155 params,
156 required_caps,
157 })
158}
159
160fn parse_param_directive(arg: &str) -> Result<ParamSpec> {
166 let mut it = arg.splitn(4, char::is_whitespace).map(str::trim);
167 let name = it.next().filter(|s| !s.is_empty());
168 let ty = it.next().filter(|s| !s.is_empty());
169 let requiredness = it.next().filter(|s| !s.is_empty());
170 let (name, ty, requiredness) = match (name, ty, requiredness) {
171 (Some(n), Some(t), Some(r)) => (n, t, r),
172 _ => {
173 return Err(Error::ValidationFailed(format!(
174 "@param requires `<name> <type> <required|optional>`, got: `{arg}`"
175 )));
176 }
177 };
178 let required = match requiredness {
179 "required" => true,
180 "optional" => false,
181 other => {
182 return Err(Error::ValidationFailed(format!(
183 "@param requiredness must be `required` or `optional`, got: `{other}`"
184 )));
185 }
186 };
187 let description = it
188 .next()
189 .map(|d| d.trim().trim_matches('"').to_string())
190 .unwrap_or_default();
191 Ok(ParamSpec {
192 name: name.to_string(),
193 ty: ty.to_string(),
194 required,
195 description,
196 schema: None,
199 })
200}
201
202#[derive(Debug, Deserialize)]
204struct ToolTomlDoc {
205 tool: ToolTomlTool,
206}
207
208#[derive(Debug, Deserialize)]
209struct ToolTomlTool {
210 name: String,
211 #[serde(default)]
212 description: String,
213 #[serde(default)]
214 params: Vec<ToolTomlParam>,
215 #[serde(default)]
217 requires: Vec<String>,
218}
219
220#[derive(Debug, Deserialize)]
221struct ToolTomlParam {
222 name: String,
223 #[serde(default, rename = "type")]
226 ty: String,
227 #[serde(default)]
228 required: bool,
229 #[serde(default)]
230 description: String,
231 #[serde(default)]
234 schema: Option<serde_json::Value>,
235}
236
237pub fn parse_tool_toml(src: &str) -> Result<ScriptToolMeta> {
240 let doc: ToolTomlDoc = toml::from_str(src)
241 .map_err(|e| Error::ValidationFailed(format!("invalid tool.toml: {e}")))?;
242 if doc.tool.name.trim().is_empty() {
243 return Err(Error::ValidationFailed(
244 "tool.toml `[tool] name` must not be empty".to_string(),
245 ));
246 }
247 let params = doc
248 .tool
249 .params
250 .into_iter()
251 .map(|p| ParamSpec {
252 name: p.name,
253 ty: p.ty,
254 required: p.required,
255 description: p.description,
256 schema: p.schema,
257 })
258 .collect();
259 Ok(ScriptToolMeta {
260 name: doc.tool.name,
261 description: doc.tool.description,
262 params,
263 required_caps: doc.tool.requires,
264 })
265}
266
267pub trait ScriptHost: Send + Sync {
275 fn http_get(
277 &self,
278 url: &str,
279 headers: BTreeMap<String, String>,
280 ) -> std::result::Result<String, String>;
281 fn http_post(
283 &self,
284 url: &str,
285 body: &str,
286 headers: BTreeMap<String, String>,
287 ) -> std::result::Result<String, String>;
288 fn shell(&self, command: &str) -> std::result::Result<String, String>;
290 fn read_file(&self, path: &str) -> std::result::Result<String, String>;
292 fn write_file(&self, path: &str, content: &str) -> std::result::Result<String, String>;
295 fn env_var(&self, name: &str) -> std::result::Result<String, String>;
297}
298
299#[derive(Clone, Debug)]
304pub struct ScriptTool {
305 pub meta: ScriptToolMeta,
307 pub ast: AST,
309 pub source_path: PathBuf,
311}
312
313#[derive(Debug, Clone)]
317pub struct SkippedTool {
318 pub path: PathBuf,
320 pub reason: String,
322}
323
324#[derive(Clone, Default)]
326pub struct ScriptToolSet {
327 tools: BTreeMap<String, ScriptTool>,
328}
329
330impl ScriptToolSet {
331 pub fn discover(dirs: &[PathBuf]) -> (Self, Vec<SkippedTool>) {
338 let mut tools: BTreeMap<String, ScriptTool> = BTreeMap::new();
339 let mut skipped: Vec<SkippedTool> = Vec::new();
340 let engine = Engine::new();
343 for dir in dirs {
344 let entries = match std::fs::read_dir(dir) {
345 Ok(e) => e,
346 Err(_) => continue, };
348 let mut paths: Vec<PathBuf> = entries
349 .filter_map(|e| e.ok().map(|e| e.path()))
350 .filter(|p| p.extension().is_some_and(|ext| ext == "rhai"))
351 .collect();
352 paths.sort();
353 for path in paths {
354 match compile_tool(&engine, &path) {
355 Ok(tool) => {
356 tools.entry(tool.meta.name.clone()).or_insert(tool);
358 }
359 Err(e) => skipped.push(SkippedTool {
360 path,
361 reason: e.to_string(),
362 }),
363 }
364 }
365 }
366 (Self { tools }, skipped)
367 }
368
369 pub fn contains(&self, name: &str) -> bool {
371 self.tools.contains_key(name)
372 }
373
374 pub fn get(&self, name: &str) -> Option<&ScriptTool> {
376 self.tools.get(name)
377 }
378
379 pub fn names(&self) -> Vec<String> {
381 self.tools.keys().cloned().collect()
382 }
383
384 pub fn metas(&self) -> Vec<ScriptToolMeta> {
386 self.tools.values().map(|t| t.meta.clone()).collect()
387 }
388
389 pub fn len(&self) -> usize {
391 self.tools.len()
392 }
393
394 pub fn is_empty(&self) -> bool {
396 self.tools.is_empty()
397 }
398}
399
400fn compile_tool(engine: &Engine, path: &Path) -> Result<ScriptTool> {
403 let src = std::fs::read_to_string(path)
404 .map_err(|e| Error::ValidationFailed(format!("read {}: {e}", path.display())))?;
405 let toml_path = path.with_extension("toml");
407 let meta = match std::fs::read_to_string(&toml_path) {
408 Ok(toml_src) => parse_tool_toml(&toml_src)?,
409 Err(_) => parse_annotations(&src)?,
410 };
411 let ast = engine
412 .compile(&src)
413 .map_err(|e| Error::CompilationFailed(format!("{}: {e}", path.display())))?;
414 Ok(ScriptTool {
415 meta,
416 ast,
417 source_path: path.to_path_buf(),
418 })
419}
420
421pub const SCRIPT_TOOL_MAX_OPERATIONS: u64 = 500_000;
427
428pub fn execute(tool: &ScriptTool, args: serde_json::Value, host: Arc<dyn ScriptHost>) -> String {
441 let engine = build_tool_engine(host);
442 let params = rhai::serde::to_dynamic(args).unwrap_or(Dynamic::UNIT);
446 let mut scope = Scope::new();
447 scope.push_dynamic("params", params);
448 match engine.eval_ast_with_scope::<Dynamic>(&mut scope, &tool.ast) {
449 Ok(value) => dynamic_to_result_string(value),
450 Err(e) => format!("[error] {}: {}", tool.meta.name, e),
451 }
452}
453
454fn dynamic_to_result_string(value: Dynamic) -> String {
458 if value.is_string() {
459 return value.into_string().unwrap_or_default();
461 }
462 if value.is_unit() {
463 return String::new();
464 }
465 match rhai::serde::from_dynamic::<serde_json::Value>(&value) {
466 Ok(json) => json.to_string(),
468 Err(e) => format!("[error] cannot serialize result: {e}"),
469 }
470}
471
472fn build_tool_engine(host: Arc<dyn ScriptHost>) -> Engine {
475 let mut engine = Engine::new();
476 crate::harden(&mut engine, SCRIPT_TOOL_MAX_OPERATIONS);
477 crate::functions::register_functions(&mut engine);
478 crate::types::register_types(&mut engine);
479 register_host_functions(&mut engine, host);
480 engine
481}
482
483type HostRes<T> = std::result::Result<T, Box<EvalAltResult>>;
485
486fn to_rhai(r: std::result::Result<String, String>) -> HostRes<String> {
489 r.map_err(|msg| Box::new(EvalAltResult::ErrorRuntime(msg.into(), Position::NONE)))
490}
491
492fn panic_to_rhai(name: &str, payload: Box<dyn std::any::Any + Send>) -> Box<EvalAltResult> {
495 let msg = leviath_core::panic_message(payload.as_ref());
496 tracing::warn!(
497 host_fn = name,
498 panic = %msg,
499 "a script-tool host function panicked; surfacing it as a script error (issue #109)"
500 );
501 Box::new(EvalAltResult::ErrorRuntime(
502 format!("{name} panicked: {msg}").into(),
503 Position::NONE,
504 ))
505}
506
507fn guard_str(name: &str, f: &mut dyn FnMut() -> HostRes<String>) -> HostRes<String> {
523 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
524 Ok(r) => r,
525 Err(payload) => Err(panic_to_rhai(name, payload)),
526 }
527}
528
529fn guard_dyn(name: &str, f: &mut dyn FnMut() -> HostRes<Dynamic>) -> HostRes<Dynamic> {
533 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
534 Ok(r) => r,
535 Err(payload) => Err(panic_to_rhai(name, payload)),
536 }
537}
538
539fn headers_from_map(map: &Map) -> BTreeMap<String, String> {
544 map.iter()
545 .map(|(k, v)| (k.to_string(), v.to_string()))
546 .collect()
547}
548
549fn register_host_functions(engine: &mut Engine, host: Arc<dyn ScriptHost>) {
558 let h = host.clone();
560 engine.register_fn("http_get", move |url: &str| {
561 guard_str("http_get", &mut || {
562 to_rhai(h.http_get(url, BTreeMap::new()))
563 })
564 });
565 let h = host.clone();
566 engine.register_fn("http_get", move |url: &str, headers: Map| {
567 guard_str("http_get", &mut || {
568 to_rhai(h.http_get(url, headers_from_map(&headers)))
569 })
570 });
571
572 let h = host.clone();
574 engine.register_fn("http_post", move |url: &str, body: &str| {
575 guard_str("http_post", &mut || {
576 to_rhai(h.http_post(url, body, BTreeMap::new()))
577 })
578 });
579 let h = host.clone();
580 engine.register_fn("http_post", move |url: &str, body: &str, headers: Map| {
581 guard_str("http_post", &mut || {
582 to_rhai(h.http_post(url, body, headers_from_map(&headers)))
583 })
584 });
585
586 let h = host.clone();
588 engine.register_fn("shell", move |cmd: &str| {
589 guard_str("shell", &mut || to_rhai(h.shell(cmd)))
590 });
591
592 let h = host.clone();
594 engine.register_fn("read_file", move |path: &str| {
595 guard_str("read_file", &mut || to_rhai(h.read_file(path)))
596 });
597
598 let h = host.clone();
600 engine.register_fn("write_file", move |path: &str, content: &str| {
601 guard_str("write_file", &mut || to_rhai(h.write_file(path, content)))
602 });
603
604 let h = host.clone();
606 engine.register_fn("env_var", move |name: &str| {
607 guard_str("env_var", &mut || to_rhai(h.env_var(name)))
608 });
609
610 engine.register_fn("parse_json", |s: &str| -> HostRes<Dynamic> {
615 guard_dyn("parse_json", &mut || parse_json_fn(s))
616 });
617 engine.register_fn("to_json", |v: Dynamic| -> HostRes<String> {
618 guard_str("to_json", &mut || to_json_fn(&v))
619 });
620 engine.register_fn("encode_uri", |s: &str| -> HostRes<String> {
621 guard_str("encode_uri", &mut || Ok(percent_encode(s)))
622 });
623 engine.register_fn("html_to_text", |s: &str| -> HostRes<String> {
624 guard_str("html_to_text", &mut || Ok(html_to_text(s)))
625 });
626}
627
628fn parse_json_fn(s: &str) -> HostRes<Dynamic> {
630 let value: serde_json::Value = serde_json::from_str(s).map_err(|e| {
631 Box::new(EvalAltResult::ErrorRuntime(
632 format!("parse_json: {e}").into(),
633 Position::NONE,
634 ))
635 })?;
636 rhai::serde::to_dynamic(value)
637}
638
639fn to_json_fn(v: &Dynamic) -> HostRes<String> {
643 let json: serde_json::Value = rhai::serde::from_dynamic(v)?;
644 Ok(json.to_string())
645}
646
647fn percent_encode(input: &str) -> String {
651 let mut out = String::with_capacity(input.len());
652 for &byte in input.as_bytes() {
653 match byte {
654 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
655 out.push(byte as char);
656 }
657 _ => {
658 out.push('%');
659 out.push(hex_digit(byte >> 4));
660 out.push(hex_digit(byte & 0x0f));
661 }
662 }
663 }
664 out
665}
666
667fn hex_digit(nibble: u8) -> char {
669 match nibble {
670 0..=9 => (b'0' + nibble) as char,
671 _ => (b'A' + (nibble - 10)) as char,
672 }
673}
674
675fn html_to_text(html: &str) -> String {
682 let without_raw = strip_raw_text_elements(html);
683 let without_tags = strip_tags(&without_raw);
684 let decoded = decode_entities(&without_tags);
685 collapse_whitespace(&decoded)
686}
687
688fn strip_raw_text_elements(html: &str) -> String {
692 let mut s = html.to_string();
693 for tag in ["script", "style"] {
694 s = strip_element(&s, tag);
695 }
696 s
697}
698
699#[expect(
700 clippy::string_slice,
701 reason = "`i` only ever advances by `ch.len_utf8()` and `rel` comes from `find`, so both are \
702 char boundaries; `to_ascii_lowercase` preserves byte lengths, so `lower` and `html` \
703 share them"
704)]
705fn strip_element(html: &str, tag: &str) -> String {
706 let lower = html.to_ascii_lowercase();
707 let open = format!("<{tag}");
708 let close = format!("</{tag}>");
709 let mut out = String::with_capacity(html.len());
710 let mut i = 0;
711 while i < html.len() {
712 if lower[i..].starts_with(&open) {
713 match lower[i..].find(&close) {
714 Some(rel) => {
715 i += rel + close.len();
716 continue;
717 }
718 None => break, }
720 }
721 let ch = html[i..].chars().next().unwrap();
722 out.push(ch);
723 i += ch.len_utf8();
724 }
725 out
726}
727
728fn strip_tags(html: &str) -> String {
731 let mut out = String::with_capacity(html.len());
732 let mut in_tag = false;
733 for c in html.chars() {
734 match c {
735 '<' => in_tag = true,
736 '>' if in_tag => {
737 in_tag = false;
738 out.push(' ');
739 }
740 _ if !in_tag => out.push(c),
741 _ => {}
742 }
743 }
744 out
745}
746
747const ENTITY_SCAN_CHARS: usize = 12;
750
751#[expect(
764 clippy::string_slice,
765 reason = "`amp` comes from `find` and `semi` from `char_indices`, so every index here is a \
766 char boundary; `after[1..]` is safe because `after` starts at the single-byte '&'"
767)]
768fn decode_entities(s: &str) -> String {
769 let mut out = String::with_capacity(s.len());
770 let mut rest = s;
771 while let Some(amp) = rest.find('&') {
772 out.push_str(&rest[..amp]);
773 let after = &rest[amp..];
774 let semi = after
775 .char_indices()
776 .take(ENTITY_SCAN_CHARS)
777 .find(|&(_, c)| c == ';')
778 .map(|(i, _)| i);
779 match semi {
780 Some(semi) => match decode_one_entity(&after[1..semi]) {
781 Some(ch) => {
782 out.push(ch);
783 rest = &after[semi + 1..];
784 }
785 None => {
786 out.push('&');
787 rest = &after[1..];
788 }
789 },
790 None => {
791 out.push('&');
792 rest = &after[1..];
793 }
794 }
795 }
796 out.push_str(rest);
797 out
798}
799
800fn decode_one_entity(e: &str) -> Option<char> {
801 match e {
802 "amp" => Some('&'),
803 "lt" => Some('<'),
804 "gt" => Some('>'),
805 "quot" => Some('"'),
806 "apos" => Some('\''),
807 "nbsp" => Some(' '),
808 "mdash" => Some('\u{2014}'),
809 "ndash" => Some('–'),
810 "hellip" => Some('…'),
811 _ => {
812 if let Some(hex) = e.strip_prefix("#x").or_else(|| e.strip_prefix("#X")) {
813 u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
814 } else if let Some(dec) = e.strip_prefix('#') {
815 dec.parse::<u32>().ok().and_then(char::from_u32)
816 } else {
817 None
818 }
819 }
820 }
821}
822
823fn collapse_whitespace(s: &str) -> String {
825 let mut out = String::with_capacity(s.len());
826 let mut prev_ws = false;
827 for c in s.chars() {
828 if c.is_whitespace() {
829 if !prev_ws {
830 out.push(' ');
831 prev_ws = true;
832 }
833 } else {
834 out.push(c);
835 prev_ws = false;
836 }
837 }
838 out.trim().to_string()
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844 use std::sync::{Mutex, PoisonError};
845
846 static PANIC_HOOK_LOCK: Mutex<()> = Mutex::new(());
851
852 type Headers = BTreeMap<String, String>;
855 type GetCall = Option<(String, Headers)>;
857 type PostCall = Option<(String, String, Headers)>;
859 type HostResult = std::result::Result<String, String>;
860
861 struct FakeHost {
862 get_response: Mutex<HostResult>,
863 post_response: Mutex<HostResult>,
864 shell_response: Mutex<HostResult>,
865 read_response: Mutex<HostResult>,
866 env_response: Mutex<HostResult>,
867 last_get: Mutex<GetCall>,
868 last_post: Mutex<PostCall>,
869 }
870
871 impl FakeHost {
872 fn arc() -> Arc<FakeHost> {
873 Arc::new(FakeHost {
874 get_response: Mutex::new(Ok("GET-OK".to_string())),
875 post_response: Mutex::new(Ok("POST-OK".to_string())),
876 shell_response: Mutex::new(Ok("SHELL-OK".to_string())),
877 read_response: Mutex::new(Ok("READ-OK".to_string())),
878 env_response: Mutex::new(Ok("ENV-OK".to_string())),
879 last_get: Mutex::new(None),
880 last_post: Mutex::new(None),
881 })
882 }
883 }
884
885 impl ScriptHost for FakeHost {
886 fn http_get(
887 &self,
888 url: &str,
889 headers: BTreeMap<String, String>,
890 ) -> std::result::Result<String, String> {
891 *self.last_get.lock().unwrap() = Some((url.to_string(), headers));
892 self.get_response.lock().unwrap().clone()
893 }
894 fn http_post(
895 &self,
896 url: &str,
897 body: &str,
898 headers: BTreeMap<String, String>,
899 ) -> std::result::Result<String, String> {
900 *self.last_post.lock().unwrap() = Some((url.to_string(), body.to_string(), headers));
901 self.post_response.lock().unwrap().clone()
902 }
903 fn shell(&self, _command: &str) -> std::result::Result<String, String> {
904 self.shell_response.lock().unwrap().clone()
905 }
906 fn read_file(&self, _path: &str) -> std::result::Result<String, String> {
907 self.read_response.lock().unwrap().clone()
908 }
909 fn write_file(&self, path: &str, content: &str) -> std::result::Result<String, String> {
910 Ok(format!("WROTE:{path}={content}"))
911 }
912 fn env_var(&self, _name: &str) -> std::result::Result<String, String> {
913 self.env_response.lock().unwrap().clone()
914 }
915 }
916
917 fn tool_from(src: &str) -> ScriptTool {
918 let engine = Engine::new();
919 let ast = engine.compile(src).expect("compile");
920 ScriptTool {
921 meta: parse_annotations(src).expect("annotations"),
922 ast,
923 source_path: PathBuf::from("mem.rhai"),
924 }
925 }
926
927 #[test]
930 fn annotations_full() {
931 let src = r#"
932// @tool web_search
933// @description Search the web
934// @param query string required "Search query"
935// @param count integer optional "How many"
93642
937"#;
938 let meta = parse_annotations(src).unwrap();
939 assert_eq!(meta.name, "web_search");
940 assert_eq!(meta.description, "Search the web");
941 assert_eq!(meta.params.len(), 2);
942 assert_eq!(
943 meta.params[0],
944 ParamSpec {
945 name: "query".into(),
946 ty: "string".into(),
947 required: true,
948 description: "Search query".into(),
949 schema: None,
950 }
951 );
952 assert!(!meta.params[1].required);
953 assert!(meta.required_caps.is_empty());
954 }
955
956 #[test]
957 fn annotations_requires_capabilities() {
958 let src = "// @tool t\n// @requires network, shell\n// @requires filesystem\n1";
960 let meta = parse_annotations(src).unwrap();
961 assert_eq!(meta.required_caps, ["network", "shell", "filesystem"]);
962 }
963
964 #[test]
965 fn annotations_missing_tool_name_errors() {
966 let err = parse_annotations("// @description no name\n1").unwrap_err();
967 assert!(err.to_string().contains("missing a `// @tool"));
968 }
969
970 #[test]
971 fn annotations_empty_tool_name_errors() {
972 let err = parse_annotations("// @tool \n1").unwrap_err();
973 assert!(err.to_string().contains("requires a tool name"));
974 }
975
976 #[test]
977 fn annotations_ignore_non_comment_and_non_directive_lines() {
978 let src = "let x = 1; // trailing\n// plain comment\n// @tool t\nx";
979 let meta = parse_annotations(src).unwrap();
980 assert_eq!(meta.name, "t");
981 assert!(meta.params.is_empty());
982 assert_eq!(meta.description, "");
983 }
984
985 #[test]
986 fn annotations_unknown_directive_ignored() {
987 let meta = parse_annotations("// @tool t\n// @bogus whatever\n1").unwrap();
988 assert_eq!(meta.name, "t");
989 }
990
991 #[test]
992 fn annotations_directive_with_no_arg_is_handled() {
993 let meta = parse_annotations("// @tool t\n// @description\n1").unwrap();
995 assert_eq!(meta.description, "");
996 }
997
998 #[test]
999 fn param_without_description_defaults_empty() {
1000 let meta = parse_annotations("// @tool t\n// @param x string required\n1").unwrap();
1001 assert_eq!(meta.params[0].description, "");
1002 assert!(meta.params[0].required);
1003 }
1004
1005 #[test]
1006 fn param_optional_flag() {
1007 let meta = parse_annotations("// @tool t\n// @param x string optional\n1").unwrap();
1008 assert!(!meta.params[0].required);
1009 }
1010
1011 #[test]
1012 fn param_too_few_tokens_errors() {
1013 let err = parse_annotations("// @tool t\n// @param x string\n1").unwrap_err();
1014 assert!(err.to_string().contains("requires `<name> <type>"));
1015 }
1016
1017 #[test]
1018 fn param_bad_requiredness_errors() {
1019 let err = parse_annotations("// @tool t\n// @param x string maybe\n1").unwrap_err();
1020 assert!(err.to_string().contains("must be `required` or `optional`"));
1021 }
1022
1023 #[test]
1026 fn tool_toml_full() {
1027 let src = r#"
1028[tool]
1029name = "fetch"
1030description = "Fetch a URL"
1031[[tool.params]]
1032name = "url"
1033type = "string"
1034required = true
1035description = "The URL"
1036"#;
1037 let meta = parse_tool_toml(src).unwrap();
1038 assert_eq!(meta.name, "fetch");
1039 assert_eq!(meta.description, "Fetch a URL");
1040 assert_eq!(meta.params.len(), 1);
1041 assert!(meta.params[0].required);
1042 assert_eq!(meta.params[0].ty, "string");
1043 }
1044
1045 #[test]
1046 fn tool_toml_requires() {
1047 let meta = parse_tool_toml("[tool]\nname = \"t\"\nrequires = [\"network\"]").unwrap();
1048 assert_eq!(meta.required_caps, ["network"]);
1049 }
1050
1051 #[test]
1052 fn tool_toml_defaults() {
1053 let meta = parse_tool_toml("[tool]\nname = \"t\"").unwrap();
1054 assert_eq!(meta.description, "");
1055 assert!(meta.params.is_empty());
1056 assert!(meta.required_caps.is_empty());
1057 }
1058
1059 #[test]
1060 fn tool_toml_raw_schema_fragment() {
1061 let src = r#"
1064[tool]
1065name = "export"
1066[[tool.params]]
1067name = "format"
1068required = true
1069schema = { type = "string", enum = ["json", "yaml"], description = "Output format" }
1070"#;
1071 let meta = parse_tool_toml(src).unwrap();
1072 assert_eq!(meta.params.len(), 1);
1073 assert!(meta.params[0].required);
1074 assert_eq!(meta.params[0].ty, "");
1076 let frag = meta.params[0].schema.as_ref().unwrap();
1077 assert_eq!(frag["enum"][0], "json");
1078 }
1079
1080 #[test]
1081 fn tool_toml_invalid_syntax_errors() {
1082 let err = parse_tool_toml("not = valid = toml").unwrap_err();
1083 assert!(err.to_string().contains("invalid tool.toml"));
1084 }
1085
1086 #[test]
1087 fn tool_toml_empty_name_errors() {
1088 let err = parse_tool_toml("[tool]\nname = \"\"").unwrap_err();
1089 assert!(err.to_string().contains("must not be empty"));
1090 }
1091
1092 #[test]
1095 fn parameters_schema_shape() {
1096 let meta = parse_annotations(
1097 "// @tool t\n// @param a string required \"A\"\n// @param b integer optional \"B\"\n1",
1098 )
1099 .unwrap();
1100 let schema = meta.parameters_schema();
1101 assert_eq!(schema["type"], "object");
1102 assert_eq!(schema["properties"]["a"]["type"], "string");
1103 assert_eq!(schema["properties"]["b"]["description"], "B");
1104 let required = schema["required"].as_array().unwrap();
1105 assert_eq!(required.len(), 1);
1106 assert_eq!(required[0], "a");
1107 }
1108
1109 #[test]
1110 fn parameters_schema_uses_raw_fragment_verbatim() {
1111 let meta = parse_tool_toml(
1115 "[tool]\nname = \"t\"\n[[tool.params]]\nname = \"fmt\"\nrequired = true\nschema = { type = \"string\", enum = [\"a\", \"b\"] }\n",
1116 )
1117 .unwrap();
1118 let schema = meta.parameters_schema();
1119 assert_eq!(schema["properties"]["fmt"]["type"], "string");
1120 assert_eq!(schema["properties"]["fmt"]["enum"][1], "b");
1121 assert!(schema["properties"]["fmt"].get("description").is_none());
1123 assert_eq!(schema["required"][0], "fmt");
1124 }
1125
1126 #[test]
1129 fn discover_compiles_and_collides() {
1130 let dir_a = tempfile::tempdir().unwrap();
1131 let dir_b = tempfile::tempdir().unwrap();
1132 std::fs::write(
1134 dir_a.path().join("dup.rhai"),
1135 "// @tool dup\n// @description from A\n1",
1136 )
1137 .unwrap();
1138 std::fs::write(
1139 dir_b.path().join("dup.rhai"),
1140 "// @tool dup\n// @description from B\n2",
1141 )
1142 .unwrap();
1143 std::fs::write(dir_b.path().join("solo.rhai"), "// @tool solo\n3").unwrap();
1144 std::fs::write(dir_b.path().join("note.txt"), "ignored").unwrap();
1146 std::fs::write(
1147 dir_b.path().join("broken.rhai"),
1148 "// no tool directive\nlet",
1149 )
1150 .unwrap();
1151
1152 let (set, skipped) = ScriptToolSet::discover(&[
1153 dir_a.path().to_path_buf(),
1154 dir_b.path().to_path_buf(),
1155 dir_a.path().join("does-not-exist"),
1156 ]);
1157 assert_eq!(set.len(), 2);
1158 assert!(!set.is_empty());
1159 assert!(set.contains("dup"));
1160 assert!(set.contains("solo"));
1161 assert_eq!(set.get("dup").unwrap().meta.description, "from A");
1162 let mut names = set.names();
1163 names.sort();
1164 assert_eq!(names, vec!["dup".to_string(), "solo".to_string()]);
1165 assert_eq!(set.metas().len(), 2);
1166 assert_eq!(skipped.len(), 1);
1168 assert!(skipped[0].path.ends_with("broken.rhai"));
1169 assert!(!skipped[0].reason.is_empty());
1170 }
1171
1172 #[test]
1173 fn discover_uses_tool_toml_override() {
1174 let dir = tempfile::tempdir().unwrap();
1175 std::fs::write(dir.path().join("t.rhai"), "// @tool ann\n1").unwrap();
1177 std::fs::write(
1178 dir.path().join("t.toml"),
1179 "[tool]\nname = \"override\"\ndescription = \"D\"",
1180 )
1181 .unwrap();
1182 let (set, skipped) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1183 assert!(set.contains("override"));
1184 assert!(!set.contains("ann"));
1185 assert!(skipped.is_empty());
1186 }
1187
1188 #[test]
1189 fn discover_skips_invalid_tool_toml() {
1190 let dir = tempfile::tempdir().unwrap();
1191 std::fs::write(dir.path().join("t.rhai"), "// @tool t\n1").unwrap();
1194 std::fs::write(dir.path().join("t.toml"), "name = broken").unwrap();
1195 let (set, skipped) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1196 assert!(set.is_empty());
1197 assert_eq!(skipped.len(), 1);
1198 assert!(skipped[0].reason.contains("tool.toml"));
1199 }
1200
1201 #[test]
1202 fn discover_skips_uncompilable_but_valid_annotation() {
1203 let dir = tempfile::tempdir().unwrap();
1204 std::fs::write(dir.path().join("t.rhai"), "// @tool t\nlet x = ;").unwrap();
1206 let (set, _) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1207 assert!(set.is_empty());
1208 }
1209
1210 #[test]
1211 fn default_set_is_empty() {
1212 let set = ScriptToolSet::default();
1213 assert!(set.is_empty());
1214 assert!(set.get("x").is_none());
1215 }
1216
1217 #[test]
1220 fn execute_returns_string_verbatim() {
1221 let tool = tool_from("// @tool t\n\"hello \" + params.name");
1222 let out = execute(&tool, serde_json::json!({"name": "world"}), FakeHost::arc());
1223 assert_eq!(out, "hello world");
1224 }
1225
1226 #[test]
1227 fn execute_serializes_non_string_result() {
1228 let tool = tool_from("// @tool t\n[1, 2, 3]");
1229 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1230 assert_eq!(out, "[1,2,3]");
1231 }
1232
1233 #[test]
1234 fn execute_unserializable_result_errors() {
1235 let tool = tool_from("// @tool t\n|| 1");
1238 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1239 assert!(out.contains("cannot serialize result"), "got: {out}");
1240 }
1241
1242 #[test]
1243 fn execute_unit_result_is_empty() {
1244 let tool = tool_from("// @tool t\nlet x = 1;");
1245 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1246 assert_eq!(out, "");
1247 }
1248
1249 #[test]
1250 fn execute_html_to_text_host_fn_via_script() {
1251 let tool = tool_from("// @tool t\nhtml_to_text(\"<p>Hi&<b>bye</b></p>\")");
1254 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1255 assert_eq!(out, "Hi& bye");
1256 }
1257
1258 #[test]
1259 fn execute_missing_optional_param_reads_as_unit() {
1260 let tool = tool_from("// @tool t\nif params.count == () { \"default\" } else { \"set\" }");
1262 let out = execute(&tool, serde_json::json!({"query": "x"}), FakeHost::arc());
1263 assert_eq!(out, "default");
1264 }
1265
1266 #[test]
1267 fn execute_script_error_is_prefixed() {
1268 let tool = tool_from("// @tool t\nthrow \"boom\"");
1269 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1270 assert!(out.starts_with("[error] t:"), "got: {out}");
1271 assert!(out.contains("boom"));
1272 }
1273
1274 enum PanicPayload {
1276 Formatted(&'static str),
1278 Literal,
1280 NonString,
1283 }
1284
1285 struct PanickingHost {
1289 payload: PanicPayload,
1290 }
1291
1292 impl PanickingHost {
1293 fn do_panic(&self) -> ! {
1294 match &self.payload {
1295 PanicPayload::Formatted(msg) => panic!("{}", msg),
1296 PanicPayload::Literal => panic!("literal str panic"),
1297 PanicPayload::NonString => std::panic::panic_any(42_i32),
1298 }
1299 }
1300 }
1301
1302 impl ScriptHost for PanickingHost {
1303 fn http_get(
1304 &self,
1305 _u: &str,
1306 _h: BTreeMap<String, String>,
1307 ) -> std::result::Result<String, String> {
1308 self.do_panic();
1309 }
1310 fn http_post(
1311 &self,
1312 _u: &str,
1313 _b: &str,
1314 _h: BTreeMap<String, String>,
1315 ) -> std::result::Result<String, String> {
1316 self.do_panic();
1317 }
1318 fn shell(&self, _c: &str) -> std::result::Result<String, String> {
1319 self.do_panic();
1320 }
1321 fn read_file(&self, _p: &str) -> std::result::Result<String, String> {
1322 self.do_panic();
1323 }
1324 fn write_file(&self, _p: &str, _c: &str) -> std::result::Result<String, String> {
1325 self.do_panic();
1326 }
1327 fn env_var(&self, _n: &str) -> std::result::Result<String, String> {
1328 self.do_panic();
1329 }
1330 }
1331
1332 fn execute_with_panicking_host(payload: PanicPayload, script: &str) -> String {
1336 let host: Arc<dyn ScriptHost> = Arc::new(PanickingHost { payload });
1337 let tool = tool_from(script);
1338 let _guard = PANIC_HOOK_LOCK
1339 .lock()
1340 .unwrap_or_else(PoisonError::into_inner);
1341 let prev = std::panic::take_hook();
1342 std::panic::set_hook(Box::new(|_| {}));
1343 let out = execute(&tool, serde_json::json!({}), host);
1344 std::panic::set_hook(prev);
1345 out
1346 }
1347
1348 fn assert_guarded_panic(out: &str, tool_name: &str, host_fn: &str, detail: &str) {
1350 assert!(
1351 out.starts_with(&format!("[error] {tool_name}:")),
1352 "got: {out}"
1353 );
1354 assert!(out.contains(&format!("{host_fn} panicked")), "got: {out}");
1355 assert!(out.contains(detail), "got: {out}");
1356 }
1357
1358 #[test]
1359 fn every_host_fn_panic_becomes_a_script_error() {
1360 for (host_fn, tool_name, script) in [
1366 ("http_get", "t", "// @tool t\nhttp_get(\"http://x\")"),
1367 (
1368 "http_get",
1369 "t",
1370 "// @tool t\nhttp_get(\"http://x\", #{ \"A\": \"b\" })",
1371 ),
1372 (
1373 "http_post",
1374 "t",
1375 "// @tool t\nhttp_post(\"http://x\", \"b\")",
1376 ),
1377 (
1378 "http_post",
1379 "t",
1380 "// @tool t\nhttp_post(\"http://x\", \"b\", #{ \"A\": \"b\" })",
1381 ),
1382 ("shell", "sh", "// @tool sh\nshell(\"ls\")"),
1383 ("read_file", "rf", "// @tool rf\nread_file(\"x.txt\")"),
1384 (
1385 "write_file",
1386 "wf",
1387 "// @tool wf\nwrite_file(\"out.txt\", \"data\")",
1388 ),
1389 ("env_var", "ev", "// @tool ev\nenv_var(\"HOME\")"),
1390 ] {
1391 let out =
1392 execute_with_panicking_host(PanicPayload::Formatted("TLS init failed"), script);
1393 assert_guarded_panic(&out, tool_name, host_fn, "TLS init failed");
1394 }
1395 }
1396
1397 #[test]
1398 fn guarded_panic_renders_str_and_non_string_payloads() {
1399 let out = execute_with_panicking_host(
1400 PanicPayload::Literal,
1401 "// @tool t\nhttp_get(\"http://x\")",
1402 );
1403 assert_guarded_panic(&out, "t", "http_get", "literal str panic");
1404
1405 let out = execute_with_panicking_host(
1406 PanicPayload::NonString,
1407 "// @tool t\nhttp_get(\"http://x\")",
1408 );
1409 assert_guarded_panic(&out, "t", "http_get", "unknown panic");
1410 }
1411
1412 #[test]
1413 fn guards_pass_through_success_and_convert_panics() {
1414 let _guard = PANIC_HOOK_LOCK
1418 .lock()
1419 .unwrap_or_else(PoisonError::into_inner);
1420 assert_eq!(
1421 guard_str("ok_str", &mut || Ok("value".to_string())).unwrap(),
1422 "value"
1423 );
1424 assert!(
1425 guard_dyn("ok_dyn", &mut || Ok(Dynamic::from(7_i64)))
1426 .unwrap()
1427 .is_int()
1428 );
1429
1430 let prev = std::panic::take_hook();
1431 std::panic::set_hook(Box::new(|_| {}));
1432 let str_err = guard_str("boom_str", &mut || panic!("string arm")).unwrap_err();
1433 let dyn_err = guard_dyn("boom_dyn", &mut || panic!("dynamic arm")).unwrap_err();
1434 std::panic::set_hook(prev);
1435 assert!(
1436 str_err
1437 .to_string()
1438 .contains("boom_str panicked: string arm")
1439 );
1440 assert!(
1441 dyn_err
1442 .to_string()
1443 .contains("boom_dyn panicked: dynamic arm")
1444 );
1445 }
1446
1447 #[test]
1448 fn execute_scalar_args_run() {
1449 let tool = tool_from("// @tool t\n\"ok\"");
1452 let out = execute(&tool, serde_json::json!(5), FakeHost::arc());
1453 assert_eq!(out, "ok");
1454 }
1455
1456 #[test]
1457 fn execute_print_and_debug_are_noop() {
1458 let tool = tool_from("// @tool t\nprint(\"p\"); debug(\"d\"); \"done\"");
1460 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1461 assert_eq!(out, "done");
1462 }
1463
1464 #[test]
1465 fn compile_tool_read_error() {
1466 let engine = Engine::new();
1467 let err = compile_tool(&engine, Path::new("/no/such/dir/tool.rhai")).unwrap_err();
1468 assert!(err.to_string().contains("read"));
1469 }
1470
1471 #[test]
1472 fn to_json_on_unserializable_value_errors() {
1473 let tool = tool_from("// @tool t\nlet f = || 1; to_json(f)");
1476 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1477 assert!(out.starts_with("[error]"), "got: {out}");
1478 }
1479
1480 #[test]
1483 fn http_get_no_headers() {
1484 let host = FakeHost::arc();
1485 let tool = tool_from("// @tool t\nhttp_get(\"http://x\")");
1486 let out = execute(&tool, serde_json::json!({}), host.clone());
1487 assert_eq!(out, "GET-OK");
1488 let (url, headers) = host.last_get.lock().unwrap().clone().unwrap();
1489 assert_eq!(url, "http://x");
1490 assert!(headers.is_empty());
1491 }
1492
1493 #[test]
1494 fn http_get_with_headers() {
1495 let host = FakeHost::arc();
1496 let tool = tool_from("// @tool t\nhttp_get(\"http://x\", #{ \"K\": \"V\" })");
1497 let out = execute(&tool, serde_json::json!({}), host.clone());
1498 assert_eq!(out, "GET-OK");
1499 let (_, headers) = host.last_get.lock().unwrap().clone().unwrap();
1500 assert_eq!(headers.get("K").map(String::as_str), Some("V"));
1501 }
1502
1503 #[test]
1504 fn http_get_error_surfaces() {
1505 let host = FakeHost::arc();
1506 *host.get_response.lock().unwrap() = Err("[denied] http_get".to_string());
1507 let tool = tool_from("// @tool t\nhttp_get(\"http://x\")");
1508 let out = execute(&tool, serde_json::json!({}), host);
1509 assert!(out.contains("[denied] http_get"));
1510 }
1511
1512 #[test]
1513 fn http_post_variants() {
1514 let host = FakeHost::arc();
1515 let tool = tool_from("// @tool t\nhttp_post(\"http://x\", \"body\")");
1516 assert_eq!(
1517 execute(&tool, serde_json::json!({}), host.clone()),
1518 "POST-OK"
1519 );
1520 let (_, body, headers) = host.last_post.lock().unwrap().clone().unwrap();
1521 assert_eq!(body, "body");
1522 assert!(headers.is_empty());
1523
1524 let tool2 = tool_from("// @tool t\nhttp_post(\"http://x\", \"b\", #{ \"H\": \"1\" })");
1525 assert_eq!(
1526 execute(&tool2, serde_json::json!({}), host.clone()),
1527 "POST-OK"
1528 );
1529 let (_, _, headers2) = host.last_post.lock().unwrap().clone().unwrap();
1530 assert_eq!(headers2.get("H").map(String::as_str), Some("1"));
1531 }
1532
1533 #[test]
1534 fn shell_read_env_hosts() {
1535 let host = FakeHost::arc();
1536 assert_eq!(
1537 execute(
1538 &tool_from("// @tool t\nshell(\"ls\")"),
1539 serde_json::json!({}),
1540 host.clone()
1541 ),
1542 "SHELL-OK"
1543 );
1544 assert_eq!(
1545 execute(
1546 &tool_from("// @tool t\nread_file(\"a\")"),
1547 serde_json::json!({}),
1548 host.clone()
1549 ),
1550 "READ-OK"
1551 );
1552 assert_eq!(
1553 execute(
1554 &tool_from("// @tool t\nenv_var(\"A\")"),
1555 serde_json::json!({}),
1556 host.clone()
1557 ),
1558 "ENV-OK"
1559 );
1560 assert_eq!(
1561 execute(
1562 &tool_from("// @tool t\nwrite_file(\"out.txt\", \"body\")"),
1563 serde_json::json!({}),
1564 host
1565 ),
1566 "WROTE:out.txt=body"
1567 );
1568 }
1569
1570 #[test]
1573 fn parse_and_to_json_roundtrip() {
1574 let host = FakeHost::arc();
1575 let tool = tool_from("// @tool t\nlet d = parse_json(\"{\\\"a\\\": 1}\"); to_json(d)");
1576 let out = execute(&tool, serde_json::json!({}), host);
1577 assert_eq!(out, "{\"a\":1}");
1578 }
1579
1580 #[test]
1581 fn parse_json_invalid_errors() {
1582 let tool = tool_from("// @tool t\nparse_json(\"not json\")");
1583 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1584 assert!(out.contains("parse_json"));
1585 }
1586
1587 #[test]
1588 fn parse_json_result_used_as_value() {
1589 let tool = tool_from("// @tool t\nlet d = parse_json(\"{\\\"k\\\": \\\"v\\\"}\"); d.k");
1591 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1592 assert_eq!(out, "v");
1593 }
1594
1595 #[test]
1596 fn encode_uri_encodes_reserved_and_passes_unreserved() {
1597 let tool = tool_from("// @tool t\nencode_uri(\"a b&c-_.~\")");
1598 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1599 assert_eq!(out, "a%20b%26c-_.~");
1600 }
1601
1602 #[test]
1603 fn to_json_fn_direct_success_and_failure() {
1604 let mut map = Map::new();
1607 map.insert("a".into(), Dynamic::from(1_i64));
1608 assert_eq!(to_json_fn(&Dynamic::from_map(map)).unwrap(), "{\"a\":1}");
1609 let engine = Engine::new();
1611 let fnptr: Dynamic = engine.eval("|| 1").unwrap();
1612 assert!(to_json_fn(&fnptr).is_err());
1613 }
1614
1615 #[test]
1616 fn parse_json_fn_direct_success_and_failure() {
1617 let d = parse_json_fn("{\"k\": \"v\"}").unwrap();
1618 assert!(d.is_map());
1619 assert!(parse_json_fn("not json").is_err());
1620 }
1621
1622 #[test]
1623 fn encode_uri_non_ascii() {
1624 assert_eq!(percent_encode("€"), "%E2%82%AC");
1626 }
1627
1628 #[test]
1629 fn hex_digit_covers_both_arms() {
1630 assert_eq!(hex_digit(9), '9');
1631 assert_eq!(hex_digit(15), 'F');
1632 assert_eq!(hex_digit(0), '0');
1633 }
1634
1635 #[test]
1636 fn headers_from_map_stringifies_values() {
1637 let mut m = Map::new();
1638 m.insert("n".into(), Dynamic::from(42_i64));
1639 let headers = headers_from_map(&m);
1640 assert_eq!(headers.get("n").map(String::as_str), Some("42"));
1641 }
1642
1643 #[test]
1644 fn html_to_text_full_pipeline() {
1645 let html = "<html><head><style>.a{color:red}</style></head>\
1646 <body><h1>Tit&le</h1><script>var x=1<2;</script>\
1647 <p>Hello world 'quoted' — done.</p></body></html>";
1648 let text = html_to_text(html);
1649 assert!(text.contains("Tit&le"), "entity decoded: {text}");
1650 assert!(
1651 text.contains("Hello world 'quoted' \u{2014} done."),
1652 "got: {text}"
1653 );
1654 assert!(!text.contains("color:red"), "style content dropped");
1655 assert!(!text.contains("var x"), "script content dropped");
1656 assert!(!text.contains('<'), "tags stripped");
1657 }
1658
1659 #[test]
1660 fn strip_element_handles_case_unclosed_and_utf8() {
1661 assert_eq!(strip_element("a<SCRIPT>x</script>b", "script"), "ab");
1663 assert_eq!(strip_element("keep<style>rest", "style"), "keep");
1665 assert_eq!(strip_element("café < 3", "script"), "café < 3");
1667 }
1668
1669 #[test]
1670 fn strip_tags_edges() {
1671 assert_eq!(strip_tags("<b>hi</b>").trim(), "hi");
1672 assert_eq!(strip_tags("2 > 1").trim(), "2 > 1");
1674 assert_eq!(strip_tags("ok <broken").trim(), "ok");
1676 }
1677
1678 #[test]
1679 fn decode_entities_named_numeric_and_unknown() {
1680 assert_eq!(decode_entities("a&b"), "a&b");
1681 assert_eq!(decode_entities("<>"'"), "<>\"'");
1682 assert_eq!(decode_entities("x y"), "x y");
1683 assert_eq!(decode_entities("—–…"), "\u{2014}–…");
1684 assert_eq!(decode_entities("ABC"), "ABC");
1685 assert_eq!(decode_entities("&bogus;"), "&bogus;");
1687 assert_eq!(decode_entities("a & b"), "a & b");
1689 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");
1696 }
1697
1698 #[test]
1699 fn decode_entities_survives_multibyte_after_an_ampersand() {
1700 assert_eq!(decode_entities("&日本語日本"), "&日本語日本");
1706 assert_eq!(decode_entities("R&D 日本語です"), "R&D 日本語です");
1707 assert_eq!(decode_entities("&🎉🎉🎉🎉"), "&🎉🎉🎉🎉");
1708 assert_eq!(
1709 decode_entities("&\u{2014}\u{2014}\u{2014}\u{2014}"),
1710 "&\u{2014}\u{2014}\u{2014}\u{2014}"
1711 );
1712 assert_eq!(decode_entities("&日本語"), "&日本語");
1714 assert_eq!(decode_entities("tail&"), "tail&");
1716 assert_eq!(decode_entities("&abcdefghi🇸"), "&abcdefghi🇸");
1721 }
1722
1723 #[test]
1724 fn collapse_whitespace_runs_and_trims() {
1725 assert_eq!(collapse_whitespace(" a \n\t b "), "a b");
1726 assert_eq!(collapse_whitespace(""), "");
1727 }
1728}