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 len(&self) -> usize {
392 self.tools.len()
393 }
394
395 pub fn is_empty(&self) -> bool {
397 self.tools.is_empty()
398 }
399}
400
401fn compile_tool(engine: &Engine, path: &Path) -> Result<ScriptTool> {
404 let src = std::fs::read_to_string(path)
405 .map_err(|e| Error::ValidationFailed(format!("read {}: {e}", path.display())))?;
406 let toml_path = path.with_extension("toml");
408 let meta = match std::fs::read_to_string(&toml_path) {
409 Ok(toml_src) => parse_tool_toml(&toml_src)?,
410 Err(_) => parse_annotations(&src)?,
411 };
412 let ast = engine
413 .compile(&src)
414 .map_err(|e| Error::CompilationFailed(format!("{}: {e}", path.display())))?;
415 Ok(ScriptTool {
416 meta,
417 ast,
418 source_path: path.to_path_buf(),
419 })
420}
421
422pub const SCRIPT_TOOL_MAX_OPERATIONS: u64 = 500_000;
428
429pub fn execute(tool: &ScriptTool, args: serde_json::Value, host: Arc<dyn ScriptHost>) -> String {
442 let engine = build_tool_engine(host);
443 let params = rhai::serde::to_dynamic(args).unwrap_or(Dynamic::UNIT);
447 let mut scope = Scope::new();
448 scope.push_dynamic("params", params);
449 match engine.eval_ast_with_scope::<Dynamic>(&mut scope, &tool.ast) {
450 Ok(value) => dynamic_to_result_string(value),
451 Err(e) => format!("[error] {}: {}", tool.meta.name, e),
452 }
453}
454
455fn dynamic_to_result_string(value: Dynamic) -> String {
459 if value.is_string() {
460 return value.into_string().unwrap_or_default();
462 }
463 if value.is_unit() {
464 return String::new();
465 }
466 match rhai::serde::from_dynamic::<serde_json::Value>(&value) {
467 Ok(json) => json.to_string(),
469 Err(e) => format!("[error] cannot serialize result: {e}"),
470 }
471}
472
473fn build_tool_engine(host: Arc<dyn ScriptHost>) -> Engine {
476 let mut engine = Engine::new();
477 crate::harden(&mut engine, SCRIPT_TOOL_MAX_OPERATIONS);
478 crate::functions::register_functions(&mut engine);
479 crate::types::register_types(&mut engine);
480 register_host_functions(&mut engine, host);
481 engine
482}
483
484type HostRes<T> = std::result::Result<T, Box<EvalAltResult>>;
486
487fn to_rhai(r: std::result::Result<String, String>) -> HostRes<String> {
490 r.map_err(|msg| Box::new(EvalAltResult::ErrorRuntime(msg.into(), Position::NONE)))
491}
492
493fn panic_to_rhai(name: &str, payload: Box<dyn std::any::Any + Send>) -> Box<EvalAltResult> {
496 let msg = leviath_core::panic_message(payload.as_ref());
497 tracing::warn!(
498 host_fn = name,
499 panic = %msg,
500 "a script-tool host function panicked; surfacing it as a script error (issue #109)"
501 );
502 Box::new(EvalAltResult::ErrorRuntime(
503 format!("{name} panicked: {msg}").into(),
504 Position::NONE,
505 ))
506}
507
508fn guard_str(name: &str, f: &mut dyn FnMut() -> HostRes<String>) -> HostRes<String> {
524 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
525 Ok(r) => r,
526 Err(payload) => Err(panic_to_rhai(name, payload)),
527 }
528}
529
530fn guard_dyn(name: &str, f: &mut dyn FnMut() -> HostRes<Dynamic>) -> HostRes<Dynamic> {
534 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
535 Ok(r) => r,
536 Err(payload) => Err(panic_to_rhai(name, payload)),
537 }
538}
539
540fn headers_from_map(map: &Map) -> BTreeMap<String, String> {
545 map.iter()
546 .map(|(k, v)| (k.to_string(), v.to_string()))
547 .collect()
548}
549
550fn register_host_functions(engine: &mut Engine, host: Arc<dyn ScriptHost>) {
559 let h = host.clone();
561 engine.register_fn("http_get", move |url: &str| {
562 guard_str("http_get", &mut || {
563 to_rhai(h.http_get(url, BTreeMap::new()))
564 })
565 });
566 let h = host.clone();
567 engine.register_fn("http_get", move |url: &str, headers: Map| {
568 guard_str("http_get", &mut || {
569 to_rhai(h.http_get(url, headers_from_map(&headers)))
570 })
571 });
572
573 let h = host.clone();
575 engine.register_fn("http_post", move |url: &str, body: &str| {
576 guard_str("http_post", &mut || {
577 to_rhai(h.http_post(url, body, BTreeMap::new()))
578 })
579 });
580 let h = host.clone();
581 engine.register_fn("http_post", move |url: &str, body: &str, headers: Map| {
582 guard_str("http_post", &mut || {
583 to_rhai(h.http_post(url, body, headers_from_map(&headers)))
584 })
585 });
586
587 let h = host.clone();
589 engine.register_fn("shell", move |cmd: &str| {
590 guard_str("shell", &mut || to_rhai(h.shell(cmd)))
591 });
592
593 let h = host.clone();
595 engine.register_fn("read_file", move |path: &str| {
596 guard_str("read_file", &mut || to_rhai(h.read_file(path)))
597 });
598
599 let h = host.clone();
601 engine.register_fn("write_file", move |path: &str, content: &str| {
602 guard_str("write_file", &mut || to_rhai(h.write_file(path, content)))
603 });
604
605 let h = host.clone();
607 engine.register_fn("env_var", move |name: &str| {
608 guard_str("env_var", &mut || to_rhai(h.env_var(name)))
609 });
610
611 engine.register_fn("parse_json", |s: &str| -> HostRes<Dynamic> {
616 guard_dyn("parse_json", &mut || parse_json_fn(s))
617 });
618 engine.register_fn("to_json", |v: Dynamic| -> HostRes<String> {
619 guard_str("to_json", &mut || to_json_fn(&v))
620 });
621 engine.register_fn("encode_uri", |s: &str| -> HostRes<String> {
622 guard_str("encode_uri", &mut || Ok(percent_encode(s)))
623 });
624 engine.register_fn("html_to_text", |s: &str| -> HostRes<String> {
625 guard_str("html_to_text", &mut || Ok(html_to_text(s)))
626 });
627}
628
629fn parse_json_fn(s: &str) -> HostRes<Dynamic> {
631 let value: serde_json::Value = serde_json::from_str(s).map_err(|e| {
632 Box::new(EvalAltResult::ErrorRuntime(
633 format!("parse_json: {e}").into(),
634 Position::NONE,
635 ))
636 })?;
637 rhai::serde::to_dynamic(value)
638}
639
640fn to_json_fn(v: &Dynamic) -> HostRes<String> {
644 let json: serde_json::Value = rhai::serde::from_dynamic(v)?;
645 Ok(json.to_string())
646}
647
648pub fn percent_encode(input: &str) -> String {
657 let mut out = String::with_capacity(input.len());
658 for &byte in input.as_bytes() {
659 match byte {
660 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
661 out.push(byte as char);
662 }
663 _ => {
664 out.push('%');
665 out.push(hex_digit(byte >> 4));
666 out.push(hex_digit(byte & 0x0f));
667 }
668 }
669 }
670 out
671}
672
673fn hex_digit(nibble: u8) -> char {
675 match nibble {
676 0..=9 => (b'0' + nibble) as char,
677 _ => (b'A' + (nibble - 10)) as char,
678 }
679}
680
681fn html_to_text(html: &str) -> String {
688 let without_raw = strip_raw_text_elements(html);
689 let without_tags = strip_tags(&without_raw);
690 let decoded = decode_entities(&without_tags);
691 collapse_whitespace(&decoded)
692}
693
694fn strip_raw_text_elements(html: &str) -> String {
698 let mut s = html.to_string();
699 for tag in ["script", "style"] {
700 s = strip_element(&s, tag);
701 }
702 s
703}
704
705fn strip_element(html: &str, tag: &str) -> String {
706 let lower = html.to_ascii_lowercase();
707 let open = format!("<{tag}");
708 let close = format!("</{tag}>");
709 let mut out = String::with_capacity(html.len());
710 let mut rest = html;
715 let mut lower_rest = lower.as_str();
716 loop {
717 if lower_rest.starts_with(&open) {
718 match lower_rest.find(&close) {
719 Some(rel) => {
720 let skip = rel + close.len();
721 rest = split_at_boundary(rest, skip).1;
722 lower_rest = split_at_boundary(lower_rest, skip).1;
723 continue;
724 }
725 None => break, }
727 }
728 let Some(ch) = rest.chars().next() else { break };
730 out.push(ch);
731 rest = split_at_boundary(rest, ch.len_utf8()).1;
732 lower_rest = split_at_boundary(lower_rest, ch.len_utf8()).1;
733 }
734 out
735}
736
737fn strip_tags(html: &str) -> String {
740 let mut out = String::with_capacity(html.len());
741 let mut in_tag = false;
742 for c in html.chars() {
743 match c {
744 '<' => in_tag = true,
745 '>' if in_tag => {
746 in_tag = false;
747 out.push(' ');
748 }
749 _ if !in_tag => out.push(c),
750 _ => {}
751 }
752 }
753 out
754}
755
756const ENTITY_SCAN_CHARS: usize = 12;
759
760fn decode_entities(s: &str) -> String {
773 let mut out = String::with_capacity(s.len());
774 let mut rest = s;
775 while let Some(amp) = rest.find('&') {
776 let (before, after) = split_at_boundary(rest, amp);
779 out.push_str(before);
780 let semi = after
781 .char_indices()
782 .take(ENTITY_SCAN_CHARS)
783 .find(|&(_, c)| c == ';')
784 .map(|(i, _)| i);
785 match semi {
786 Some(semi) => match decode_one_entity(substring(after, 1, semi)) {
787 Some(ch) => {
788 out.push(ch);
789 rest = split_at_boundary(after, semi + 1).1;
790 }
791 None => {
792 out.push('&');
793 rest = split_at_boundary(after, 1).1;
794 }
795 },
796 None => {
797 out.push('&');
798 rest = split_at_boundary(after, 1).1;
799 }
800 }
801 }
802 out.push_str(rest);
803 out
804}
805
806fn decode_one_entity(e: &str) -> Option<char> {
807 match e {
808 "amp" => Some('&'),
809 "lt" => Some('<'),
810 "gt" => Some('>'),
811 "quot" => Some('"'),
812 "apos" => Some('\''),
813 "nbsp" => Some(' '),
814 "mdash" => Some('\u{2014}'),
815 "ndash" => Some('–'),
816 "hellip" => Some('…'),
817 _ => {
818 if let Some(hex) = e.strip_prefix("#x").or_else(|| e.strip_prefix("#X")) {
819 u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
820 } else if let Some(dec) = e.strip_prefix('#') {
821 dec.parse::<u32>().ok().and_then(char::from_u32)
822 } else {
823 None
824 }
825 }
826 }
827}
828
829fn collapse_whitespace(s: &str) -> String {
831 let mut out = String::with_capacity(s.len());
832 let mut prev_ws = false;
833 for c in s.chars() {
834 if c.is_whitespace() {
835 if !prev_ws {
836 out.push(' ');
837 prev_ws = true;
838 }
839 } else {
840 out.push(c);
841 prev_ws = false;
842 }
843 }
844 out.trim().to_string()
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850 use std::sync::{Mutex, PoisonError};
851
852 static PANIC_HOOK_LOCK: Mutex<()> = Mutex::new(());
857
858 type Headers = BTreeMap<String, String>;
861 type GetCall = Option<(String, Headers)>;
863 type PostCall = Option<(String, String, Headers)>;
865 type HostResult = std::result::Result<String, String>;
866
867 struct FakeHost {
868 get_response: Mutex<HostResult>,
869 post_response: Mutex<HostResult>,
870 shell_response: Mutex<HostResult>,
871 read_response: Mutex<HostResult>,
872 env_response: Mutex<HostResult>,
873 last_get: Mutex<GetCall>,
874 last_post: Mutex<PostCall>,
875 }
876
877 impl FakeHost {
878 fn arc() -> Arc<FakeHost> {
879 Arc::new(FakeHost {
880 get_response: Mutex::new(Ok("GET-OK".to_string())),
881 post_response: Mutex::new(Ok("POST-OK".to_string())),
882 shell_response: Mutex::new(Ok("SHELL-OK".to_string())),
883 read_response: Mutex::new(Ok("READ-OK".to_string())),
884 env_response: Mutex::new(Ok("ENV-OK".to_string())),
885 last_get: Mutex::new(None),
886 last_post: Mutex::new(None),
887 })
888 }
889 }
890
891 impl ScriptHost for FakeHost {
892 fn http_get(
893 &self,
894 url: &str,
895 headers: BTreeMap<String, String>,
896 ) -> std::result::Result<String, String> {
897 *self.last_get.lock().unwrap() = Some((url.to_string(), headers));
898 self.get_response.lock().unwrap().clone()
899 }
900 fn http_post(
901 &self,
902 url: &str,
903 body: &str,
904 headers: BTreeMap<String, String>,
905 ) -> std::result::Result<String, String> {
906 *self.last_post.lock().unwrap() = Some((url.to_string(), body.to_string(), headers));
907 self.post_response.lock().unwrap().clone()
908 }
909 fn shell(&self, _command: &str) -> std::result::Result<String, String> {
910 self.shell_response.lock().unwrap().clone()
911 }
912 fn read_file(&self, _path: &str) -> std::result::Result<String, String> {
913 self.read_response.lock().unwrap().clone()
914 }
915 fn write_file(&self, path: &str, content: &str) -> std::result::Result<String, String> {
916 Ok(format!("WROTE:{path}={content}"))
917 }
918 fn env_var(&self, _name: &str) -> std::result::Result<String, String> {
919 self.env_response.lock().unwrap().clone()
920 }
921 }
922
923 fn tool_from(src: &str) -> ScriptTool {
924 let engine = Engine::new();
925 let ast = engine.compile(src).expect("compile");
926 ScriptTool {
927 meta: parse_annotations(src).expect("annotations"),
928 ast,
929 source_path: PathBuf::from("mem.rhai"),
930 }
931 }
932
933 #[test]
936 fn annotations_full() {
937 let src = r#"
938// @tool web_search
939// @description Search the web
940// @param query string required "Search query"
941// @param count integer optional "How many"
94242
943"#;
944 let meta = parse_annotations(src).unwrap();
945 assert_eq!(meta.name, "web_search");
946 assert_eq!(meta.description, "Search the web");
947 assert_eq!(meta.params.len(), 2);
948 assert_eq!(
949 meta.params[0],
950 ParamSpec {
951 name: "query".into(),
952 ty: "string".into(),
953 required: true,
954 description: "Search query".into(),
955 schema: None,
956 }
957 );
958 assert!(!meta.params[1].required);
959 assert!(meta.required_caps.is_empty());
960 }
961
962 #[test]
963 fn annotations_requires_capabilities() {
964 let src = "// @tool t\n// @requires network, shell\n// @requires filesystem\n1";
966 let meta = parse_annotations(src).unwrap();
967 assert_eq!(meta.required_caps, ["network", "shell", "filesystem"]);
968 }
969
970 #[test]
971 fn annotations_missing_tool_name_errors() {
972 let err = parse_annotations("// @description no name\n1").unwrap_err();
973 assert!(err.to_string().contains("missing a `// @tool"));
974 }
975
976 #[test]
977 fn annotations_empty_tool_name_errors() {
978 let err = parse_annotations("// @tool \n1").unwrap_err();
979 assert!(err.to_string().contains("requires a tool name"));
980 }
981
982 #[test]
983 fn annotations_ignore_non_comment_and_non_directive_lines() {
984 let src = "let x = 1; // trailing\n// plain comment\n// @tool t\nx";
985 let meta = parse_annotations(src).unwrap();
986 assert_eq!(meta.name, "t");
987 assert!(meta.params.is_empty());
988 assert_eq!(meta.description, "");
989 }
990
991 #[test]
992 fn annotations_unknown_directive_ignored() {
993 let meta = parse_annotations("// @tool t\n// @bogus whatever\n1").unwrap();
994 assert_eq!(meta.name, "t");
995 }
996
997 #[test]
998 fn annotations_directive_with_no_arg_is_handled() {
999 let meta = parse_annotations("// @tool t\n// @description\n1").unwrap();
1001 assert_eq!(meta.description, "");
1002 }
1003
1004 #[test]
1005 fn param_without_description_defaults_empty() {
1006 let meta = parse_annotations("// @tool t\n// @param x string required\n1").unwrap();
1007 assert_eq!(meta.params[0].description, "");
1008 assert!(meta.params[0].required);
1009 }
1010
1011 #[test]
1012 fn param_optional_flag() {
1013 let meta = parse_annotations("// @tool t\n// @param x string optional\n1").unwrap();
1014 assert!(!meta.params[0].required);
1015 }
1016
1017 #[test]
1018 fn param_too_few_tokens_errors() {
1019 let err = parse_annotations("// @tool t\n// @param x string\n1").unwrap_err();
1020 assert!(err.to_string().contains("requires `<name> <type>"));
1021 }
1022
1023 #[test]
1024 fn param_bad_requiredness_errors() {
1025 let err = parse_annotations("// @tool t\n// @param x string maybe\n1").unwrap_err();
1026 assert!(err.to_string().contains("must be `required` or `optional`"));
1027 }
1028
1029 #[test]
1032 fn tool_toml_full() {
1033 let src = r#"
1034[tool]
1035name = "fetch"
1036description = "Fetch a URL"
1037[[tool.params]]
1038name = "url"
1039type = "string"
1040required = true
1041description = "The URL"
1042"#;
1043 let meta = parse_tool_toml(src).unwrap();
1044 assert_eq!(meta.name, "fetch");
1045 assert_eq!(meta.description, "Fetch a URL");
1046 assert_eq!(meta.params.len(), 1);
1047 assert!(meta.params[0].required);
1048 assert_eq!(meta.params[0].ty, "string");
1049 }
1050
1051 #[test]
1052 fn tool_toml_requires() {
1053 let meta = parse_tool_toml("[tool]\nname = \"t\"\nrequires = [\"network\"]").unwrap();
1054 assert_eq!(meta.required_caps, ["network"]);
1055 }
1056
1057 #[test]
1058 fn tool_toml_defaults() {
1059 let meta = parse_tool_toml("[tool]\nname = \"t\"").unwrap();
1060 assert_eq!(meta.description, "");
1061 assert!(meta.params.is_empty());
1062 assert!(meta.required_caps.is_empty());
1063 }
1064
1065 #[test]
1066 fn tool_toml_raw_schema_fragment() {
1067 let src = r#"
1070[tool]
1071name = "export"
1072[[tool.params]]
1073name = "format"
1074required = true
1075schema = { type = "string", enum = ["json", "yaml"], description = "Output format" }
1076"#;
1077 let meta = parse_tool_toml(src).unwrap();
1078 assert_eq!(meta.params.len(), 1);
1079 assert!(meta.params[0].required);
1080 assert_eq!(meta.params[0].ty, "");
1082 let frag = meta.params[0].schema.as_ref().unwrap();
1083 assert_eq!(frag["enum"][0], "json");
1084 }
1085
1086 #[test]
1087 fn tool_toml_invalid_syntax_errors() {
1088 let err = parse_tool_toml("not = valid = toml").unwrap_err();
1089 assert!(err.to_string().contains("invalid tool.toml"));
1090 }
1091
1092 #[test]
1093 fn tool_toml_empty_name_errors() {
1094 let err = parse_tool_toml("[tool]\nname = \"\"").unwrap_err();
1095 assert!(err.to_string().contains("must not be empty"));
1096 }
1097
1098 #[test]
1101 fn parameters_schema_shape() {
1102 let meta = parse_annotations(
1103 "// @tool t\n// @param a string required \"A\"\n// @param b integer optional \"B\"\n1",
1104 )
1105 .unwrap();
1106 let schema = meta.parameters_schema();
1107 assert_eq!(schema["type"], "object");
1108 assert_eq!(schema["properties"]["a"]["type"], "string");
1109 assert_eq!(schema["properties"]["b"]["description"], "B");
1110 let required = schema["required"].as_array().unwrap();
1111 assert_eq!(required.len(), 1);
1112 assert_eq!(required[0], "a");
1113 }
1114
1115 #[test]
1116 fn parameters_schema_uses_raw_fragment_verbatim() {
1117 let meta = parse_tool_toml(
1121 "[tool]\nname = \"t\"\n[[tool.params]]\nname = \"fmt\"\nrequired = true\nschema = { type = \"string\", enum = [\"a\", \"b\"] }\n",
1122 )
1123 .unwrap();
1124 let schema = meta.parameters_schema();
1125 assert_eq!(schema["properties"]["fmt"]["type"], "string");
1126 assert_eq!(schema["properties"]["fmt"]["enum"][1], "b");
1127 assert!(schema["properties"]["fmt"].get("description").is_none());
1129 assert_eq!(schema["required"][0], "fmt");
1130 }
1131
1132 #[test]
1135 fn discover_compiles_and_collides() {
1136 let dir_a = tempfile::tempdir().unwrap();
1137 let dir_b = tempfile::tempdir().unwrap();
1138 std::fs::write(
1140 dir_a.path().join("dup.rhai"),
1141 "// @tool dup\n// @description from A\n1",
1142 )
1143 .unwrap();
1144 std::fs::write(
1145 dir_b.path().join("dup.rhai"),
1146 "// @tool dup\n// @description from B\n2",
1147 )
1148 .unwrap();
1149 std::fs::write(dir_b.path().join("solo.rhai"), "// @tool solo\n3").unwrap();
1150 std::fs::write(dir_b.path().join("note.txt"), "ignored").unwrap();
1152 std::fs::write(
1153 dir_b.path().join("broken.rhai"),
1154 "// no tool directive\nlet",
1155 )
1156 .unwrap();
1157
1158 let (set, skipped) = ScriptToolSet::discover(&[
1159 dir_a.path().to_path_buf(),
1160 dir_b.path().to_path_buf(),
1161 dir_a.path().join("does-not-exist"),
1162 ]);
1163 assert_eq!(set.len(), 2);
1164 assert!(!set.is_empty());
1165 assert!(set.contains("dup"));
1166 assert!(set.contains("solo"));
1167 assert_eq!(set.get("dup").unwrap().meta.description, "from A");
1168 let mut names = set.names();
1169 names.sort();
1170 assert_eq!(names, vec!["dup".to_string(), "solo".to_string()]);
1171 assert_eq!(set.metas().len(), 2);
1172 assert_eq!(skipped.len(), 1);
1174 assert!(skipped[0].path.ends_with("broken.rhai"));
1175 assert!(!skipped[0].reason.is_empty());
1176 }
1177
1178 #[test]
1179 fn discover_uses_tool_toml_override() {
1180 let dir = tempfile::tempdir().unwrap();
1181 std::fs::write(dir.path().join("t.rhai"), "// @tool ann\n1").unwrap();
1183 std::fs::write(
1184 dir.path().join("t.toml"),
1185 "[tool]\nname = \"override\"\ndescription = \"D\"",
1186 )
1187 .unwrap();
1188 let (set, skipped) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1189 assert!(set.contains("override"));
1190 assert!(!set.contains("ann"));
1191 assert!(skipped.is_empty());
1192 }
1193
1194 #[test]
1195 fn discover_skips_invalid_tool_toml() {
1196 let dir = tempfile::tempdir().unwrap();
1197 std::fs::write(dir.path().join("t.rhai"), "// @tool t\n1").unwrap();
1200 std::fs::write(dir.path().join("t.toml"), "name = broken").unwrap();
1201 let (set, skipped) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1202 assert!(set.is_empty());
1203 assert_eq!(skipped.len(), 1);
1204 assert!(skipped[0].reason.contains("tool.toml"));
1205 }
1206
1207 #[test]
1208 fn discover_skips_uncompilable_but_valid_annotation() {
1209 let dir = tempfile::tempdir().unwrap();
1210 std::fs::write(dir.path().join("t.rhai"), "// @tool t\nlet x = ;").unwrap();
1212 let (set, _) = ScriptToolSet::discover(&[dir.path().to_path_buf()]);
1213 assert!(set.is_empty());
1214 }
1215
1216 #[test]
1217 fn default_set_is_empty() {
1218 let set = ScriptToolSet::default();
1219 assert!(set.is_empty());
1220 assert!(set.get("x").is_none());
1221 }
1222
1223 #[test]
1226 fn execute_returns_string_verbatim() {
1227 let tool = tool_from("// @tool t\n\"hello \" + params.name");
1228 let out = execute(&tool, serde_json::json!({"name": "world"}), FakeHost::arc());
1229 assert_eq!(out, "hello world");
1230 }
1231
1232 #[test]
1233 fn execute_serializes_non_string_result() {
1234 let tool = tool_from("// @tool t\n[1, 2, 3]");
1235 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1236 assert_eq!(out, "[1,2,3]");
1237 }
1238
1239 #[test]
1240 fn execute_unserializable_result_errors() {
1241 let tool = tool_from("// @tool t\n|| 1");
1244 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1245 assert!(out.contains("cannot serialize result"), "got: {out}");
1246 }
1247
1248 #[test]
1249 fn execute_unit_result_is_empty() {
1250 let tool = tool_from("// @tool t\nlet x = 1;");
1251 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1252 assert_eq!(out, "");
1253 }
1254
1255 #[test]
1256 fn execute_html_to_text_host_fn_via_script() {
1257 let tool = tool_from("// @tool t\nhtml_to_text(\"<p>Hi&<b>bye</b></p>\")");
1260 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1261 assert_eq!(out, "Hi& bye");
1262 }
1263
1264 #[test]
1265 fn execute_missing_optional_param_reads_as_unit() {
1266 let tool = tool_from("// @tool t\nif params.count == () { \"default\" } else { \"set\" }");
1268 let out = execute(&tool, serde_json::json!({"query": "x"}), FakeHost::arc());
1269 assert_eq!(out, "default");
1270 }
1271
1272 #[test]
1273 fn execute_script_error_is_prefixed() {
1274 let tool = tool_from("// @tool t\nthrow \"boom\"");
1275 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1276 assert!(out.starts_with("[error] t:"), "got: {out}");
1277 assert!(out.contains("boom"));
1278 }
1279
1280 enum PanicPayload {
1282 Formatted(&'static str),
1284 Literal,
1286 NonString,
1289 }
1290
1291 struct PanickingHost {
1295 payload: PanicPayload,
1296 }
1297
1298 impl PanickingHost {
1299 fn do_panic(&self) -> ! {
1300 match &self.payload {
1301 PanicPayload::Formatted(msg) => panic!("{}", msg),
1302 PanicPayload::Literal => panic!("literal str panic"),
1303 PanicPayload::NonString => std::panic::panic_any(42_i32),
1304 }
1305 }
1306 }
1307
1308 impl ScriptHost for PanickingHost {
1309 fn http_get(
1310 &self,
1311 _u: &str,
1312 _h: BTreeMap<String, String>,
1313 ) -> std::result::Result<String, String> {
1314 self.do_panic();
1315 }
1316 fn http_post(
1317 &self,
1318 _u: &str,
1319 _b: &str,
1320 _h: BTreeMap<String, String>,
1321 ) -> std::result::Result<String, String> {
1322 self.do_panic();
1323 }
1324 fn shell(&self, _c: &str) -> std::result::Result<String, String> {
1325 self.do_panic();
1326 }
1327 fn read_file(&self, _p: &str) -> std::result::Result<String, String> {
1328 self.do_panic();
1329 }
1330 fn write_file(&self, _p: &str, _c: &str) -> std::result::Result<String, String> {
1331 self.do_panic();
1332 }
1333 fn env_var(&self, _n: &str) -> std::result::Result<String, String> {
1334 self.do_panic();
1335 }
1336 }
1337
1338 fn execute_with_panicking_host(payload: PanicPayload, script: &str) -> String {
1342 let host: Arc<dyn ScriptHost> = Arc::new(PanickingHost { payload });
1343 let tool = tool_from(script);
1344 let _guard = PANIC_HOOK_LOCK
1345 .lock()
1346 .unwrap_or_else(PoisonError::into_inner);
1347 let prev = std::panic::take_hook();
1348 std::panic::set_hook(Box::new(|_| {}));
1349 let out = execute(&tool, serde_json::json!({}), host);
1350 std::panic::set_hook(prev);
1351 out
1352 }
1353
1354 fn assert_guarded_panic(out: &str, tool_name: &str, host_fn: &str, detail: &str) {
1356 assert!(
1357 out.starts_with(&format!("[error] {tool_name}:")),
1358 "got: {out}"
1359 );
1360 assert!(out.contains(&format!("{host_fn} panicked")), "got: {out}");
1361 assert!(out.contains(detail), "got: {out}");
1362 }
1363
1364 #[test]
1365 fn every_host_fn_panic_becomes_a_script_error() {
1366 for (host_fn, tool_name, script) in [
1372 ("http_get", "t", "// @tool t\nhttp_get(\"http://x\")"),
1373 (
1374 "http_get",
1375 "t",
1376 "// @tool t\nhttp_get(\"http://x\", #{ \"A\": \"b\" })",
1377 ),
1378 (
1379 "http_post",
1380 "t",
1381 "// @tool t\nhttp_post(\"http://x\", \"b\")",
1382 ),
1383 (
1384 "http_post",
1385 "t",
1386 "// @tool t\nhttp_post(\"http://x\", \"b\", #{ \"A\": \"b\" })",
1387 ),
1388 ("shell", "sh", "// @tool sh\nshell(\"ls\")"),
1389 ("read_file", "rf", "// @tool rf\nread_file(\"x.txt\")"),
1390 (
1391 "write_file",
1392 "wf",
1393 "// @tool wf\nwrite_file(\"out.txt\", \"data\")",
1394 ),
1395 ("env_var", "ev", "// @tool ev\nenv_var(\"HOME\")"),
1396 ] {
1397 let out =
1398 execute_with_panicking_host(PanicPayload::Formatted("TLS init failed"), script);
1399 assert_guarded_panic(&out, tool_name, host_fn, "TLS init failed");
1400 }
1401 }
1402
1403 #[test]
1404 fn guarded_panic_renders_str_and_non_string_payloads() {
1405 let out = execute_with_panicking_host(
1406 PanicPayload::Literal,
1407 "// @tool t\nhttp_get(\"http://x\")",
1408 );
1409 assert_guarded_panic(&out, "t", "http_get", "literal str panic");
1410
1411 let out = execute_with_panicking_host(
1412 PanicPayload::NonString,
1413 "// @tool t\nhttp_get(\"http://x\")",
1414 );
1415 assert_guarded_panic(&out, "t", "http_get", "unknown panic");
1416 }
1417
1418 #[test]
1419 fn guards_pass_through_success_and_convert_panics() {
1420 let _guard = PANIC_HOOK_LOCK
1424 .lock()
1425 .unwrap_or_else(PoisonError::into_inner);
1426 assert_eq!(
1427 guard_str("ok_str", &mut || Ok("value".to_string())).unwrap(),
1428 "value"
1429 );
1430 assert!(
1431 guard_dyn("ok_dyn", &mut || Ok(Dynamic::from(7_i64)))
1432 .unwrap()
1433 .is_int()
1434 );
1435
1436 let prev = std::panic::take_hook();
1437 std::panic::set_hook(Box::new(|_| {}));
1438 let str_err = guard_str("boom_str", &mut || panic!("string arm")).unwrap_err();
1439 let dyn_err = guard_dyn("boom_dyn", &mut || panic!("dynamic arm")).unwrap_err();
1440 std::panic::set_hook(prev);
1441 assert!(
1442 str_err
1443 .to_string()
1444 .contains("boom_str panicked: string arm")
1445 );
1446 assert!(
1447 dyn_err
1448 .to_string()
1449 .contains("boom_dyn panicked: dynamic arm")
1450 );
1451 }
1452
1453 #[test]
1454 fn execute_scalar_args_run() {
1455 let tool = tool_from("// @tool t\n\"ok\"");
1458 let out = execute(&tool, serde_json::json!(5), FakeHost::arc());
1459 assert_eq!(out, "ok");
1460 }
1461
1462 #[test]
1463 fn execute_print_and_debug_are_noop() {
1464 let tool = tool_from("// @tool t\nprint(\"p\"); debug(\"d\"); \"done\"");
1466 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1467 assert_eq!(out, "done");
1468 }
1469
1470 #[test]
1471 fn compile_tool_read_error() {
1472 let engine = Engine::new();
1473 let err = compile_tool(&engine, Path::new("/no/such/dir/tool.rhai")).unwrap_err();
1474 assert!(err.to_string().contains("read"));
1475 }
1476
1477 #[test]
1478 fn to_json_on_unserializable_value_errors() {
1479 let tool = tool_from("// @tool t\nlet f = || 1; to_json(f)");
1482 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1483 assert!(out.starts_with("[error]"), "got: {out}");
1484 }
1485
1486 #[test]
1489 fn http_get_no_headers() {
1490 let host = FakeHost::arc();
1491 let tool = tool_from("// @tool t\nhttp_get(\"http://x\")");
1492 let out = execute(&tool, serde_json::json!({}), host.clone());
1493 assert_eq!(out, "GET-OK");
1494 let (url, headers) = host.last_get.lock().unwrap().clone().unwrap();
1495 assert_eq!(url, "http://x");
1496 assert!(headers.is_empty());
1497 }
1498
1499 #[test]
1500 fn http_get_with_headers() {
1501 let host = FakeHost::arc();
1502 let tool = tool_from("// @tool t\nhttp_get(\"http://x\", #{ \"K\": \"V\" })");
1503 let out = execute(&tool, serde_json::json!({}), host.clone());
1504 assert_eq!(out, "GET-OK");
1505 let (_, headers) = host.last_get.lock().unwrap().clone().unwrap();
1506 assert_eq!(headers.get("K").map(String::as_str), Some("V"));
1507 }
1508
1509 #[test]
1510 fn http_get_error_surfaces() {
1511 let host = FakeHost::arc();
1512 *host.get_response.lock().unwrap() = Err("[denied] http_get".to_string());
1513 let tool = tool_from("// @tool t\nhttp_get(\"http://x\")");
1514 let out = execute(&tool, serde_json::json!({}), host);
1515 assert!(out.contains("[denied] http_get"));
1516 }
1517
1518 #[test]
1519 fn http_post_variants() {
1520 let host = FakeHost::arc();
1521 let tool = tool_from("// @tool t\nhttp_post(\"http://x\", \"body\")");
1522 assert_eq!(
1523 execute(&tool, serde_json::json!({}), host.clone()),
1524 "POST-OK"
1525 );
1526 let (_, body, headers) = host.last_post.lock().unwrap().clone().unwrap();
1527 assert_eq!(body, "body");
1528 assert!(headers.is_empty());
1529
1530 let tool2 = tool_from("// @tool t\nhttp_post(\"http://x\", \"b\", #{ \"H\": \"1\" })");
1531 assert_eq!(
1532 execute(&tool2, serde_json::json!({}), host.clone()),
1533 "POST-OK"
1534 );
1535 let (_, _, headers2) = host.last_post.lock().unwrap().clone().unwrap();
1536 assert_eq!(headers2.get("H").map(String::as_str), Some("1"));
1537 }
1538
1539 #[test]
1540 fn shell_read_env_hosts() {
1541 let host = FakeHost::arc();
1542 assert_eq!(
1543 execute(
1544 &tool_from("// @tool t\nshell(\"ls\")"),
1545 serde_json::json!({}),
1546 host.clone()
1547 ),
1548 "SHELL-OK"
1549 );
1550 assert_eq!(
1551 execute(
1552 &tool_from("// @tool t\nread_file(\"a\")"),
1553 serde_json::json!({}),
1554 host.clone()
1555 ),
1556 "READ-OK"
1557 );
1558 assert_eq!(
1559 execute(
1560 &tool_from("// @tool t\nenv_var(\"A\")"),
1561 serde_json::json!({}),
1562 host.clone()
1563 ),
1564 "ENV-OK"
1565 );
1566 assert_eq!(
1567 execute(
1568 &tool_from("// @tool t\nwrite_file(\"out.txt\", \"body\")"),
1569 serde_json::json!({}),
1570 host
1571 ),
1572 "WROTE:out.txt=body"
1573 );
1574 }
1575
1576 #[test]
1579 fn parse_and_to_json_roundtrip() {
1580 let host = FakeHost::arc();
1581 let tool = tool_from("// @tool t\nlet d = parse_json(\"{\\\"a\\\": 1}\"); to_json(d)");
1582 let out = execute(&tool, serde_json::json!({}), host);
1583 assert_eq!(out, "{\"a\":1}");
1584 }
1585
1586 #[test]
1587 fn parse_json_invalid_errors() {
1588 let tool = tool_from("// @tool t\nparse_json(\"not json\")");
1589 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1590 assert!(out.contains("parse_json"));
1591 }
1592
1593 #[test]
1594 fn parse_json_result_used_as_value() {
1595 let tool = tool_from("// @tool t\nlet d = parse_json(\"{\\\"k\\\": \\\"v\\\"}\"); d.k");
1597 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1598 assert_eq!(out, "v");
1599 }
1600
1601 #[test]
1602 fn encode_uri_encodes_reserved_and_passes_unreserved() {
1603 let tool = tool_from("// @tool t\nencode_uri(\"a b&c-_.~\")");
1604 let out = execute(&tool, serde_json::json!({}), FakeHost::arc());
1605 assert_eq!(out, "a%20b%26c-_.~");
1606 }
1607
1608 #[test]
1609 fn to_json_fn_direct_success_and_failure() {
1610 let mut map = Map::new();
1613 map.insert("a".into(), Dynamic::from(1_i64));
1614 assert_eq!(to_json_fn(&Dynamic::from_map(map)).unwrap(), "{\"a\":1}");
1615 let engine = Engine::new();
1617 let fnptr: Dynamic = engine.eval("|| 1").unwrap();
1618 assert!(to_json_fn(&fnptr).is_err());
1619 }
1620
1621 #[test]
1622 fn parse_json_fn_direct_success_and_failure() {
1623 let d = parse_json_fn("{\"k\": \"v\"}").unwrap();
1624 assert!(d.is_map());
1625 assert!(parse_json_fn("not json").is_err());
1626 }
1627
1628 #[test]
1629 fn encode_uri_non_ascii() {
1630 assert_eq!(percent_encode("€"), "%E2%82%AC");
1632 }
1633
1634 #[test]
1635 fn hex_digit_covers_both_arms() {
1636 assert_eq!(hex_digit(9), '9');
1637 assert_eq!(hex_digit(15), 'F');
1638 assert_eq!(hex_digit(0), '0');
1639 }
1640
1641 #[test]
1642 fn headers_from_map_stringifies_values() {
1643 let mut m = Map::new();
1644 m.insert("n".into(), Dynamic::from(42_i64));
1645 let headers = headers_from_map(&m);
1646 assert_eq!(headers.get("n").map(String::as_str), Some("42"));
1647 }
1648
1649 #[test]
1650 fn html_to_text_full_pipeline() {
1651 let html = "<html><head><style>.a{color:red}</style></head>\
1652 <body><h1>Tit&le</h1><script>var x=1<2;</script>\
1653 <p>Hello world 'quoted' — done.</p></body></html>";
1654 let text = html_to_text(html);
1655 assert!(text.contains("Tit&le"), "entity decoded: {text}");
1656 assert!(
1657 text.contains("Hello world 'quoted' \u{2014} done."),
1658 "got: {text}"
1659 );
1660 assert!(!text.contains("color:red"), "style content dropped");
1661 assert!(!text.contains("var x"), "script content dropped");
1662 assert!(!text.contains('<'), "tags stripped");
1663 }
1664
1665 #[test]
1666 fn strip_element_handles_case_unclosed_and_utf8() {
1667 assert_eq!(strip_element("a<SCRIPT>x</script>b", "script"), "ab");
1669 assert_eq!(strip_element("keep<style>rest", "style"), "keep");
1671 assert_eq!(strip_element("café < 3", "script"), "café < 3");
1673 }
1674
1675 #[test]
1676 fn strip_tags_edges() {
1677 assert_eq!(strip_tags("<b>hi</b>").trim(), "hi");
1678 assert_eq!(strip_tags("2 > 1").trim(), "2 > 1");
1680 assert_eq!(strip_tags("ok <broken").trim(), "ok");
1682 }
1683
1684 #[test]
1685 fn decode_entities_named_numeric_and_unknown() {
1686 assert_eq!(decode_entities("a&b"), "a&b");
1687 assert_eq!(decode_entities("<>"'"), "<>\"'");
1688 assert_eq!(decode_entities("x y"), "x y");
1689 assert_eq!(decode_entities("—–…"), "\u{2014}–…");
1690 assert_eq!(decode_entities("ABC"), "ABC");
1691 assert_eq!(decode_entities("&bogus;"), "&bogus;");
1693 assert_eq!(decode_entities("a & b"), "a & b");
1695 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");
1702 }
1703
1704 #[test]
1705 fn decode_entities_survives_multibyte_after_an_ampersand() {
1706 assert_eq!(decode_entities("&日本語日本"), "&日本語日本");
1712 assert_eq!(decode_entities("R&D 日本語です"), "R&D 日本語です");
1713 assert_eq!(decode_entities("&🎉🎉🎉🎉"), "&🎉🎉🎉🎉");
1714 assert_eq!(
1715 decode_entities("&\u{2014}\u{2014}\u{2014}\u{2014}"),
1716 "&\u{2014}\u{2014}\u{2014}\u{2014}"
1717 );
1718 assert_eq!(decode_entities("&日本語"), "&日本語");
1720 assert_eq!(decode_entities("tail&"), "tail&");
1722 assert_eq!(decode_entities("&abcdefghi🇸"), "&abcdefghi🇸");
1727 }
1728
1729 #[test]
1730 fn collapse_whitespace_runs_and_trims() {
1731 assert_eq!(collapse_whitespace(" a \n\t b "), "a b");
1732 assert_eq!(collapse_whitespace(""), "");
1733 }
1734}