Skip to main content

recursive/tools/
web_search.rs

1//! `WebSearch`: search the web using a configurable provider.
2//!
3//! Configuration (via environment variables):
4//!   `RECURSIVE_WEB_SEARCH_PROVIDER` — one of `brave`, `tavily`, `serper`, `bocha`, `bing`.
5//!   `RECURSIVE_WEB_SEARCH_API_KEY`  — API key for the chosen provider.
6//!
7//! When no provider/API key is configured, the tool falls back in order:
8//!   1. DuckDuckGo HTML scrape (zero-config)
9//!   2. Bing HTML scrape (if DDG is challenged / empty)
10//!   3. Jina AI Search (`s.jina.ai`, optional `RECURSIVE_WEB_SEARCH_JINA_KEY`)
11//!
12//! Result format (lightweight): numbered list of `title / url / snippet` entries.
13
14use 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
32// Default base URLs — overridable in tests via `WebSearch::with_test_base`.
33const 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";
40/// Browser-like UA for HTML SERP scraping (bot-detection sensitive endpoints).
41const 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/// Supported search providers.
44#[derive(Debug, Clone, PartialEq)]
45pub(crate) enum Provider {
46    Brave,
47    Tavily,
48    Serper,
49    Bocha,
50    Bing,
51}
52
53impl Provider {
54    /// Case-insensitive name lookup for a known provider.
55    /// `pub(crate)` so `config.rs` can validate `web_search_provider` at
56    /// config time without duplicating the valid set (the crate-internal
57    /// visibility keeps clippy's `should_implement_trait` from suggesting a
58    /// `FromStr` impl — this is a lookup, not a fallible parse).
59    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    /// When set (tests only), replaces the provider's real base URL.
75    /// A single override is sufficient because integration tests run one
76    /// provider at a time against a mockito server.
77    test_base_url: Option<String>,
78    /// Optional search provider name override (from Config).
79    /// Takes precedence over `RECURSIVE_WEB_SEARCH_PROVIDER` env var.
80    provider_override: Option<String>,
81    /// Optional search API key override (from Config).
82    /// Takes precedence over `RECURSIVE_WEB_SEARCH_API_KEY` env var.
83    api_key_override: Option<String>,
84    /// Optional Jina API key override (from Config).
85    /// Takes precedence over `RECURSIVE_WEB_SEARCH_JINA_KEY` env var.
86    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            // Construction carve-out: if TLS backend fails to initialize, the
102            // process cannot perform HTTP requests at all. This is a fatal
103            // startup condition equivalent to the providers.rs TOML parse
104            // (Invariant #5 §construction).
105            .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    /// Configure search provider and API keys from Config values.
121    /// When set, these take precedence over `RECURSIVE_WEB_SEARCH_*` env vars.
122    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    /// Test-only constructor that redirects all provider requests to `base_url`.
135    #[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    /// Resolve the effective base URL for a provider.
153    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    /// Read provider + api_key from config override or env. Returns `None` if not configured.
162    fn load_config(&self) -> Option<(Provider, String)> {
163        // Check struct overrides first, then fall back to env vars.
164        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        // SAFETY: serde_json::from_str is safe on arbitrary input and only
215        // returns an Err on malformed JSON.
216        #[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        // SAFETY: serde_json::from_str is safe on arbitrary input.
281        #[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        // SAFETY: serde_json::from_str is safe on arbitrary input.
341        #[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        // SAFETY: serde_json::from_str is safe on arbitrary input.
402        #[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        // Bocha returns { data: { webPages: { value: [...] } } }
410        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        // SAFETY: serde_json::from_str is safe on arbitrary input.
456        #[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    /// Zero-config HTML fallback: DuckDuckGo scrape, then Bing scrape.
479    ///
480    /// Used when no API provider/key is configured. Returns structured
481    /// `title / url / snippet` results when either engine yields parseable
482    /// hits; callers should fall through to Jina when this returns empty.
483    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    /// Zero-config fallback using Jina AI Search (`s.jina.ai`).
585    ///
586    /// Does not require an API key — Jina provides a free anonymous tier.
587    /// Optionally, set `RECURSIVE_WEB_SEARCH_JINA_KEY` for a higher quota.
588    /// Returns the raw Markdown content from Jina (not a structured list).
589    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        // URL-encode the query
597        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                    // percent-encode
608                    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        // Optional: use a Jina API key for higher quota (override or env var)
622        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        // Truncate to avoid flooding the context
653        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    /// Format results as a numbered list.
669    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
697// ── HTML SERP parsing (DuckDuckGo / Bing zero-config fallback) ───────────────
698
699static 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
848/// Drop SERP batches dominated by a single root domain (SEO spam / stuffed pages).
849fn 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: &regex::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    // Bing wraps SERP links in `/ck/a?...&u=<base64>` redirects; HTML often
982    // encodes separators as `&amp;`, so decode entities before parsing `u=`.
983    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        // If no provider is configured, try HTML scrape (DDG → Bing), then Jina.
1068        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    // ── Unit tests (no network) ──────────────────────────────────────────────
1093
1094    #[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        // kills `clamp(1, ...)` lower-bound mutation: 0 must become 1
1153        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        // kills `join("\n")` vs `join("\n\n")` mutations
1160        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        // Bing `u=` is often `a1` + base64(url). Decode strips the prefix first.
1238        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?&amp;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    // ── Integration tests with mockito (no real API key needed) ─────────────
1318    //
1319    // Per AGENTS.md: env-var tests MUST be ONE test function to avoid races.
1320    // This single test covers all 5 providers sequentially.
1321
1322    #[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        // ── Brave ──
1330        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        // ── Tavily ──
1353        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        // ── Serper ──
1373        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        // ── Bocha ──
1392        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        // ── Bing ──
1411        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        // Ensure env vars from other tests don't leak into this path.
1566        // Per AGENTS.md: keep env mutation inside a single test body.
1567        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        // SAFETY: test-only env mutation, restored before return.
1570        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        // SAFETY: restore prior env if any.
1597        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        // kills `if results.is_empty()` guard removal mutation
1612        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        // kills `i + 1` → `i` or `i + 2` mutations
1622        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}