1#![forbid(unsafe_code)]
19
20use async_trait::async_trait;
21
22use serde_json::{Value, json};
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25use wm_core::security::is_url_safe;
26use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
27
28const USER_AGENT: &str = "WhiteMagic/5.6 (local research agent)";
29const MAX_REDIRECTS: u32 = 5;
30
31pub(crate) struct Fetched {
33 pub(crate) url: String,
34 pub(crate) title: String,
35 pub(crate) content: String,
37 pub(crate) raw: String,
39 pub(crate) status_code: u16,
40 pub(crate) duration_ms: f64,
41 pub(crate) pages: u32,
42}
43
44fn safe_url(url: &str) -> Result<String, wm_core::CoreError> {
46 if !is_url_safe(url) {
47 return Err(wm_core::CoreError::InvalidArgs(format!(
48 "unsafe URL (SSRF guard): {url}"
49 )));
50 }
51 Ok(url.to_string())
52}
53
54pub(crate) fn fetch_bounded(
57 start_url: &str,
58 max_chars: usize,
59 timeout: Duration,
60) -> Result<Fetched, wm_core::CoreError> {
61 let started = Instant::now();
62 let mut current = start_url.to_string();
63 let mut pages = 1u32;
64
65 for _hop in 0..=MAX_REDIRECTS {
66 let agent = ureq::Agent::config_builder()
67 .timeout_global(Some(timeout))
68 .build()
69 .new_agent();
70 let response = agent
71 .get(¤t)
72 .header("User-Agent", USER_AGENT)
73 .call()
74 .map_err(|e| wm_core::CoreError::Tool(format!("fetch {current}: {e}")))?;
75
76 let status = response.status().as_u16();
77 if (300..400).contains(&status) {
78 let location = response
79 .headers()
80 .get("location")
81 .and_then(|v| v.to_str().ok())
82 .ok_or_else(|| {
83 wm_core::CoreError::Tool(format!(
84 "redirect {status} without Location at {current}"
85 ))
86 })?
87 .to_string();
88 let next = resolve_url(¤t, &location);
89 safe_url(&next)?;
90 current = next;
91 pages += 1;
92 continue;
93 }
94 if !(200..300).contains(&status) {
95 return Err(wm_core::CoreError::Tool(format!(
96 "HTTP {status} from {current}"
97 )));
98 }
99
100 let raw_budget = (max_chars as u64)
105 .saturating_mul(8)
106 .clamp(64_000, 1_000_000);
107 let mut reader = response.into_body().into_reader();
108 let mut bytes = Vec::new();
109 std::io::Read::read_to_end(
110 &mut std::io::Read::take(&mut reader, raw_budget),
111 &mut bytes,
112 )
113 .map_err(|e| wm_core::CoreError::Tool(format!("read {current}: {e}")))?;
114 let html = String::from_utf8_lossy(&bytes).into_owned();
115 let title = extract_title(&html).unwrap_or_default();
116 let content = strip_html(&html);
117 let content: String = content.chars().take(max_chars).collect();
118 return Ok(Fetched {
119 url: current,
120 title,
121 content,
122 raw: html,
123 status_code: status,
124 duration_ms: started.elapsed().as_secs_f64() * 1000.0,
125 pages,
126 });
127 }
128
129 Err(wm_core::CoreError::Tool(format!(
130 "too many redirects ({MAX_REDIRECTS})"
131 )))
132}
133
134#[must_use]
138pub fn resolve_url(base: &str, location: &str) -> String {
139 if location.starts_with("http://") || location.starts_with("https://") {
140 return location.to_string();
141 }
142 let (scheme, rest) = base
143 .split_once("://")
144 .map_or(("https", base), |(s, r)| (s, r));
145 if location.starts_with("//") {
146 return format!("{scheme}:{location}");
147 }
148 let slash = rest.find('/').unwrap_or(rest.len());
149 let (host, path) = rest.split_at(slash);
150 if location.starts_with('/') {
151 return format!("{scheme}://{host}{location}");
152 }
153 let dir: String = if path.is_empty() {
155 "/".to_string()
156 } else {
157 format!("{}/", path.rsplit_once('/').map_or("/", |(d, _)| d))
158 };
159 format!("{scheme}://{host}{dir}{location}")
160}
161
162pub(crate) fn extract_title(html: &str) -> Option<String> {
164 let lower = html.to_ascii_lowercase();
165 let start = lower.find("<title")?;
166 let gt = lower[start..].find('>')? + start + 1;
167 let end = lower[gt..].find("</title")? + gt;
168 let raw = &html[gt.min(html.len())..end.min(html.len())];
169 let title = strip_html(raw);
170 let title = title.trim();
171 if title.is_empty() {
172 None
173 } else {
174 Some(title.to_string())
175 }
176}
177
178#[must_use]
181pub fn strip_html(html: &str) -> String {
182 let mut out = String::with_capacity(html.len() / 2);
183 let mut in_script = false;
184 let mut chars = html.chars();
185 while let Some(c) = chars.next() {
186 match c {
187 '<' => {
188 let mut tag = String::new();
189 for pc in chars.by_ref() {
190 tag.push(pc);
191 if pc == '>' {
192 break;
193 }
194 }
195 let lower = tag.to_ascii_lowercase();
196 let trimmed = lower.trim_matches(['<', '>', '/']);
197 let name = trimmed.split_whitespace().next().unwrap_or("");
198 if name == "script" || name == "style" {
199 in_script = !lower.starts_with('/');
202 } else if !in_script
203 && !lower.starts_with("</")
204 && matches!(
205 name,
206 "p" | "br" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "tr"
207 )
208 && !out.ends_with('\n')
209 {
210 out.push('\n');
211 }
212 }
213 _ if in_script => {} '&' => {
215 let mut lookahead = chars.clone();
220 let mut entity = String::new();
221 let mut terminated = false;
222 for _ in 0..=12 {
223 match lookahead.next() {
224 Some(';') => {
225 terminated = true;
226 break;
227 }
228 Some('<') => break,
229 Some(c) => entity.push(c),
230 None => break,
231 }
232 }
233 if terminated {
234 if is_known_entity(&entity) {
235 for _ in entity.chars() {
237 chars.next();
238 }
239 chars.next();
240 out.push_str(&decode_entity(&entity));
241 } else {
242 out.push('&');
245 }
246 } else {
247 out.push('&');
250 }
251 }
252 c => out.push(c),
253 }
254 }
255 let mut result = String::with_capacity(out.len());
257 let mut pending_newline = false;
258 let mut pending_space = false;
259 for c in out.chars() {
260 if c == '\n' {
261 pending_newline = true;
262 pending_space = false;
263 } else if c.is_whitespace() {
264 pending_space = true;
265 } else {
266 if pending_newline {
267 if !result.ends_with('\n') && !result.is_empty() {
268 result.push('\n');
269 }
270 pending_newline = false;
271 } else if pending_space {
272 if !result.ends_with(' ') && !result.ends_with('\n') && !result.is_empty() {
273 result.push(' ');
274 }
275 pending_space = false;
276 }
277 result.push(c);
278 }
279 }
280 result.trim().to_string()
281}
282
283fn decode_entity(entity: &str) -> String {
286 let e = entity.trim_end_matches(';');
287 let out = match e {
288 "amp" => "&",
289 "lt" => "<",
290 "gt" => ">",
291 "quot" => "\"",
292 "apos" | "#39" => "'",
293 "nbsp" => " ",
294 _ => {
295 if let Some(num) = e.strip_prefix('#') {
296 let code = num.parse::<u32>().ok().or_else(|| {
297 num.strip_prefix('x')
298 .and_then(|h| u32::from_str_radix(h, 16).ok())
299 });
300 if let Some(code) = code {
301 if let Some(ch) = char::from_u32(code) {
302 return ch.to_string();
303 }
304 }
305 }
306 return format!("&{e};");
307 }
308 };
309 out.to_string()
310}
311
312fn is_known_entity(entity: &str) -> bool {
315 let e = entity.trim_end_matches(';');
316 if matches!(e, "amp" | "lt" | "gt" | "quot" | "apos" | "#39" | "nbsp") {
317 return true;
318 }
319 if let Some(num) = e.strip_prefix('#') {
320 let code = num.parse::<u32>().ok().or_else(|| {
321 num.strip_prefix('x')
322 .and_then(|h| u32::from_str_radix(h, 16).ok())
323 });
324 if let Some(code) = code {
325 return char::from_u32(code).is_some();
326 }
327 }
328 false
329}
330
331#[must_use]
334pub fn ddg_target(href: &str) -> Option<String> {
335 let idx = href.find("uddg=")?;
336 let encoded = &href[idx + 5..];
337 let end = encoded.find('&').unwrap_or(encoded.len());
338 let bytes = encoded.as_bytes();
339 let end = end.min(bytes.len());
340 let mut out = Vec::new();
341 let mut i = 0;
342 while i < end {
343 if bytes[i] == b'%' && i + 2 < end {
344 if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
345 out.push((hi << 4) | lo);
346 i += 3;
347 continue;
348 }
349 }
350 out.push(bytes[i]);
351 i += 1;
352 }
353 String::from_utf8(out).ok()
354}
355
356#[must_use]
358const fn hex_val(b: u8) -> Option<u8> {
359 match b {
360 b'0'..=b'9' => Some(b - b'0'),
361 b'a'..=b'f' => Some(b - b'a' + 10),
362 b'A'..=b'F' => Some(b - b'A' + 10),
363 _ => None,
364 }
365}
366
367#[must_use]
373pub fn bing_decode(href: &str) -> Option<String> {
374 let unescaped = href.replace("&", "&");
375 let idx = unescaped.find("u=a1")?;
376 let rest = &unescaped[idx + 4..];
377 let end = rest.find('&').unwrap_or(rest.len());
378 let b64 = rest[..end].replace('-', "+").replace('_', "/");
379 let mut bytes = Vec::with_capacity(b64.len() * 3 / 4);
380 let table: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
381 let mut acc = 0u32;
382 let mut bits = 0u8;
383 for c in b64.bytes().filter(|c| *c != b'=') {
384 let v = table.iter().position(|t| *t == c)?;
385 acc = (acc << 6) | v as u32;
386 bits += 6;
387 if bits >= 8 {
388 bits -= 8;
389 bytes.push((acc >> bits) as u8);
390 acc &= (1 << bits) - 1;
391 }
392 }
393 let target = String::from_utf8(bytes).ok()?;
394 if target.starts_with("http://") || target.starts_with("https://") {
397 Some(target)
398 } else {
399 None
400 }
401}
402
403#[must_use]
405pub fn percent_encode_query(query: &str) -> String {
406 query
407 .chars()
408 .flat_map(|c| match c {
409 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => vec![c],
410 ' ' => vec!['+'],
411 _ => {
412 let mut bytes = [0u8; 4];
413 let s = c.encode_utf8(&mut bytes);
414 s.bytes()
415 .flat_map(|b| format!("%{b:02X}").chars().collect::<Vec<_>>())
416 .collect()
417 }
418 })
419 .collect()
420}
421
422#[derive(Debug)]
424pub struct SearchResult {
425 pub url: String,
426 pub title: String,
427 pub snippet: String,
428}
429
430pub(crate) fn web_search(
437 query: &str,
438 num_results: usize,
439 timeout: Duration,
440) -> Result<Vec<SearchResult>, wm_core::CoreError> {
441 let url = format!(
442 "https://www.bing.com/search?q={}&count={}",
443 percent_encode_query(query),
444 num_results
445 );
446 safe_url(&url)?;
447 let fetched = fetch_bounded(&url, 300_000, timeout)?;
448 if fetched.status_code == 202 {
449 return Ok(Vec::new());
450 }
451 Ok(parse_bing_results(&fetched.raw, num_results))
452}
453
454#[must_use]
460pub fn parse_bing_results(html: &str, num_results: usize) -> Vec<SearchResult> {
461 let lower = html.to_ascii_lowercase();
462
463 let mut results: Vec<SearchResult> = Vec::new();
464 let mut pos = 0usize;
465 while results.len() < num_results {
466 let block = lower[pos..].find("<li class=\"b_algo\"");
467 let Some(block) = block else { break };
468 let block = pos + block;
469 let block_end = lower[block..]
470 .find("</li>")
471 .map_or(lower.len(), |e| block + e);
472 let chunk = &html[block..block_end];
473 let chunk_lower = &lower[block..block_end];
474
475 let mut anchor_at = 0usize;
477 let mut href = None;
478 while anchor_at < chunk.len() {
479 let Some(rel) = chunk_lower[anchor_at..].find("<a ") else {
480 break;
481 };
482 let a_start = anchor_at + rel;
483 let Some(href_start) = chunk_lower[a_start..].find("href=\"") else {
484 break;
485 };
486 let href_start = a_start + href_start + 6;
487 let Some(href_end) = chunk_lower[href_start..].find('"') else {
488 break;
489 };
490 let href_end = href_start + href_end;
491 let candidate = &chunk[href_start..href_end];
492 anchor_at = href_end + 1;
493 if candidate.starts_with("javascript:") || candidate.starts_with('#') {
494 continue;
495 }
496 href = Some(candidate.to_string());
497 break;
498 }
499 let Some(href) = href else {
500 pos = block + 7;
501 continue;
502 };
503
504 let title = {
506 let h2 = chunk_lower.find("<h2").unwrap_or(0);
507 let gt = chunk_lower[h2..].find('>').map_or(0, |e| h2 + e + 1);
508 let close = chunk_lower[gt..]
509 .find("</a>")
510 .map_or(chunk.len(), |e| gt + e);
511 strip_html(&chunk[gt..close.min(chunk.len())])
512 };
513
514 let snippet = {
516 let p_start = chunk_lower.find("<p ");
517 match p_start {
518 Some(ps) => {
519 let gt = chunk_lower[ps..].find('>').map(|e| ps + e + 1);
520 match gt {
521 Some(gt) => {
522 let p_close = chunk_lower[gt..].find("</p>").map(|e| gt + e);
523 match p_close {
524 Some(pc) => strip_html(&chunk[gt..pc]),
525 None => String::new(),
526 }
527 }
528 None => String::new(),
529 }
530 }
531 None => String::new(),
532 }
533 };
534
535 let target = if href.contains("/ck/a") {
536 bing_decode(&href)
537 .filter(|t| is_url_safe(t))
538 .unwrap_or_default()
539 } else if href.starts_with("http") && is_url_safe(&href) {
540 href
541 } else {
542 ddg_target(&href)
543 .filter(|t| is_url_safe(t))
544 .unwrap_or_default()
545 };
546
547 if !target.is_empty() {
548 results.push(SearchResult {
549 url: target,
550 title: title.trim().to_string(),
551 snippet: snippet.trim().to_string(),
552 });
553 }
554 pos = block + 7;
555 }
556 results
557}
558
559fn fetch_response(fetched: &Fetched, truncated: bool) -> Value {
561 json!({
562 "status": "success",
563 "url": fetched.url,
564 "title": fetched.title,
565 "content": fetched.content,
566 "content_length": fetched.content.len(),
567 "status_code": fetched.status_code,
568 "duration_ms": fetched.duration_ms,
569 "pages_fetched": fetched.pages,
570 "truncated": truncated,
571 })
572}
573
574pub struct WebFetchTool {
578 stats: ToolStats,
579 effects: EffectRow,
580}
581
582impl WebFetchTool {
583 #[must_use]
584 pub fn new() -> Self {
585 Self {
586 stats: ToolStats::default(),
587 effects: EffectRow::read_only(vec![Resource::Network]),
588 }
589 }
590}
591
592impl Default for WebFetchTool {
593 fn default() -> Self {
594 Self::new()
595 }
596}
597
598#[async_trait]
599impl Tool for WebFetchTool {
600 fn name(&self) -> &str {
601 "web.fetch"
602 }
603 fn gana(&self) -> Gana {
604 Gana::Chariot
605 }
606 fn effects(&self) -> &EffectRow {
607 &self.effects
608 }
609 fn description(&self) -> &str {
610 "Fetch a URL and return clean text content (no browser needed). Args: url (required), max_chars (default 30000), timeout_secs (default 15)."
611 }
612 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
613 let url = args
614 .get("url")
615 .and_then(Value::as_str)
616 .ok_or_else(|| wm_core::CoreError::InvalidArgs("url is required".into()))?;
617 let max_chars = args
618 .get("max_chars")
619 .and_then(Value::as_u64)
620 .unwrap_or(30_000) as usize;
621 let timeout = args
624 .get("timeout_secs")
625 .and_then(Value::as_f64)
626 .unwrap_or(15.0)
627 .clamp(0.0, 300.0);
628 let url = safe_url(url)?;
629 let fetched = tokio::task::spawn_blocking(move || {
630 fetch_bounded(&url, max_chars, Duration::from_secs_f64(timeout))
631 })
632 .await
633 .map_err(|e| wm_core::CoreError::Tool(format!("web.fetch task: {e}")))??;
634 Ok(fetch_response(&fetched, fetched.content.len() >= max_chars))
635 }
636 fn stats(&self) -> &ToolStats {
637 &self.stats
638 }
639}
640
641pub struct WebDeepFetchTool {
645 stats: ToolStats,
646 effects: EffectRow,
647}
648
649impl WebDeepFetchTool {
650 #[must_use]
651 pub fn new() -> Self {
652 Self {
653 stats: ToolStats::default(),
654 effects: EffectRow::read_only(vec![Resource::Network]),
655 }
656 }
657}
658
659impl Default for WebDeepFetchTool {
660 fn default() -> Self {
661 Self::new()
662 }
663}
664
665#[async_trait]
666impl Tool for WebDeepFetchTool {
667 fn name(&self) -> &str {
668 "web.deep_fetch"
669 }
670 fn gana(&self) -> Gana {
671 Gana::Chariot
672 }
673 fn effects(&self) -> &EffectRow {
674 &self.effects
675 }
676 fn description(&self) -> &str {
677 "Fetch a URL with full-content retrieval (up to 200K chars, no chunk skimming). Args: url (required), max_chars (default 200000), timeout_secs (default 30)."
678 }
679 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
680 let url = args
681 .get("url")
682 .and_then(Value::as_str)
683 .ok_or_else(|| wm_core::CoreError::InvalidArgs("url is required".into()))?;
684 let max_chars = args
685 .get("max_chars")
686 .and_then(Value::as_u64)
687 .unwrap_or(200_000) as usize;
688 let timeout = args
689 .get("timeout_secs")
690 .and_then(Value::as_f64)
691 .unwrap_or(30.0)
692 .clamp(0.0, 300.0);
693 let url = safe_url(url)?;
694 let fetched = tokio::task::spawn_blocking(move || {
695 fetch_bounded(&url, max_chars, Duration::from_secs_f64(timeout))
696 })
697 .await
698 .map_err(|e| wm_core::CoreError::Tool(format!("web.deep_fetch task: {e}")))??;
699 Ok(fetch_response(&fetched, fetched.content.len() >= max_chars))
700 }
701 fn stats(&self) -> &ToolStats {
702 &self.stats
703 }
704}
705
706pub struct WebSearchTool {
710 stats: ToolStats,
711 effects: EffectRow,
712}
713
714impl WebSearchTool {
715 #[must_use]
716 pub fn new() -> Self {
717 Self {
718 stats: ToolStats::default(),
719 effects: EffectRow::read_only(vec![Resource::Network]),
720 }
721 }
722}
723
724impl Default for WebSearchTool {
725 fn default() -> Self {
726 Self::new()
727 }
728}
729
730#[async_trait]
731impl Tool for WebSearchTool {
732 fn name(&self) -> &str {
733 "web.search"
734 }
735 fn gana(&self) -> Gana {
736 Gana::Chariot
737 }
738 fn effects(&self) -> &EffectRow {
739 &self.effects
740 }
741 fn description(&self) -> &str {
742 "Search the web (Bing HTML, no API key needed). Args: query (required), num_results (default 8), timeout_secs (default 10)."
743 }
744 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
745 let query = args
746 .get("query")
747 .and_then(Value::as_str)
748 .ok_or_else(|| wm_core::CoreError::InvalidArgs("query is required".into()))?;
749 let num_results = args.get("num_results").and_then(Value::as_u64).unwrap_or(8) as usize;
750 let timeout = args
751 .get("timeout_secs")
752 .and_then(Value::as_f64)
753 .unwrap_or(10.0)
754 .clamp(0.0, 300.0);
755 let query = query.to_string();
756 let query_for_task = query.clone();
757 let results = tokio::task::spawn_blocking(move || {
758 web_search(
759 &query_for_task,
760 num_results,
761 Duration::from_secs_f64(timeout),
762 )
763 })
764 .await
765 .map_err(|e| wm_core::CoreError::Tool(format!("web.search task: {e}")))??;
766 let results: Vec<Value> = results
767 .into_iter()
768 .map(|r| json!({"url": r.url, "title": r.title, "snippet": r.snippet}))
769 .collect();
770 Ok(json!({
771 "status": "success",
772 "query": query,
773 "total_results": results.len(),
774 "results": results,
775 }))
776 }
777 fn stats(&self) -> &ToolStats {
778 &self.stats
779 }
780}
781
782pub struct WebSearchAndReadTool {
786 stats: ToolStats,
787 effects: EffectRow,
788}
789
790impl WebSearchAndReadTool {
791 #[must_use]
792 pub fn new() -> Self {
793 Self {
794 stats: ToolStats::default(),
795 effects: EffectRow::read_only(vec![Resource::Network]),
796 }
797 }
798}
799
800impl Default for WebSearchAndReadTool {
801 fn default() -> Self {
802 Self::new()
803 }
804}
805
806#[async_trait]
807impl Tool for WebSearchAndReadTool {
808 fn name(&self) -> &str {
809 "web.search_and_read"
810 }
811 fn gana(&self) -> Gana {
812 Gana::Chariot
813 }
814 fn effects(&self) -> &EffectRow {
815 &self.effects
816 }
817 fn description(&self) -> &str {
818 "Search the web AND fetch content from top results in one call. Args: query (required), num_results (default 5), max_fetch (default 3), max_chars_per_page (default 15000)."
819 }
820 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
821 let query = args
822 .get("query")
823 .and_then(Value::as_str)
824 .ok_or_else(|| wm_core::CoreError::InvalidArgs("query is required".into()))?;
825 let num_results = args.get("num_results").and_then(Value::as_u64).unwrap_or(5) as usize;
826 let max_fetch = args.get("max_fetch").and_then(Value::as_u64).unwrap_or(3) as usize;
827 let max_chars = args
828 .get("max_chars_per_page")
829 .and_then(Value::as_u64)
830 .unwrap_or(15_000) as usize;
831 let timeout = args
833 .get("timeout_secs")
834 .and_then(Value::as_f64)
835 .unwrap_or(15.0)
836 .clamp(0.0, 300.0);
837 let query = query.to_string();
838 let query_for_task = query.clone();
839 let results = tokio::task::spawn_blocking(move || {
840 web_search(
841 &query_for_task,
842 num_results,
843 Duration::from_secs_f64(timeout),
844 )
845 })
846 .await
847 .map_err(|e| wm_core::CoreError::Tool(format!("web.search_and_read task: {e}")))??;
848
849 let mut entries: Vec<Value> = results
850 .into_iter()
851 .map(|r| json!({"url": r.url, "title": r.title, "snippet": r.snippet, "content": null}))
852 .collect();
853
854 let mut fetched_count = 0usize;
855 for entry in &mut entries.iter_mut().take(max_fetch) {
856 let url = entry
857 .get("url")
858 .and_then(Value::as_str)
859 .unwrap_or_default()
860 .to_string();
861 if url.is_empty() || !is_url_safe(&url) {
862 continue;
863 }
864 let url_c = url.clone();
865 let max_c = max_chars;
866 let t = timeout;
867 if let Ok(Ok(fetched)) = tokio::task::spawn_blocking(move || {
868 fetch_bounded(&url_c, max_c, Duration::from_secs_f64(t))
869 })
870 .await
871 {
872 entry["content"] = json!(fetched.content);
873 entry["content_length"] = json!(fetched.content.len());
874 if entry["title"].as_str().unwrap_or_default().is_empty() {
875 entry["title"] = json!(fetched.title);
876 }
877 fetched_count += 1;
878 }
879 }
880
881 Ok(json!({
882 "status": "success",
883 "query": query,
884 "results": entries,
885 "total_results": entries.len(),
886 "fetched_count": fetched_count,
887 }))
888 }
889 fn stats(&self) -> &ToolStats {
890 &self.stats
891 }
892}
893
894#[must_use]
896pub fn register_web(registry: &wm_dispatch::ToolRegistry) -> wm_dispatch::ToolRegistry {
897 registry
898 .register(Arc::new(WebFetchTool::new()))
899 .register(Arc::new(WebDeepFetchTool::new()))
900 .register(Arc::new(WebSearchTool::new()))
901 .register(Arc::new(WebSearchAndReadTool::new()))
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907
908 #[tokio::test]
909 async fn negative_timeout_does_not_panic() {
910 let tool = WebFetchTool::new();
914 let result = tool
915 .call(
916 &mut Context::default(),
917 json!({"url": "http://127.0.0.1:1/never", "timeout_secs": -5.0}),
918 )
919 .await;
920 assert!(
921 result.is_err(),
922 "unroutable local URL should fail, not panic"
923 );
924 }
925
926 #[test]
927 fn html_stripping_removes_tags_and_scripts() {
928 let html = "<html><head><title>Test Page</title><script>var x=1;</script></head><body><h1>Hello</h1><p>World wide</p><div>One</div><div>Two</div></body></html>";
929 let text = strip_html(html);
930 assert!(text.contains("Hello"));
931 assert!(text.contains("World wide"));
932 assert!(text.contains("One"));
933 assert!(!text.contains("var x"));
934 assert!(!text.contains("<p>"));
935 }
936
937 #[test]
938 fn html_stripping_decodes_entities() {
939 assert_eq!(
940 strip_html("& <tag> "q" A B"),
941 "& <tag> \"q\" A B"
942 );
943 assert_eq!(strip_html("&unknown;"), "&unknown;");
944 assert_eq!(
947 strip_html("Hello&<script>var x=1;</script><p>World</p>"),
948 "Hello&\nWorld"
949 );
950 assert_eq!(strip_html("<p>Hello world</p>"), "Hello world");
952 assert_eq!(
954 strip_html("<script>var a = 1 && b;</script><p>x</p>"),
955 "x"
956 );
957 }
958
959 #[test]
960 fn title_extraction() {
961 assert_eq!(
962 extract_title("<html><title> My Page </title></html>"),
963 Some("My Page".to_string())
964 );
965 assert!(extract_title("<html><body>no title</body></html>").is_none());
966 }
967
968 #[test]
969 fn ddg_redirect_decodes_target() {
970 let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpage%3Fa%3D1&rut=abc";
971 assert_eq!(
972 ddg_target(href),
973 Some("https://example.com/page?a=1".to_string())
974 );
975 }
976
977 #[test]
978 fn bing_ck_a_decodes_target() {
979 let href = "https://www.bing.com/ck/a?!&&p=abc&u=a1aHR0cHM6Ly9ydXN0LWxhbmcub3JnLw&ntb=1";
981 assert_eq!(
982 bing_decode(href),
983 Some("https://rust-lang.org/".to_string())
984 );
985 let href2 = "https://www.bing.com/ck/a?u=a1aHR0cHM6Ly9leGFtcGxlLmNvbS8_YS1iX3M";
987 assert_eq!(
988 bing_decode(href2),
989 Some("https://example.com/?a-b_s".to_string())
990 );
991 assert_eq!(bing_decode("https://www.bing.com/ck/a?p=1"), None);
992 }
993
994 #[test]
995 fn resolve_url_handles_relative_and_protocol() {
996 assert_eq!(
997 resolve_url("https://example.com/a/b", "/c"),
998 "https://example.com/c"
999 );
1000 assert_eq!(
1001 resolve_url("https://example.com/a/b", "c.html"),
1002 "https://example.com/a/c.html"
1003 );
1004 assert_eq!(
1005 resolve_url("http://example.com/x", "//other.com/y"),
1006 "http://other.com/y"
1007 );
1008 assert_eq!(
1009 resolve_url("https://example.com/x", "https://other.com/y"),
1010 "https://other.com/y"
1011 );
1012 }
1013
1014 #[test]
1015 fn ssrf_guard_rejects_private_and_non_http() {
1016 assert!(safe_url("http://169.254.169.254/latest/meta-data").is_err());
1017 assert!(safe_url("file:///etc/passwd").is_err());
1018 assert!(safe_url("https://example.com").is_ok());
1019 }
1020
1021 #[test]
1022 fn tool_declarations() {
1023 assert_eq!(WebFetchTool::new().name(), "web.fetch");
1024 assert_eq!(WebSearchTool::new().name(), "web.search");
1025 assert_eq!(WebDeepFetchTool::new().name(), "web.deep_fetch");
1026 assert_eq!(WebSearchAndReadTool::new().name(), "web.search_and_read");
1027 let tools: Vec<Box<dyn Tool>> = vec![
1028 Box::new(WebFetchTool::new()),
1029 Box::new(WebSearchTool::new()),
1030 Box::new(WebDeepFetchTool::new()),
1031 Box::new(WebSearchAndReadTool::new()),
1032 ];
1033 for tool in tools {
1034 assert_eq!(tool.gana(), Gana::Chariot);
1035 assert!(tool.effects().writes.is_empty());
1036 assert!(!tool.effects().destructive);
1037 assert_eq!(tool.effects().reads.len(), 1);
1038 assert_eq!(tool.effects().reads[0], Resource::Network);
1039 }
1040 }
1041
1042 #[tokio::test]
1043 async fn fetch_requires_url() {
1044 let tool = WebFetchTool::new();
1045 let mut ctx = Context::default();
1046 let result = tool.call(&mut ctx, json!({})).await;
1047 assert!(result.is_err());
1048 }
1049
1050 #[tokio::test]
1051 async fn search_requires_query() {
1052 let tool = WebSearchTool::new();
1053 let mut ctx = Context::default();
1054 let result = tool.call(&mut ctx, json!({})).await;
1055 assert!(result.is_err());
1056 }
1057}