1use async_trait::async_trait;
15use base64::{engine::general_purpose, Engine as _};
16use regex::Regex;
17use reqwest::Client;
18use serde_json::{json, Value};
19use std::sync::OnceLock;
20use std::time::Duration;
21
22use super::Tool;
23use crate::acp::ToolKind;
24use crate::error::{Error, Result};
25use crate::llm::ToolSpec;
26
27const REQUEST_TIMEOUT_SECS: u64 = 15;
28const CONNECT_TIMEOUT_SECS: u64 = 5;
29const DEFAULT_NUM_RESULTS: u64 = 5;
30const MAX_NUM_RESULTS: u64 = 10;
31
32const BRAVE_BASE: &str = "https://api.search.brave.com";
34const TAVILY_BASE: &str = "https://api.tavily.com";
35const SERPER_BASE: &str = "https://google.serper.dev";
36const BOCHA_BASE: &str = "https://api.bochaai.com";
37const BING_BASE: &str = "https://api.bing.microsoft.com";
38const DUCKDUCKGO_HTML_BASE: &str = "https://html.duckduckgo.com";
39const BING_HTML_BASE: &str = "https://www.bing.com";
40const BROWSER_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
42
43#[derive(Debug, Clone, PartialEq)]
45pub(crate) enum Provider {
46 Brave,
47 Tavily,
48 Serper,
49 Bocha,
50 Bing,
51}
52
53impl Provider {
54 pub(crate) fn from_str(s: &str) -> Option<Self> {
60 match s.to_lowercase().as_str() {
61 "brave" => Some(Self::Brave),
62 "tavily" => Some(Self::Tavily),
63 "serper" => Some(Self::Serper),
64 "bocha" => Some(Self::Bocha),
65 "bing" => Some(Self::Bing),
66 _ => None,
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
72pub struct WebSearch {
73 client: Client,
74 test_base_url: Option<String>,
78 provider_override: Option<String>,
81 api_key_override: Option<String>,
84 jina_key_override: Option<String>,
87}
88
89impl Default for WebSearch {
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95impl WebSearch {
96 pub fn new() -> Self {
97 let client = Client::builder()
98 .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
99 .connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS))
100 .user_agent(format!("recursive-agent/{}", env!("CARGO_PKG_VERSION")))
101 .build();
106 #[allow(
107 clippy::expect_used,
108 reason = "TLS backend unavailable is a fatal startup error"
109 )]
110 let client = client.expect("reqwest client build: TLS backend unavailable");
111 Self {
112 client,
113 test_base_url: None,
114 provider_override: None,
115 api_key_override: None,
116 jina_key_override: None,
117 }
118 }
119
120 pub fn with_search_config(
123 mut self,
124 provider: Option<String>,
125 api_key: Option<String>,
126 jina_key: Option<String>,
127 ) -> Self {
128 self.provider_override = provider;
129 self.api_key_override = api_key;
130 self.jina_key_override = jina_key;
131 self
132 }
133
134 #[cfg(test)]
136 fn with_test_base(base_url: impl Into<String>) -> Self {
137 let client = Client::builder()
138 .timeout(Duration::from_secs(2))
139 .connect_timeout(Duration::from_secs(1))
140 .user_agent("recursive-test")
141 .build()
142 .expect("reqwest client build");
143 Self {
144 client,
145 test_base_url: Some(base_url.into()),
146 provider_override: None,
147 api_key_override: None,
148 jina_key_override: None,
149 }
150 }
151
152 fn base_url(&self, default: &str) -> String {
154 self.test_base_url
155 .as_deref()
156 .unwrap_or(default)
157 .trim_end_matches('/')
158 .to_string()
159 }
160
161 fn load_config(&self) -> Option<(Provider, String)> {
163 let provider_str = self
165 .provider_override
166 .clone()
167 .or_else(|| std::env::var("RECURSIVE_WEB_SEARCH_PROVIDER").ok())
168 .filter(|s| !s.is_empty())?;
169 let api_key = self
170 .api_key_override
171 .clone()
172 .or_else(|| std::env::var("RECURSIVE_WEB_SEARCH_API_KEY").ok())
173 .filter(|s| !s.is_empty())?;
174 let provider = Provider::from_str(&provider_str)?;
175 Some((provider, api_key))
176 }
177
178 async fn search_brave(
179 &self,
180 query: &str,
181 num: u64,
182 api_key: &str,
183 ) -> Result<Vec<SearchResult>> {
184 let url = format!("{}/res/v1/web/search", self.base_url(BRAVE_BASE));
185 let resp = self
186 .client
187 .get(&url)
188 .header("Accept", "application/json")
189 .header("X-Subscription-Token", api_key)
190 .query(&[("q", query), ("count", &num.to_string())])
191 .send()
192 .await
193 .map_err(|e| Error::Tool {
194 name: "WebSearch".into(),
195 call_id: None,
196 message: format!("Brave request failed: {e}"),
197 })?;
198
199 let status = resp.status();
200 let body_text = resp.text().await.map_err(|e| Error::Tool {
201 name: "WebSearch".into(),
202 call_id: None,
203 message: format!("Brave response read failed: {e}"),
204 })?;
205
206 if !status.is_success() {
207 return Err(Error::Tool {
208 name: "WebSearch".into(),
209 call_id: None,
210 message: format!("Brave HTTP {status}: {body_text}"),
211 });
212 }
213
214 #[allow(clippy::unwrap_in_result)]
217 let data: Value = serde_json::from_str(&body_text).map_err(|e| Error::Tool {
218 name: "WebSearch".into(),
219 call_id: None,
220 message: format!("Brave response parse failed: {e}"),
221 })?;
222
223 let results = data["web"]["results"]
224 .as_array()
225 .cloned()
226 .unwrap_or_default();
227
228 Ok(results
229 .iter()
230 .map(|r| SearchResult {
231 title: r["title"].as_str().unwrap_or("").to_string(),
232 url: r["url"].as_str().unwrap_or("").to_string(),
233 snippet: r["description"].as_str().unwrap_or("").to_string(),
234 })
235 .collect())
236 }
237
238 async fn search_tavily(
239 &self,
240 query: &str,
241 num: u64,
242 api_key: &str,
243 ) -> Result<Vec<SearchResult>> {
244 let url = format!("{}/search", self.base_url(TAVILY_BASE));
245 let body = json!({
246 "api_key": api_key,
247 "query": query,
248 "max_results": num,
249 "search_depth": "basic",
250 "include_answer": false,
251 });
252
253 let resp = self
254 .client
255 .post(&url)
256 .json(&body)
257 .send()
258 .await
259 .map_err(|e| Error::Tool {
260 name: "WebSearch".into(),
261 call_id: None,
262 message: format!("Tavily request failed: {e}"),
263 })?;
264
265 let status = resp.status();
266 let body_text = resp.text().await.map_err(|e| Error::Tool {
267 name: "WebSearch".into(),
268 call_id: None,
269 message: format!("Tavily response read failed: {e}"),
270 })?;
271
272 if !status.is_success() {
273 return Err(Error::Tool {
274 name: "WebSearch".into(),
275 call_id: None,
276 message: format!("Tavily HTTP {status}: {body_text}"),
277 });
278 }
279
280 #[allow(clippy::unwrap_in_result)]
282 let data: Value = serde_json::from_str(&body_text).map_err(|e| Error::Tool {
283 name: "WebSearch".into(),
284 call_id: None,
285 message: format!("Tavily response parse failed: {e}"),
286 })?;
287
288 let results = data["results"].as_array().cloned().unwrap_or_default();
289 Ok(results
290 .iter()
291 .map(|r| SearchResult {
292 title: r["title"].as_str().unwrap_or("").to_string(),
293 url: r["url"].as_str().unwrap_or("").to_string(),
294 snippet: r["content"].as_str().unwrap_or("").to_string(),
295 })
296 .collect())
297 }
298
299 async fn search_serper(
300 &self,
301 query: &str,
302 num: u64,
303 api_key: &str,
304 ) -> Result<Vec<SearchResult>> {
305 let url = format!("{}/search", self.base_url(SERPER_BASE));
306 let body = json!({
307 "q": query,
308 "num": num,
309 });
310
311 let resp = self
312 .client
313 .post(&url)
314 .header("X-API-KEY", api_key)
315 .header("Content-Type", "application/json")
316 .json(&body)
317 .send()
318 .await
319 .map_err(|e| Error::Tool {
320 name: "WebSearch".into(),
321 call_id: None,
322 message: format!("Serper request failed: {e}"),
323 })?;
324
325 let status = resp.status();
326 let body_text = resp.text().await.map_err(|e| Error::Tool {
327 name: "WebSearch".into(),
328 call_id: None,
329 message: format!("Serper response read failed: {e}"),
330 })?;
331
332 if !status.is_success() {
333 return Err(Error::Tool {
334 name: "WebSearch".into(),
335 call_id: None,
336 message: format!("Serper HTTP {status}: {body_text}"),
337 });
338 }
339
340 #[allow(clippy::unwrap_in_result)]
342 let data: Value = serde_json::from_str(&body_text).map_err(|e| Error::Tool {
343 name: "WebSearch".into(),
344 call_id: None,
345 message: format!("Serper response parse failed: {e}"),
346 })?;
347
348 let results = data["organic"].as_array().cloned().unwrap_or_default();
349 Ok(results
350 .iter()
351 .take(num as usize)
352 .map(|r| SearchResult {
353 title: r["title"].as_str().unwrap_or("").to_string(),
354 url: r["link"].as_str().unwrap_or("").to_string(),
355 snippet: r["snippet"].as_str().unwrap_or("").to_string(),
356 })
357 .collect())
358 }
359
360 async fn search_bocha(
361 &self,
362 query: &str,
363 num: u64,
364 api_key: &str,
365 ) -> Result<Vec<SearchResult>> {
366 let url = format!("{}/v1/web-search", self.base_url(BOCHA_BASE));
367 let body = json!({
368 "query": query,
369 "count": num,
370 });
371
372 let resp = self
373 .client
374 .post(&url)
375 .header("Authorization", format!("Bearer {api_key}"))
376 .header("Content-Type", "application/json")
377 .json(&body)
378 .send()
379 .await
380 .map_err(|e| Error::Tool {
381 name: "WebSearch".into(),
382 call_id: None,
383 message: format!("Bocha request failed: {e}"),
384 })?;
385
386 let status = resp.status();
387 let body_text = resp.text().await.map_err(|e| Error::Tool {
388 name: "WebSearch".into(),
389 call_id: None,
390 message: format!("Bocha response read failed: {e}"),
391 })?;
392
393 if !status.is_success() {
394 return Err(Error::Tool {
395 name: "WebSearch".into(),
396 call_id: None,
397 message: format!("Bocha HTTP {status}: {body_text}"),
398 });
399 }
400
401 #[allow(clippy::unwrap_in_result)]
403 let data: Value = serde_json::from_str(&body_text).map_err(|e| Error::Tool {
404 name: "WebSearch".into(),
405 call_id: None,
406 message: format!("Bocha response parse failed: {e}"),
407 })?;
408
409 let results = data["data"]["webPages"]["value"]
411 .as_array()
412 .cloned()
413 .unwrap_or_default();
414
415 Ok(results
416 .iter()
417 .map(|r| SearchResult {
418 title: r["name"].as_str().unwrap_or("").to_string(),
419 url: r["url"].as_str().unwrap_or("").to_string(),
420 snippet: r["snippet"].as_str().unwrap_or("").to_string(),
421 })
422 .collect())
423 }
424
425 async fn search_bing(&self, query: &str, num: u64, api_key: &str) -> Result<Vec<SearchResult>> {
426 let url = format!("{}/v7.0/search", self.base_url(BING_BASE));
427 let resp = self
428 .client
429 .get(&url)
430 .header("Ocp-Apim-Subscription-Key", api_key)
431 .query(&[("q", query), ("count", &num.to_string())])
432 .send()
433 .await
434 .map_err(|e| Error::Tool {
435 name: "WebSearch".into(),
436 call_id: None,
437 message: format!("Bing request failed: {e}"),
438 })?;
439
440 let status = resp.status();
441 let body_text = resp.text().await.map_err(|e| Error::Tool {
442 name: "WebSearch".into(),
443 call_id: None,
444 message: format!("Bing response read failed: {e}"),
445 })?;
446
447 if !status.is_success() {
448 return Err(Error::Tool {
449 name: "WebSearch".into(),
450 call_id: None,
451 message: format!("Bing HTTP {status}: {body_text}"),
452 });
453 }
454
455 #[allow(clippy::unwrap_in_result)]
457 let data: Value = serde_json::from_str(&body_text).map_err(|e| Error::Tool {
458 name: "WebSearch".into(),
459 call_id: None,
460 message: format!("Bing response parse failed: {e}"),
461 })?;
462
463 let results = data["webPages"]["value"]
464 .as_array()
465 .cloned()
466 .unwrap_or_default();
467
468 Ok(results
469 .iter()
470 .map(|r| SearchResult {
471 title: r["name"].as_str().unwrap_or("").to_string(),
472 url: r["url"].as_str().unwrap_or("").to_string(),
473 snippet: r["snippet"].as_str().unwrap_or("").to_string(),
474 })
475 .collect())
476 }
477
478 async fn search_html_fallback(&self, query: &str, num: u64) -> Result<Vec<SearchResult>> {
484 match self.search_duckduckgo_html(query, num).await {
485 Ok(results) if !results.is_empty() => return Ok(results),
486 Ok(_) => {}
487 Err(err) => {
488 tracing::debug!(error = %err, "DuckDuckGo HTML search failed; trying Bing");
489 }
490 }
491
492 match self.search_bing_html(query, num).await {
493 Ok(results) if !results.is_empty() => Ok(results),
494 Ok(_) => Ok(Vec::new()),
495 Err(err) => {
496 tracing::debug!(error = %err, "Bing HTML search failed");
497 Ok(Vec::new())
498 }
499 }
500 }
501
502 async fn search_duckduckgo_html(&self, query: &str, num: u64) -> Result<Vec<SearchResult>> {
503 let base = self.base_url(DUCKDUCKGO_HTML_BASE);
504 let url = format!("{base}/html/");
505 let resp = self
506 .client
507 .get(&url)
508 .query(&[("q", query)])
509 .header("User-Agent", BROWSER_USER_AGENT)
510 .header(
511 "Accept",
512 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
513 )
514 .header("Accept-Language", "en-US,en;q=0.5")
515 .send()
516 .await
517 .map_err(|e| Error::Tool {
518 name: "WebSearch".into(),
519 call_id: None,
520 message: format!("DuckDuckGo HTML request failed: {e}"),
521 })?;
522
523 let status = resp.status();
524 let body = resp.text().await.map_err(|e| Error::Tool {
525 name: "WebSearch".into(),
526 call_id: None,
527 message: format!("DuckDuckGo HTML response read failed: {e}"),
528 })?;
529
530 if !status.is_success() {
531 return Err(Error::Tool {
532 name: "WebSearch".into(),
533 call_id: None,
534 message: format!("DuckDuckGo HTML HTTP {status}"),
535 });
536 }
537
538 if is_duckduckgo_challenge(&body) {
539 return Ok(Vec::new());
540 }
541
542 Ok(parse_duckduckgo_results(&body, num as usize))
543 }
544
545 async fn search_bing_html(&self, query: &str, num: u64) -> Result<Vec<SearchResult>> {
546 let base = self.base_url(BING_HTML_BASE);
547 let url = format!("{base}/search");
548 let resp = self
549 .client
550 .get(&url)
551 .query(&[("q", query)])
552 .header("User-Agent", BROWSER_USER_AGENT)
553 .header(
554 "Accept",
555 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
556 )
557 .header("Accept-Language", "en-US,en;q=0.5")
558 .send()
559 .await
560 .map_err(|e| Error::Tool {
561 name: "WebSearch".into(),
562 call_id: None,
563 message: format!("Bing HTML request failed: {e}"),
564 })?;
565
566 let status = resp.status();
567 let body = resp.text().await.map_err(|e| Error::Tool {
568 name: "WebSearch".into(),
569 call_id: None,
570 message: format!("Bing HTML response read failed: {e}"),
571 })?;
572
573 if !status.is_success() {
574 return Err(Error::Tool {
575 name: "WebSearch".into(),
576 call_id: None,
577 message: format!("Bing HTML HTTP {status}"),
578 });
579 }
580
581 Ok(parse_bing_results(&body, num as usize))
582 }
583
584 async fn search_jina_fallback(&self, query: &str) -> Result<String> {
590 let jina_base = self
591 .test_base_url
592 .as_deref()
593 .unwrap_or("https://s.jina.ai")
594 .trim_end_matches('/');
595
596 let encoded: String = query
598 .chars()
599 .flat_map(|c| {
600 if c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '~' | ' ') {
601 if c == ' ' {
602 vec!['+']
603 } else {
604 vec![c]
605 }
606 } else {
607 format!("%{:02X}", c as u32).chars().collect()
609 }
610 })
611 .collect();
612
613 let url = format!("{jina_base}/{encoded}");
614
615 let mut req = self
616 .client
617 .get(&url)
618 .header("Accept", "text/markdown")
619 .header("X-No-Cache", "true");
620
621 let jina_key = self
623 .jina_key_override
624 .clone()
625 .or_else(|| std::env::var("RECURSIVE_WEB_SEARCH_JINA_KEY").ok())
626 .filter(|k| !k.is_empty());
627 if let Some(key) = jina_key {
628 req = req.header("Authorization", format!("Bearer {key}"));
629 }
630
631 let resp = req.send().await.map_err(|e| Error::Tool {
632 name: "WebSearch".into(),
633 call_id: None,
634 message: format!("Jina Search request failed: {e}"),
635 })?;
636
637 let status = resp.status();
638 let body = resp.text().await.map_err(|e| Error::Tool {
639 name: "WebSearch".into(),
640 call_id: None,
641 message: format!("Jina Search response read failed: {e}"),
642 })?;
643
644 if !status.is_success() {
645 return Err(Error::Tool {
646 name: "WebSearch".into(),
647 call_id: None,
648 message: format!("Jina Search HTTP {status}"),
649 });
650 }
651
652 const JINA_MAX_CHARS: usize = 4096;
654 if body.len() > JINA_MAX_CHARS {
655 let mut end = JINA_MAX_CHARS;
656 while !body.is_char_boundary(end) {
657 end -= 1;
658 }
659 Ok(format!(
660 "{}\n\n[...truncated at {JINA_MAX_CHARS} chars]",
661 &body[..end]
662 ))
663 } else {
664 Ok(body)
665 }
666 }
667
668 fn format_results(results: &[SearchResult]) -> String {
670 if results.is_empty() {
671 return "No results found.".to_string();
672 }
673 results
674 .iter()
675 .enumerate()
676 .map(|(i, r)| {
677 format!(
678 "{}. Title: {}\n URL: {}\n Summary: {}",
679 i + 1,
680 r.title,
681 r.url,
682 r.snippet
683 )
684 })
685 .collect::<Vec<_>>()
686 .join("\n\n")
687 }
688}
689
690#[derive(Debug)]
691struct SearchResult {
692 title: String,
693 url: String,
694 snippet: String,
695}
696
697static DDG_TITLE_RE: OnceLock<Regex> = OnceLock::new();
700static DDG_SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
701static TAG_RE: OnceLock<Regex> = OnceLock::new();
702static BING_RESULT_RE: OnceLock<Regex> = OnceLock::new();
703static BING_TITLE_RE: OnceLock<Regex> = OnceLock::new();
704static BING_SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
705
706fn ddg_title_re() -> &'static Regex {
707 DDG_TITLE_RE.get_or_init(|| {
708 #[allow(
709 clippy::expect_used,
710 reason = "static regex pattern is a compile-time constant"
711 )]
712 Regex::new(r#"<a[^>]*class=\"result__a\"[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>"#)
713 .expect("ddg title regex")
714 })
715}
716
717fn ddg_snippet_re() -> &'static Regex {
718 DDG_SNIPPET_RE.get_or_init(|| {
719 #[allow(
720 clippy::expect_used,
721 reason = "static regex pattern is a compile-time constant"
722 )]
723 Regex::new(
724 r#"<a[^>]*class=\"result__snippet\"[^>]*>(.*?)</a>|<div[^>]*class=\"result__snippet\"[^>]*>(.*?)</div>"#,
725 )
726 .expect("ddg snippet regex")
727 })
728}
729
730fn tag_re() -> &'static Regex {
731 TAG_RE.get_or_init(|| {
732 #[allow(
733 clippy::expect_used,
734 reason = "static regex pattern is a compile-time constant"
735 )]
736 Regex::new(r"<[^>]+>").expect("tag regex")
737 })
738}
739
740fn bing_result_re() -> &'static Regex {
741 BING_RESULT_RE.get_or_init(|| {
742 #[allow(
743 clippy::expect_used,
744 reason = "static regex pattern is a compile-time constant"
745 )]
746 Regex::new(r#"(?is)<li[^>]*class=\"[^\"]*\bb_algo\b[^\"]*\"[^>]*>(.*?)</li>"#)
747 .expect("bing result regex")
748 })
749}
750
751fn bing_title_re() -> &'static Regex {
752 BING_TITLE_RE.get_or_init(|| {
753 #[allow(
754 clippy::expect_used,
755 reason = "static regex pattern is a compile-time constant"
756 )]
757 Regex::new(r#"(?is)<h2[^>]*>.*?<a[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>"#)
758 .expect("bing title regex")
759 })
760}
761
762fn bing_snippet_re() -> &'static Regex {
763 BING_SNIPPET_RE.get_or_init(|| {
764 #[allow(
765 clippy::expect_used,
766 reason = "static regex pattern is a compile-time constant"
767 )]
768 Regex::new(r#"(?is)<div[^>]*class=\"[^\"]*\bb_caption\b[^\"]*\"[^>]*>.*?<p[^>]*>(.*?)</p>"#)
769 .expect("bing snippet regex")
770 })
771}
772
773fn is_duckduckgo_challenge(html: &str) -> bool {
774 html.contains("anomaly-modal") || html.contains("Unfortunately, bots use DuckDuckGo too")
775}
776
777fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec<SearchResult> {
778 let snippets: Vec<String> = ddg_snippet_re()
779 .captures_iter(html)
780 .filter_map(|cap| cap.get(1).or_else(|| cap.get(2)))
781 .map(|m| normalize_text(m.as_str()))
782 .collect();
783
784 let mut results = Vec::new();
785 for (idx, cap) in ddg_title_re().captures_iter(html).enumerate() {
786 if results.len() >= max_results {
787 break;
788 }
789 let href = cap.get(1).map(|m| m.as_str()).unwrap_or("");
790 let title = normalize_text(cap.get(2).map(|m| m.as_str()).unwrap_or(""));
791 if title.is_empty() {
792 continue;
793 }
794 let snippet = snippets
795 .get(idx)
796 .cloned()
797 .filter(|s| !s.is_empty())
798 .unwrap_or_default();
799 results.push(SearchResult {
800 title,
801 url: normalize_ddg_url(href),
802 snippet,
803 });
804 }
805
806 if is_likely_spam_results(&results) {
807 return Vec::new();
808 }
809 results
810}
811
812fn parse_bing_results(html: &str, max_results: usize) -> Vec<SearchResult> {
813 let mut results = Vec::new();
814 for cap in bing_result_re().captures_iter(html) {
815 if results.len() >= max_results {
816 break;
817 }
818 let Some(block) = cap.get(1).map(|m| m.as_str()) else {
819 continue;
820 };
821 let Some(title_cap) = bing_title_re().captures(block) else {
822 continue;
823 };
824 let href = title_cap.get(1).map(|m| m.as_str()).unwrap_or("");
825 let title = normalize_text(title_cap.get(2).map(|m| m.as_str()).unwrap_or(""));
826 if title.is_empty() {
827 continue;
828 }
829 let snippet = bing_snippet_re()
830 .captures(block)
831 .and_then(|c| c.get(1))
832 .map(|m| normalize_text(m.as_str()))
833 .filter(|s| !s.is_empty())
834 .unwrap_or_default();
835 results.push(SearchResult {
836 title,
837 url: normalize_bing_url(href),
838 snippet,
839 });
840 }
841
842 if is_likely_spam_results(&results) {
843 return Vec::new();
844 }
845 results
846}
847
848fn is_likely_spam_results(results: &[SearchResult]) -> bool {
850 if results.len() < 3 {
851 return false;
852 }
853 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
854 for r in results {
855 if let Some(host) = root_domain(&r.url) {
856 *counts.entry(host).or_insert(0) += 1;
857 }
858 }
859 let Some(&max) = counts.values().max() else {
860 return false;
861 };
862 max * 5 >= results.len() * 3
863}
864
865fn root_domain(url: &str) -> Option<String> {
866 let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
867 let host = after_scheme.split(['/', '?', '#']).next()?;
868 let host = host.split('@').next_back()?;
869 let host = host.split(':').next()?.to_ascii_lowercase();
870 if host.is_empty() {
871 return None;
872 }
873 let labels: Vec<&str> = host.split('.').filter(|s| !s.is_empty()).collect();
874 if labels.len() <= 2 {
875 return Some(host);
876 }
877 Some(labels[labels.len().saturating_sub(2)..].join("."))
878}
879
880fn normalize_text(text: &str) -> String {
881 let stripped = tag_re().replace_all(text, "").to_string();
882 let decoded = decode_html_entities(&stripped);
883 decoded.split_whitespace().collect::<Vec<_>>().join(" ")
884}
885
886fn decode_html_entities(text: &str) -> String {
887 static ENTITY_RE: OnceLock<Regex> = OnceLock::new();
888 let re = ENTITY_RE.get_or_init(|| {
889 #[allow(
890 clippy::expect_used,
891 reason = "static regex pattern is a compile-time constant"
892 )]
893 Regex::new(r"&(?:#(\d+)|#x([0-9A-Fa-f]+)|([a-zA-Z]+));").expect("HTML entity regex")
894 });
895
896 re.replace_all(text, |caps: ®ex::Captures| {
897 if let Some(dec) = caps.get(1) {
898 return dec
899 .as_str()
900 .parse::<u32>()
901 .ok()
902 .and_then(std::char::from_u32)
903 .unwrap_or('\u{FFFD}')
904 .to_string();
905 }
906 if let Some(hex) = caps.get(2) {
907 return u32::from_str_radix(hex.as_str(), 16)
908 .ok()
909 .and_then(std::char::from_u32)
910 .unwrap_or('\u{FFFD}')
911 .to_string();
912 }
913 match caps.get(3).map(|m| m.as_str()) {
914 Some("amp") => "&".to_string(),
915 Some("lt") => "<".to_string(),
916 Some("gt") => ">".to_string(),
917 Some("quot") => "\"".to_string(),
918 Some("apos") => "'".to_string(),
919 Some("nbsp") => " ".to_string(),
920 Some("mdash") => "\u{2014}".to_string(),
921 Some("ndash") => "\u{2013}".to_string(),
922 Some("hellip") => "\u{2026}".to_string(),
923 _ => caps.get(0).map(|m| m.as_str()).unwrap_or("").to_string(),
924 }
925 })
926 .to_string()
927}
928
929fn percent_decode(input: &str) -> String {
930 let bytes = input.as_bytes();
931 let mut out = Vec::new();
932 let mut i = 0;
933 while i < bytes.len() {
934 match bytes[i] {
935 b'%' if i + 2 < bytes.len() => {
936 let hex = &input[i + 1..i + 3];
937 if let Ok(val) = u8::from_str_radix(hex, 16) {
938 out.push(val);
939 i += 3;
940 continue;
941 }
942 out.push(bytes[i]);
943 }
944 b'+' => out.push(b' '),
945 c => out.push(c),
946 }
947 i += 1;
948 }
949 String::from_utf8_lossy(&out).to_string()
950}
951
952fn extract_query_param(url: &str, key: &str) -> Option<String> {
953 let query = url.split_once('?')?.1;
954 for part in query.split('&') {
955 let mut iter = part.splitn(2, '=');
956 let name = iter.next().unwrap_or("");
957 if name == key {
958 return iter.next().map(str::to_string);
959 }
960 }
961 None
962}
963
964fn normalize_ddg_url(href: &str) -> String {
965 if let Some(uddg) = extract_query_param(href, "uddg") {
966 let decoded = percent_decode(&uddg);
967 if !decoded.is_empty() {
968 return decoded;
969 }
970 }
971 if href.starts_with("//") {
972 return format!("https:{href}");
973 }
974 if href.starts_with('/') {
975 return format!("https://duckduckgo.com{href}");
976 }
977 href.to_string()
978}
979
980fn normalize_bing_url(href: &str) -> String {
981 let href = decode_html_entities(href);
984 let href = href.as_str();
985 if let Some(encoded) = extract_query_param(href, "u") {
986 let decoded = percent_decode(&encoded);
987 let token = decoded.strip_prefix("a1").unwrap_or(&decoded);
988 let mut padded = token.replace('-', "+").replace('_', "/");
989 while padded.len() % 4 != 0 {
990 padded.push('=');
991 }
992 if let Ok(bytes) = general_purpose::STANDARD.decode(padded) {
993 if let Ok(url) = String::from_utf8(bytes) {
994 if url.starts_with("http://") || url.starts_with("https://") {
995 return url;
996 }
997 }
998 }
999 }
1000 if href.starts_with("//") {
1001 return format!("https:{href}");
1002 }
1003 if href.starts_with('/') {
1004 return format!("https://www.bing.com{href}");
1005 }
1006 href.to_string()
1007}
1008
1009#[async_trait]
1010impl Tool for WebSearch {
1011 fn spec(&self) -> ToolSpec {
1012 ToolSpec {
1013 name: "WebSearch".into(),
1014 description: "Search the web and return relevant results. When \
1015 RECURSIVE_WEB_SEARCH_PROVIDER and RECURSIVE_WEB_SEARCH_API_KEY are \
1016 set, returns a structured list (title, URL, summary) via the \
1017 configured provider (brave | tavily | serper | bocha | bing). \
1018 When no provider is configured, tries DuckDuckGo HTML then Bing \
1019 HTML (zero-config scrape), then falls back to Jina AI Search \
1020 (Markdown). Optionally set RECURSIVE_WEB_SEARCH_JINA_KEY for a \
1021 higher Jina quota."
1022 .into(),
1023 parameters: json!({
1024 "type": "object",
1025 "properties": {
1026 "query": {
1027 "type": "string",
1028 "description": "The search query."
1029 },
1030 "num_results": {
1031 "type": "integer",
1032 "description": "Number of results to return (1-10, default 5)."
1033 }
1034 },
1035 "required": ["query"]
1036 }),
1037 }
1038 }
1039
1040 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
1041 crate::tools::ToolSideEffect::ReadOnly
1042 }
1043
1044 fn kind(&self) -> ToolKind {
1045 ToolKind::WebSearch
1046 }
1047
1048 async fn execute(&self, args: Value) -> Result<String> {
1049 let query = args["query"].as_str().ok_or_else(|| Error::BadToolArgs {
1050 name: "WebSearch".into(),
1051 message: "missing `query`".into(),
1052 })?;
1053
1054 if query.trim().is_empty() {
1055 return Err(Error::BadToolArgs {
1056 name: "WebSearch".into(),
1057 message: "`query` must not be empty".into(),
1058 });
1059 }
1060
1061 let num = args
1062 .get("num_results")
1063 .and_then(|v| v.as_u64())
1064 .unwrap_or(DEFAULT_NUM_RESULTS)
1065 .clamp(1, MAX_NUM_RESULTS);
1066
1067 let Some((provider, api_key)) = self.load_config() else {
1069 let html_results = self.search_html_fallback(query, num).await?;
1070 if !html_results.is_empty() {
1071 return Ok(Self::format_results(&html_results));
1072 }
1073 return self.search_jina_fallback(query).await;
1074 };
1075
1076 let results = match provider {
1077 Provider::Brave => self.search_brave(query, num, &api_key).await?,
1078 Provider::Tavily => self.search_tavily(query, num, &api_key).await?,
1079 Provider::Serper => self.search_serper(query, num, &api_key).await?,
1080 Provider::Bocha => self.search_bocha(query, num, &api_key).await?,
1081 Provider::Bing => self.search_bing(query, num, &api_key).await?,
1082 };
1083
1084 Ok(Self::format_results(&results))
1085 }
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091
1092 #[test]
1095 fn provider_from_str_recognises_all() {
1096 assert_eq!(Provider::from_str("brave"), Some(Provider::Brave));
1097 assert_eq!(Provider::from_str("BRAVE"), Some(Provider::Brave));
1098 assert_eq!(Provider::from_str("tavily"), Some(Provider::Tavily));
1099 assert_eq!(Provider::from_str("serper"), Some(Provider::Serper));
1100 assert_eq!(Provider::from_str("bocha"), Some(Provider::Bocha));
1101 assert_eq!(Provider::from_str("bing"), Some(Provider::Bing));
1102 assert_eq!(Provider::from_str("unknown"), None);
1103 }
1104
1105 #[test]
1106 fn format_results_empty() {
1107 assert_eq!(WebSearch::format_results(&[]), "No results found.");
1108 }
1109
1110 #[test]
1111 fn format_results_numbered() {
1112 let results = vec![
1113 SearchResult {
1114 title: "Rust lang".to_string(),
1115 url: "https://rust-lang.org".to_string(),
1116 snippet: "A systems programming language.".to_string(),
1117 },
1118 SearchResult {
1119 title: "Cargo docs".to_string(),
1120 url: "https://doc.rust-lang.org/cargo/".to_string(),
1121 snippet: "Rust's package manager.".to_string(),
1122 },
1123 ];
1124 let out = WebSearch::format_results(&results);
1125 assert!(out.contains("1. Title: Rust lang"));
1126 assert!(out.contains("URL: https://rust-lang.org"));
1127 assert!(out.contains("2. Title: Cargo docs"));
1128 }
1129
1130 #[tokio::test]
1131 async fn rejects_empty_query() {
1132 let tool = WebSearch::new();
1133 let err = tool.execute(json!({"query": ""})).await.unwrap_err();
1134 assert!(err.to_string().contains("must not be empty"));
1135 }
1136
1137 #[tokio::test]
1138 async fn rejects_missing_query() {
1139 let tool = WebSearch::new();
1140 let err = tool.execute(json!({})).await.unwrap_err();
1141 assert!(err.to_string().contains("missing `query`"));
1142 }
1143
1144 #[test]
1145 fn num_results_clamped_to_max() {
1146 let clamped = 100u64.clamp(1, MAX_NUM_RESULTS);
1147 assert_eq!(clamped, 10);
1148 }
1149
1150 #[test]
1151 fn num_results_clamped_to_min() {
1152 let clamped = 0u64.clamp(1, MAX_NUM_RESULTS);
1154 assert_eq!(clamped, 1, "0 must clamp to minimum 1");
1155 }
1156
1157 #[test]
1158 fn format_results_uses_double_newline_separator() {
1159 let results = vec![
1161 SearchResult {
1162 title: "A".to_string(),
1163 url: "http://a.com".to_string(),
1164 snippet: "snippet-a".to_string(),
1165 },
1166 SearchResult {
1167 title: "B".to_string(),
1168 url: "http://b.com".to_string(),
1169 snippet: "snippet-b".to_string(),
1170 },
1171 ];
1172 let out = WebSearch::format_results(&results);
1173 assert!(
1174 out.contains("\n\n"),
1175 "results must be separated by double newline; got: {out:?}"
1176 );
1177 }
1178
1179 #[test]
1180 fn spec_name_and_description() {
1181 let spec = WebSearch::new().spec();
1182 assert_eq!(spec.name, "WebSearch");
1183 assert!(spec.description.contains("RECURSIVE_WEB_SEARCH_PROVIDER"));
1184 assert!(spec.description.contains("DuckDuckGo"));
1185 assert!(spec.description.contains("Jina"));
1186 }
1187
1188 #[test]
1189 fn web_search_construction_smoke() {
1190 let tool = WebSearch::new();
1191 assert_eq!(tool.spec().name, "WebSearch");
1192 }
1193
1194 #[test]
1195 fn parse_duckduckgo_html_extracts_title_url_snippet() {
1196 let html = r#"
1197 <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Frust-lang.org">Rust</a>
1198 <a class="result__snippet">A systems programming language</a>
1199 <a class="result__a" href="https://doc.rust-lang.org/book/">The Book</a>
1200 <div class="result__snippet">Official Rust book</div>
1201 "#;
1202 let results = parse_duckduckgo_results(html, 5);
1203 assert_eq!(results.len(), 2);
1204 assert_eq!(results[0].title, "Rust");
1205 assert_eq!(results[0].url, "https://rust-lang.org");
1206 assert_eq!(results[0].snippet, "A systems programming language");
1207 assert_eq!(results[1].title, "The Book");
1208 assert_eq!(results[1].url, "https://doc.rust-lang.org/book/");
1209 }
1210
1211 #[test]
1212 fn parse_duckduckgo_respects_max_results() {
1213 let html = r#"
1214 <a class="result__a" href="https://a.example">A</a>
1215 <a class="result__snippet">sa</a>
1216 <a class="result__a" href="https://b.example">B</a>
1217 <a class="result__snippet">sb</a>
1218 <a class="result__a" href="https://c.example">C</a>
1219 <a class="result__snippet">sc</a>
1220 "#;
1221 let results = parse_duckduckgo_results(html, 2);
1222 assert_eq!(results.len(), 2);
1223 }
1224
1225 #[test]
1226 fn duckduckgo_challenge_page_detected() {
1227 assert!(is_duckduckgo_challenge(
1228 r#"<div class="anomaly-modal">Unfortunately, bots use DuckDuckGo too</div>"#
1229 ));
1230 assert!(!is_duckduckgo_challenge(
1231 r#"<a class="result__a" href="https://example.com">Ok</a>"#
1232 ));
1233 }
1234
1235 #[test]
1236 fn parse_bing_html_extracts_results_and_decodes_ck_url() {
1237 let real = b"https://example.com/page";
1239 let encoded = general_purpose::STANDARD.encode(real);
1240 let encoded = format!("a1{}", encoded.replace('+', "-").replace('/', "_"));
1241 let href = format!("/ck/a?&u={encoded}");
1242 let html = format!(
1243 r#"<li class="b_algo"><h2><a href="{href}">Example Page</a></h2>
1244 <div class="b_caption"><p>An example snippet</p></div></li>"#
1245 );
1246 let results = parse_bing_results(&html, 5);
1247 assert_eq!(results.len(), 1);
1248 assert_eq!(results[0].title, "Example Page");
1249 assert_eq!(results[0].url, "https://example.com/page");
1250 assert_eq!(results[0].snippet, "An example snippet");
1251 }
1252
1253 #[test]
1254 fn spam_heuristic_drops_single_domain_stuffed_results() {
1255 let results = vec![
1256 SearchResult {
1257 title: "1".into(),
1258 url: "https://spam.example/a".into(),
1259 snippet: "a".into(),
1260 },
1261 SearchResult {
1262 title: "2".into(),
1263 url: "https://spam.example/b".into(),
1264 snippet: "b".into(),
1265 },
1266 SearchResult {
1267 title: "3".into(),
1268 url: "https://spam.example/c".into(),
1269 snippet: "c".into(),
1270 },
1271 SearchResult {
1272 title: "4".into(),
1273 url: "https://spam.example/d".into(),
1274 snippet: "d".into(),
1275 },
1276 SearchResult {
1277 title: "5".into(),
1278 url: "https://other.org/e".into(),
1279 snippet: "e".into(),
1280 },
1281 ];
1282 assert!(is_likely_spam_results(&results));
1283 }
1284
1285 #[test]
1286 fn spam_heuristic_keeps_mixed_domains() {
1287 let results = vec![
1288 SearchResult {
1289 title: "1".into(),
1290 url: "https://a.example/x".into(),
1291 snippet: "a".into(),
1292 },
1293 SearchResult {
1294 title: "2".into(),
1295 url: "https://b.example/x".into(),
1296 snippet: "b".into(),
1297 },
1298 SearchResult {
1299 title: "3".into(),
1300 url: "https://c.example/x".into(),
1301 snippet: "c".into(),
1302 },
1303 SearchResult {
1304 title: "4".into(),
1305 url: "https://d.example/x".into(),
1306 snippet: "d".into(),
1307 },
1308 SearchResult {
1309 title: "5".into(),
1310 url: "https://e.example/x".into(),
1311 snippet: "e".into(),
1312 },
1313 ];
1314 assert!(!is_likely_spam_results(&results));
1315 }
1316
1317 #[tokio::test]
1323 async fn mock_providers_return_expected_results() {
1324 use mockito::Server;
1325
1326 let mut server = Server::new_async().await;
1327 let base = server.url();
1328
1329 let brave_mock = server
1331 .mock("GET", "/res/v1/web/search")
1332 .match_query(mockito::Matcher::Any)
1333 .with_status(200)
1334 .with_header("content-type", "application/json")
1335 .with_body(
1336 r#"{"web":{"results":[{"title":"Rust","url":"https://rust-lang.org","description":"A systems language"}]}}"#,
1337 )
1338 .create_async()
1339 .await;
1340
1341 let tool = WebSearch::with_test_base(&base);
1342 let out = tool
1343 .search_brave("rust lang", 5, "test-key")
1344 .await
1345 .expect("brave mock");
1346 assert_eq!(out.len(), 1);
1347 assert_eq!(out[0].title, "Rust");
1348 assert_eq!(out[0].url, "https://rust-lang.org");
1349 assert_eq!(out[0].snippet, "A systems language");
1350 brave_mock.assert_async().await;
1351
1352 let tavily_mock = server
1354 .mock("POST", "/search")
1355 .with_status(200)
1356 .with_header("content-type", "application/json")
1357 .with_body(
1358 r#"{"results":[{"title":"Tavily","url":"https://tavily.com","content":"AI search"}]}"#,
1359 )
1360 .create_async()
1361 .await;
1362
1363 let out = tool
1364 .search_tavily("ai search", 5, "test-key")
1365 .await
1366 .expect("tavily mock");
1367 assert_eq!(out.len(), 1);
1368 assert_eq!(out[0].title, "Tavily");
1369 assert_eq!(out[0].snippet, "AI search");
1370 tavily_mock.assert_async().await;
1371
1372 let serper_mock = server
1374 .mock("POST", "/search")
1375 .with_status(200)
1376 .with_header("content-type", "application/json")
1377 .with_body(
1378 r#"{"organic":[{"title":"Google","link":"https://google.com","snippet":"Search engine"}]}"#,
1379 )
1380 .create_async()
1381 .await;
1382
1383 let out = tool
1384 .search_serper("google", 5, "test-key")
1385 .await
1386 .expect("serper mock");
1387 assert_eq!(out.len(), 1);
1388 assert_eq!(out[0].url, "https://google.com");
1389 serper_mock.assert_async().await;
1390
1391 let bocha_mock = server
1393 .mock("POST", "/v1/web-search")
1394 .with_status(200)
1395 .with_header("content-type", "application/json")
1396 .with_body(
1397 r#"{"data":{"webPages":{"value":[{"name":"Bocha","url":"https://bochaai.com","snippet":"National AI search"}]}}}"#,
1398 )
1399 .create_async()
1400 .await;
1401
1402 let out = tool
1403 .search_bocha("bocha", 5, "test-key")
1404 .await
1405 .expect("bocha mock");
1406 assert_eq!(out.len(), 1);
1407 assert_eq!(out[0].title, "Bocha");
1408 bocha_mock.assert_async().await;
1409
1410 let bing_mock = server
1412 .mock("GET", "/v7.0/search")
1413 .match_query(mockito::Matcher::Any)
1414 .with_status(200)
1415 .with_header("content-type", "application/json")
1416 .with_body(
1417 r#"{"webPages":{"value":[{"name":"Bing","url":"https://bing.com","snippet":"Microsoft search"}]}}"#,
1418 )
1419 .create_async()
1420 .await;
1421
1422 let out = tool
1423 .search_bing("bing", 5, "test-key")
1424 .await
1425 .expect("bing mock");
1426 assert_eq!(out.len(), 1);
1427 assert_eq!(out[0].snippet, "Microsoft search");
1428 bing_mock.assert_async().await;
1429 }
1430
1431 #[tokio::test]
1432 async fn mock_provider_http_error_propagates() {
1433 use mockito::Server;
1434
1435 let mut server = Server::new_async().await;
1436 let _m = server
1437 .mock("GET", "/res/v1/web/search")
1438 .match_query(mockito::Matcher::Any)
1439 .with_status(401)
1440 .with_header("content-type", "application/json")
1441 .with_body(r#"{"error":"Unauthorized"}"#)
1442 .create_async()
1443 .await;
1444
1445 let tool = WebSearch::with_test_base(server.url());
1446 let err = tool.search_brave("rust", 5, "bad-key").await.unwrap_err();
1447 assert!(err.to_string().contains("401"));
1448 }
1449
1450 #[tokio::test]
1451 async fn jina_fallback_returns_markdown_content() {
1452 use mockito::Server;
1453
1454 let mut server = Server::new_async().await;
1455 let _m = server
1456 .mock("GET", "/rust+programming+language")
1457 .with_status(200)
1458 .with_header("content-type", "text/markdown")
1459 .with_body("# Rust Programming Language\n\nRust is a systems language...")
1460 .create_async()
1461 .await;
1462
1463 let tool = WebSearch::with_test_base(server.url());
1464 let out = tool
1465 .search_jina_fallback("rust programming language")
1466 .await
1467 .expect("jina fallback mock");
1468 assert!(out.contains("Rust"));
1469 }
1470
1471 #[tokio::test]
1472 async fn jina_fallback_truncates_long_response() {
1473 use mockito::Server;
1474
1475 let mut server = Server::new_async().await;
1476 let long_body = "x".repeat(5000);
1477 let _m = server
1478 .mock("GET", mockito::Matcher::Any)
1479 .with_status(200)
1480 .with_header("content-type", "text/markdown")
1481 .with_body(long_body.as_str())
1482 .create_async()
1483 .await;
1484
1485 let tool = WebSearch::with_test_base(server.url());
1486 let out = tool
1487 .search_jina_fallback("anything")
1488 .await
1489 .expect("jina truncation mock");
1490 assert!(out.contains("truncated"));
1491 assert!(out.len() < 5000);
1492 }
1493
1494 #[tokio::test]
1495 async fn html_fallback_uses_duckduckgo_when_available() {
1496 use mockito::Server;
1497
1498 let mut server = Server::new_async().await;
1499 let ddg_body = r#"
1500 <a class="result__a" href="https://rust-lang.org">Rust</a>
1501 <a class="result__snippet">Systems language</a>
1502 "#;
1503 let ddg_mock = server
1504 .mock("GET", "/html/")
1505 .match_query(mockito::Matcher::Any)
1506 .with_status(200)
1507 .with_header("content-type", "text/html")
1508 .with_body(ddg_body)
1509 .create_async()
1510 .await;
1511
1512 let tool = WebSearch::with_test_base(server.url());
1513 let out = tool
1514 .search_html_fallback("rust", 5)
1515 .await
1516 .expect("html fallback");
1517 assert_eq!(out.len(), 1);
1518 assert_eq!(out[0].title, "Rust");
1519 assert_eq!(out[0].url, "https://rust-lang.org");
1520 ddg_mock.assert_async().await;
1521 }
1522
1523 #[tokio::test]
1524 async fn html_fallback_uses_bing_when_duckduckgo_challenged() {
1525 use mockito::Server;
1526
1527 let mut server = Server::new_async().await;
1528 let _ddg = server
1529 .mock("GET", "/html/")
1530 .match_query(mockito::Matcher::Any)
1531 .with_status(200)
1532 .with_header("content-type", "text/html")
1533 .with_body(r#"<div class="anomaly-modal">Unfortunately, bots use DuckDuckGo too</div>"#)
1534 .create_async()
1535 .await;
1536
1537 let bing_body = r#"
1538 <li class="b_algo"><h2><a href="https://bing-result.example">Bing Hit</a></h2>
1539 <div class="b_caption"><p>From Bing HTML</p></div></li>
1540 "#;
1541 let bing_mock = server
1542 .mock("GET", "/search")
1543 .match_query(mockito::Matcher::Any)
1544 .with_status(200)
1545 .with_header("content-type", "text/html")
1546 .with_body(bing_body)
1547 .create_async()
1548 .await;
1549
1550 let tool = WebSearch::with_test_base(server.url());
1551 let out = tool
1552 .search_html_fallback("rust", 5)
1553 .await
1554 .expect("bing html fallback");
1555 assert_eq!(out.len(), 1);
1556 assert_eq!(out[0].title, "Bing Hit");
1557 assert_eq!(out[0].snippet, "From Bing HTML");
1558 bing_mock.assert_async().await;
1559 }
1560
1561 #[tokio::test]
1562 async fn execute_without_config_prefers_html_over_jina() {
1563 use mockito::Server;
1564
1565 let previous_provider = std::env::var("RECURSIVE_WEB_SEARCH_PROVIDER").ok();
1568 let previous_key = std::env::var("RECURSIVE_WEB_SEARCH_API_KEY").ok();
1569 unsafe {
1571 std::env::remove_var("RECURSIVE_WEB_SEARCH_PROVIDER");
1572 std::env::remove_var("RECURSIVE_WEB_SEARCH_API_KEY");
1573 }
1574
1575 let mut server = Server::new_async().await;
1576 let ddg_body = r#"
1577 <a class="result__a" href="https://example.com/html-hit">HTML Hit</a>
1578 <a class="result__snippet">from duckduckgo</a>
1579 "#;
1580 let _ddg = server
1581 .mock("GET", "/html/")
1582 .match_query(mockito::Matcher::Any)
1583 .with_status(200)
1584 .with_body(ddg_body)
1585 .create_async()
1586 .await;
1587
1588 let tool = WebSearch::with_test_base(server.url());
1589 let out = tool
1590 .execute(json!({"query": "html fallback"}))
1591 .await
1592 .expect("execute html path");
1593 assert!(out.contains("HTML Hit"), "got: {out}");
1594 assert!(out.contains("https://example.com/html-hit"));
1595
1596 unsafe {
1598 match previous_provider {
1599 Some(v) => std::env::set_var("RECURSIVE_WEB_SEARCH_PROVIDER", v),
1600 None => std::env::remove_var("RECURSIVE_WEB_SEARCH_PROVIDER"),
1601 }
1602 match previous_key {
1603 Some(v) => std::env::set_var("RECURSIVE_WEB_SEARCH_API_KEY", v),
1604 None => std::env::remove_var("RECURSIVE_WEB_SEARCH_API_KEY"),
1605 }
1606 }
1607 }
1608
1609 #[test]
1610 fn format_results_empty_returns_no_results_sentinel() {
1611 let out = WebSearch::format_results(&[]);
1613 assert_eq!(
1614 out, "No results found.",
1615 "empty results must return the sentinel string"
1616 );
1617 }
1618
1619 #[test]
1620 fn format_results_includes_numbered_index() {
1621 let results = vec![SearchResult {
1623 title: "Rust".to_string(),
1624 url: "https://www.rust-lang.org".to_string(),
1625 snippet: "A systems programming language.".to_string(),
1626 }];
1627 let out = WebSearch::format_results(&results);
1628 assert!(
1629 out.starts_with("1."),
1630 "first result must start with '1.'; got: {out:?}"
1631 );
1632 assert!(out.contains("Title: Rust"), "result must include the title");
1633 assert!(
1634 out.contains("URL: https://www.rust-lang.org"),
1635 "result must include the URL"
1636 );
1637 assert!(
1638 out.contains("Summary: A systems programming language."),
1639 "result must include the snippet"
1640 );
1641 }
1642}