Skip to main content

heartbit_core/tool/builtins/
websearch.rs

1use std::future::Future;
2use std::pin::Pin;
3
4use serde_json::json;
5
6use crate::error::Error;
7use crate::llm::types::ToolDefinition;
8use crate::tool::{Tool, ToolOutput};
9
10const DEFAULT_NUM_RESULTS: u64 = 8;
11const MAX_NUM_RESULTS: u64 = 50;
12
13/// Search provider selection.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum SearchProvider {
16    /// Auto-detect: tries providers in priority order based on available API keys.
17    /// Priority: Exa -> Tavily -> Brave -> DuckDuckGo (zero-config fallback).
18    #[default]
19    Auto,
20    Exa,
21    Tavily,
22    Brave,
23    DuckDuckGo,
24}
25
26/// Builtin tool that performs web searches and returns ranked result snippets.
27///
28/// Delegates to one of four backends selected via `SearchProvider`; the default
29/// `Auto` mode tries Exa → Tavily → Brave → DuckDuckGo in priority order based
30/// on which API keys are present in the environment. DuckDuckGo requires no key
31/// and is always available as a zero-config fallback. Results are returned as
32/// title + URL + snippet text, capped at `MAX_NUM_RESULTS = 50`.
33pub struct WebSearchTool {
34    client: reqwest::Client,
35    provider: SearchProvider,
36}
37
38impl WebSearchTool {
39    /// Create a `WebSearchTool` with automatic provider selection.
40    ///
41    /// Panics if the HTTP client cannot be built (TLS initialisation failure).
42    /// Use [`WebSearchTool::try_new`] if you need to handle the error.
43    pub fn new() -> Self {
44        Self::try_new().expect("failed to build reqwest client")
45    }
46
47    /// Create a `WebSearchTool` with automatic provider selection.
48    ///
49    /// Returns `Err` if the underlying HTTP client cannot be constructed
50    /// (e.g., TLS initialisation failure).
51    pub fn try_new() -> Result<Self, crate::error::Error> {
52        let client = crate::http::vendor_client_builder()
53            .timeout(std::time::Duration::from_secs(30))
54            .build()
55            .map_err(|e| {
56                crate::error::Error::Agent(format!("failed to build reqwest client: {e}"))
57            })?;
58        Ok(Self {
59            client,
60            provider: SearchProvider::Auto,
61        })
62    }
63
64    /// Create a `WebSearchTool` that uses a specific search provider
65    /// instead of auto-detecting from environment variables.
66    ///
67    /// Panics if the HTTP client cannot be built. Use [`WebSearchTool::try_with_provider`]
68    /// if you need to handle the error.
69    #[allow(dead_code)] // Public API — not yet re-exported from lib.rs
70    pub fn with_provider(provider: SearchProvider) -> Self {
71        Self::try_with_provider(provider).expect("failed to build reqwest client")
72    }
73
74    /// Create a `WebSearchTool` that uses a specific search provider.
75    ///
76    /// Returns `Err` if the underlying HTTP client cannot be constructed.
77    pub fn try_with_provider(provider: SearchProvider) -> Result<Self, crate::error::Error> {
78        let client = crate::http::vendor_client_builder()
79            .timeout(std::time::Duration::from_secs(30))
80            .build()
81            .map_err(|e| {
82                crate::error::Error::Agent(format!("failed to build reqwest client: {e}"))
83            })?;
84        Ok(Self { client, provider })
85    }
86
87    async fn search_with(
88        &self,
89        provider: &SearchProvider,
90        query: &str,
91        num_results: u64,
92    ) -> Result<Vec<SearchResult>, Error> {
93        match provider {
94            SearchProvider::Auto => Err(Error::Agent(
95                "Auto should not be passed to search_with".into(),
96            )),
97            SearchProvider::Exa => self.search_exa(query, num_results).await,
98            SearchProvider::Tavily => self.search_tavily(query, num_results).await,
99            SearchProvider::Brave => self.search_brave(query, num_results).await,
100            SearchProvider::DuckDuckGo => self.search_duckduckgo(query, num_results).await,
101        }
102    }
103
104    async fn search_exa(&self, query: &str, num_results: u64) -> Result<Vec<SearchResult>, Error> {
105        let api_key = std::env::var("EXA_API_KEY")
106            .map_err(|_| Error::Agent("EXA_API_KEY environment variable not set".into()))?;
107
108        let body = json!({
109            "query": query,
110            "numResults": num_results,
111            "contents": {
112                "text": true
113            }
114        });
115
116        let response = self
117            .client
118            .post("https://api.exa.ai/search")
119            .header("x-api-key", &api_key)
120            .header("Content-Type", "application/json")
121            .json(&body)
122            .send()
123            .await
124            .map_err(|e| Error::Agent(format!("Exa API request failed: {e}")))?;
125
126        let status = response.status();
127        if !status.is_success() {
128            // SECURITY (F-NET-1): cap error body.
129            let error_body = crate::http::read_text_capped(response, 4 * 1024)
130                .await
131                .unwrap_or_default();
132            return Err(Error::Agent(format!(
133                "Exa API error (HTTP {}): {error_body}",
134                status.as_u16()
135            )));
136        }
137
138        // SECURITY (F-NET-1): cap successful body. Search results can be a
139        // few hundred KB; 5 MiB is a comfortable upper bound.
140        let (bytes, _) =
141            crate::http::read_body_capped(response, crate::http::DEFAULT_VENDOR_BODY_CAP)
142                .await
143                .map_err(|e| Error::Agent(format!("Failed to read Exa response: {e}")))?;
144        let data: serde_json::Value = serde_json::from_slice(&bytes)
145            .map_err(|e| Error::Agent(format!("Failed to parse Exa response: {e}")))?;
146
147        let results = data
148            .get("results")
149            .and_then(|v| v.as_array())
150            .map(|arr| {
151                arr.iter()
152                    .map(|r| SearchResult {
153                        title: r
154                            .get("title")
155                            .and_then(|v| v.as_str())
156                            .unwrap_or("")
157                            .to_string(),
158                        url: r
159                            .get("url")
160                            .and_then(|v| v.as_str())
161                            .unwrap_or("")
162                            .to_string(),
163                        text: r
164                            .get("text")
165                            .and_then(|v| v.as_str())
166                            .unwrap_or("")
167                            .to_string(),
168                    })
169                    .collect()
170            })
171            .unwrap_or_default();
172
173        Ok(results)
174    }
175
176    async fn search_tavily(
177        &self,
178        query: &str,
179        num_results: u64,
180    ) -> Result<Vec<SearchResult>, Error> {
181        let api_key = std::env::var("TAVILY_API_KEY")
182            .map_err(|_| Error::Agent("TAVILY_API_KEY environment variable not set".into()))?;
183
184        let body = json!({
185            "api_key": api_key,
186            "query": query,
187            "max_results": num_results,
188            "include_answer": false
189        });
190
191        let response = self
192            .client
193            .post("https://api.tavily.com/search")
194            .header("Content-Type", "application/json")
195            .json(&body)
196            .send()
197            .await
198            .map_err(|e| Error::Agent(format!("Tavily API request failed: {e}")))?;
199
200        let status = response.status();
201        if !status.is_success() {
202            let error_body = crate::http::read_text_capped(response, 4 * 1024)
203                .await
204                .unwrap_or_default();
205            return Err(Error::Agent(format!(
206                "Tavily API error (HTTP {}): {error_body}",
207                status.as_u16()
208            )));
209        }
210
211        let (bytes, _) =
212            crate::http::read_body_capped(response, crate::http::DEFAULT_VENDOR_BODY_CAP)
213                .await
214                .map_err(|e| Error::Agent(format!("Failed to read Tavily response: {e}")))?;
215        let data: serde_json::Value = serde_json::from_slice(&bytes)
216            .map_err(|e| Error::Agent(format!("Failed to parse Tavily response: {e}")))?;
217
218        Ok(parse_tavily_results(&data))
219    }
220
221    async fn search_brave(
222        &self,
223        query: &str,
224        num_results: u64,
225    ) -> Result<Vec<SearchResult>, Error> {
226        let api_key = std::env::var("BRAVE_API_KEY")
227            .map_err(|_| Error::Agent("BRAVE_API_KEY environment variable not set".into()))?;
228
229        let response = self
230            .client
231            .get("https://api.search.brave.com/res/v1/web/search")
232            .query(&[("q", query), ("count", &num_results.to_string())])
233            .header("X-Subscription-Token", &api_key)
234            .header("Accept", "application/json")
235            .send()
236            .await
237            .map_err(|e| Error::Agent(format!("Brave API request failed: {e}")))?;
238
239        let status = response.status();
240        if !status.is_success() {
241            let error_body = crate::http::read_text_capped(response, 4 * 1024)
242                .await
243                .unwrap_or_default();
244            return Err(Error::Agent(format!(
245                "Brave API error (HTTP {}): {error_body}",
246                status.as_u16()
247            )));
248        }
249
250        let (bytes, _) =
251            crate::http::read_body_capped(response, crate::http::DEFAULT_VENDOR_BODY_CAP)
252                .await
253                .map_err(|e| Error::Agent(format!("Failed to read Brave response: {e}")))?;
254        let data: serde_json::Value = serde_json::from_slice(&bytes)
255            .map_err(|e| Error::Agent(format!("Failed to parse Brave response: {e}")))?;
256
257        Ok(parse_brave_results(&data))
258    }
259
260    async fn search_duckduckgo(
261        &self,
262        query: &str,
263        num_results: u64,
264    ) -> Result<Vec<SearchResult>, Error> {
265        let response = self
266            .client
267            .get("https://html.duckduckgo.com/html/")
268            .query(&[("q", query)])
269            // SECURITY (F-NET-5): generic User-Agent. The previous string
270            // explicitly identified the framework, enabling search-engine
271            // fingerprinting and tailored prompt injection in HTML.
272            .header("User-Agent", "Mozilla/5.0 (compatible)")
273            .send()
274            .await
275            .map_err(|e| Error::Agent(format!("DuckDuckGo request failed: {e}")))?;
276
277        let status = response.status();
278        if !status.is_success() {
279            return Err(Error::Agent(format!(
280                "DuckDuckGo error (HTTP {})",
281                status.as_u16()
282            )));
283        }
284
285        // SECURITY (F-NET-1): cap DuckDuckGo HTML response. Search HTML is
286        // typically <500 KiB; 5 MiB is a generous cap.
287        let html = crate::http::read_text_capped(response, crate::http::DEFAULT_VENDOR_BODY_CAP)
288            .await
289            .map_err(|e| Error::Agent(format!("Failed to read DuckDuckGo response: {e}")))?;
290
291        duckduckgo_results_or_block(&html, num_results)
292    }
293}
294
295/// Parse a DuckDuckGo HTML page, surfacing the anti-bot wall as an ERROR.
296/// DDG serves a 200 OK challenge page under scraping pressure (live finding:
297/// the first query works, every later one gets the wall); parsing it to an
298/// empty set masked the block as "No search results found." and sent the
299/// model fabricating URLs. Genuine empty pages still return Ok(vec![]).
300fn duckduckgo_results_or_block(html: &str, max_results: u64) -> Result<Vec<SearchResult>, Error> {
301    let results = parse_duckduckgo_html(html, max_results);
302    if results.is_empty() {
303        let lower = html.to_lowercase();
304        let walled = !html.contains("result__a")
305            && (lower.contains("anomaly") || lower.contains("challenge") || lower.contains("bots"));
306        if walled {
307            return Err(Error::Agent(
308                "DuckDuckGo blocked this request (anti-bot challenge page). \
309                 Scraped search is rate-limited; configure EXA_API_KEY, \
310                 TAVILY_API_KEY, or BRAVE_API_KEY for reliable search, or \
311                 wait before retrying."
312                    .into(),
313            ));
314        }
315    }
316    Ok(results)
317}
318
319impl Default for WebSearchTool {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325#[derive(Debug)]
326struct SearchResult {
327    title: String,
328    url: String,
329    text: String,
330}
331
332/// Human label for the search backend `Auto` mode will actually use — lets a
333/// UI surface "search: ddg-only" BEFORE a session silently degrades to the
334/// scraped fallback (live finding: a missing key was invisible until every
335/// query returned bot-walled empties).
336pub fn search_provider_label() -> &'static str {
337    match detect_providers().first() {
338        Some(SearchProvider::Exa) => "exa",
339        Some(SearchProvider::Tavily) => "tavily",
340        Some(SearchProvider::Brave) => "brave",
341        _ => "ddg-only (no search API key)",
342    }
343}
344
345fn detect_providers() -> Vec<SearchProvider> {
346    let mut providers = Vec::new();
347    if std::env::var("EXA_API_KEY").is_ok() {
348        providers.push(SearchProvider::Exa);
349    }
350    if std::env::var("TAVILY_API_KEY").is_ok() {
351        providers.push(SearchProvider::Tavily);
352    }
353    if std::env::var("BRAVE_API_KEY").is_ok() {
354        providers.push(SearchProvider::Brave);
355    }
356    // DuckDuckGo is always available (no API key needed)
357    providers.push(SearchProvider::DuckDuckGo);
358    providers
359}
360
361fn parse_tavily_results(data: &serde_json::Value) -> Vec<SearchResult> {
362    data.get("results")
363        .and_then(|v| v.as_array())
364        .map(|arr| {
365            arr.iter()
366                .map(|r| SearchResult {
367                    title: r
368                        .get("title")
369                        .and_then(|v| v.as_str())
370                        .unwrap_or("")
371                        .to_string(),
372                    url: r
373                        .get("url")
374                        .and_then(|v| v.as_str())
375                        .unwrap_or("")
376                        .to_string(),
377                    text: r
378                        .get("content")
379                        .and_then(|v| v.as_str())
380                        .unwrap_or("")
381                        .to_string(),
382                })
383                .collect()
384        })
385        .unwrap_or_default()
386}
387
388fn parse_brave_results(data: &serde_json::Value) -> Vec<SearchResult> {
389    data.get("web")
390        .and_then(|v| v.get("results"))
391        .and_then(|v| v.as_array())
392        .map(|arr| {
393            arr.iter()
394                .map(|r| SearchResult {
395                    title: r
396                        .get("title")
397                        .and_then(|v| v.as_str())
398                        .unwrap_or("")
399                        .to_string(),
400                    url: r
401                        .get("url")
402                        .and_then(|v| v.as_str())
403                        .unwrap_or("")
404                        .to_string(),
405                    text: r
406                        .get("description")
407                        .and_then(|v| v.as_str())
408                        .unwrap_or("")
409                        .to_string(),
410                })
411                .collect()
412        })
413        .unwrap_or_default()
414}
415
416fn parse_duckduckgo_html(html: &str, max_results: u64) -> Vec<SearchResult> {
417    let mut results = Vec::new();
418    let max = max_results as usize;
419
420    // Parse result__a links for title + URL
421    let mut search_start = 0;
422    while results.len() < max {
423        let marker = "class=\"result__a\"";
424        let Some(marker_pos) = html[search_start..].find(marker) else {
425            break;
426        };
427        let abs_marker = search_start + marker_pos;
428
429        // Find the href attribute (look backwards for <a, then find href).
430        // Floor to a char boundary: `abs_marker - 200` is an arbitrary byte
431        // offset into third-party HTML and can land mid-codepoint when the
432        // preceding result's title/snippet is multibyte (panic otherwise).
433        let tag_start_region = super::floor_char_boundary(html, abs_marker.saturating_sub(200));
434        let tag_region = &html[tag_start_region..abs_marker];
435        let href = tag_region
436            .rfind("href=\"")
437            .and_then(|pos| {
438                let start = tag_start_region + pos + 6;
439                html[start..].find('"').map(|end| &html[start..start + end])
440            })
441            .unwrap_or("");
442
443        // Extract inner text: from after the '>' that closes the <a> tag to the next </a>
444        let after_marker = abs_marker + marker.len();
445        let title = html[after_marker..]
446            .find('>')
447            .and_then(|gt| {
448                let text_start = after_marker + gt + 1;
449                html[text_start..].find("</a>").map(|end| {
450                    // Strip any inner HTML tags
451                    strip_html_tags(&html[text_start..text_start + end])
452                })
453            })
454            .unwrap_or_default();
455
456        // Find corresponding snippet
457        let snippet_marker = "class=\"result__snippet\"";
458        let snippet = html[after_marker..]
459            .find(snippet_marker)
460            .and_then(|pos| {
461                let snippet_start = after_marker + pos + snippet_marker.len();
462                html[snippet_start..].find('>').and_then(|gt| {
463                    let text_start = snippet_start + gt + 1;
464                    // Find closing tag
465                    html[text_start..]
466                        .find("</")
467                        .map(|end| strip_html_tags(&html[text_start..text_start + end]))
468                })
469            })
470            .unwrap_or_default();
471
472        // Decode the URL - DuckDuckGo wraps URLs in a redirect
473        let url = decode_ddg_url(href);
474
475        if !title.is_empty() || !url.is_empty() {
476            results.push(SearchResult {
477                title: title.trim().to_string(),
478                url,
479                text: snippet.trim().to_string(),
480            });
481        }
482
483        search_start = after_marker;
484    }
485
486    results
487}
488
489/// Strip HTML tags from a string and decode common HTML entities (simple best-effort).
490fn strip_html_tags(s: &str) -> String {
491    let mut out = String::with_capacity(s.len());
492    let mut in_tag = false;
493    for ch in s.chars() {
494        if ch == '<' {
495            in_tag = true;
496        } else if ch == '>' {
497            in_tag = false;
498        } else if !in_tag {
499            out.push(ch);
500        }
501    }
502    // Decode common HTML entities
503    decode_html_entities(&out)
504}
505
506fn decode_html_entities(input: &str) -> String {
507    input
508        .replace("&amp;", "&")
509        .replace("&lt;", "<")
510        .replace("&gt;", ">")
511        .replace("&quot;", "\"")
512        .replace("&#39;", "'")
513        .replace("&#x27;", "'")
514        .replace("&nbsp;", " ")
515        .replace("&apos;", "'")
516}
517
518/// Decode DuckDuckGo redirect URLs. They look like:
519/// `//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com&rut=...`
520/// We extract and decode the `uddg` parameter.
521fn decode_ddg_url(href: &str) -> String {
522    if let Some(uddg_start) = href.find("uddg=") {
523        let value_start = uddg_start + 5;
524        let value_end = href[value_start..]
525            .find('&')
526            .map(|pos| value_start + pos)
527            .unwrap_or(href.len());
528        let encoded = &href[value_start..value_end];
529        url_decode(encoded)
530    } else {
531        href.to_string()
532    }
533}
534
535/// Minimal percent-decoding for URLs (multi-byte UTF-8 safe).
536fn url_decode(input: &str) -> String {
537    let mut bytes = Vec::with_capacity(input.len());
538    let mut chars = input.chars();
539    while let Some(c) = chars.next() {
540        if c == '%' {
541            let hex: String = chars.by_ref().take(2).collect();
542            if hex.len() == 2 {
543                if let Ok(byte) = u8::from_str_radix(&hex, 16) {
544                    bytes.push(byte);
545                } else {
546                    bytes.push(b'%');
547                    bytes.extend(hex.bytes());
548                }
549            } else {
550                bytes.push(b'%');
551                bytes.extend(hex.bytes());
552            }
553        } else if c == '+' {
554            bytes.push(b' ');
555        } else {
556            let mut buf = [0u8; 4];
557            let encoded = c.encode_utf8(&mut buf);
558            bytes.extend(encoded.bytes());
559        }
560    }
561    String::from_utf8_lossy(&bytes).into_owned()
562}
563
564fn format_results(query: &str, results: &[SearchResult]) -> String {
565    if results.is_empty() {
566        return "No search results found.".into();
567    }
568
569    let mut output = format!("Search results for \"{query}\":\n\n");
570
571    for (i, result) in results.iter().enumerate() {
572        let title = if result.title.is_empty() {
573            "Untitled"
574        } else {
575            &result.title
576        };
577
578        // Truncate text snippet (char-boundary safe)
579        let snippet = if result.text.len() > 500 {
580            let end = super::floor_char_boundary(&result.text, 500);
581            format!("{}...", &result.text[..end])
582        } else {
583            result.text.clone()
584        };
585
586        output.push_str(&format!(
587            "{}. **{}**\n   {}\n   {}\n\n",
588            i + 1,
589            title,
590            result.url,
591            snippet.trim()
592        ));
593    }
594
595    output
596}
597
598impl Tool for WebSearchTool {
599    fn definition(&self) -> ToolDefinition {
600        ToolDefinition {
601            name: "websearch".into(),
602            description:
603                "Search the web using multiple providers (Exa, Tavily, Brave, DuckDuckGo). \
604                          Auto-detects available API keys and cascades on failure. \
605                          DuckDuckGo requires no API key."
606                    .into(),
607            input_schema: json!({
608                "type": "object",
609                "properties": {
610                    "query": {
611                        "type": "string",
612                        "description": "The search query"
613                    },
614                    "num_results": {
615                        "type": "integer",
616                        "description": "Number of results to return (default: 8)"
617                    }
618                },
619                "required": ["query"]
620            }),
621        }
622    }
623
624    fn execute(
625        &self,
626        _ctx: &crate::ExecutionContext,
627        input: serde_json::Value,
628    ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
629        Box::pin(async move {
630            let query = input
631                .get("query")
632                .and_then(|v| v.as_str())
633                .ok_or_else(|| Error::Agent("query is required".into()))?;
634
635            let num_results = input
636                .get("num_results")
637                .and_then(|v| v.as_u64())
638                .unwrap_or(DEFAULT_NUM_RESULTS)
639                .min(MAX_NUM_RESULTS);
640
641            let providers = match self.provider {
642                SearchProvider::Auto => detect_providers(),
643                specific => vec![specific],
644            };
645
646            let mut last_error = String::new();
647            for provider in &providers {
648                match self.search_with(provider, query, num_results).await {
649                    Ok(results) => return Ok(ToolOutput::success(format_results(query, &results))),
650                    Err(e) => {
651                        tracing::warn!(provider = ?provider, error = %e, "search provider failed, trying next");
652                        last_error = e.to_string();
653                    }
654                }
655            }
656
657            Ok(ToolOutput::error(format!(
658                "All search providers failed. Last error: {last_error}"
659            )))
660        })
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    #[test]
669    fn definition_has_correct_name() {
670        let tool = WebSearchTool::new();
671        assert_eq!(tool.definition().name, "websearch");
672    }
673
674    // Live finding: DuckDuckGo serves a 200 OK anti-bot page from the second
675    // scrape onwards (markers: "anomaly"/"challenge"/"bots", zero result__a
676    // divs). Parsing it to an EMPTY result set masked the block as a benign
677    // "No search results found." — the model then fabricated URLs (8× 404).
678    // A bot-wall must surface as an ERROR; a genuine empty stays a success.
679    #[test]
680    fn ddg_bot_wall_is_an_error_not_an_empty_success() {
681        let wall = r#"<html><body>
682            <div class="anomaly-modal__title">Unfortunately, bots use DuckDuckGo too.</div>
683            <p>Please complete the following challenge to confirm this search
684            was made by a human.</p></body></html>"#;
685        let err = duckduckgo_results_or_block(wall, 8)
686            .unwrap_err()
687            .to_string();
688        assert!(err.contains("anti-bot"), "got: {err}");
689        assert!(
690            err.contains("API_KEY"),
691            "error must point at configuring a real provider: {err}"
692        );
693    }
694
695    #[test]
696    fn ddg_genuine_empty_stays_a_success() {
697        let empty = r#"<html><body><div class="no-results">
698            No results found for your search.</div></body></html>"#;
699        let results = duckduckgo_results_or_block(empty, 8).unwrap();
700        assert!(results.is_empty());
701    }
702
703    #[test]
704    fn ddg_results_page_parses_normally() {
705        let page = r#"<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&amp;rut=x">Title A</a>"#;
706        let results = duckduckgo_results_or_block(page, 8).unwrap();
707        assert_eq!(results.len(), 1);
708        assert_eq!(results[0].title, "Title A");
709    }
710
711    #[test]
712    fn search_provider_default_is_auto() {
713        let provider = SearchProvider::default();
714        assert_eq!(provider, SearchProvider::Auto);
715    }
716
717    #[test]
718    fn with_provider_constructor() {
719        let tool = WebSearchTool::with_provider(SearchProvider::Brave);
720        assert_eq!(tool.provider, SearchProvider::Brave);
721    }
722
723    #[test]
724    fn detect_providers_with_no_keys() {
725        // This test may see env vars from the environment, so we just verify
726        // DuckDuckGo is always the last provider.
727        let providers = detect_providers();
728        assert!(!providers.is_empty());
729        assert_eq!(*providers.last().unwrap(), SearchProvider::DuckDuckGo);
730    }
731
732    #[test]
733    fn format_results_empty() {
734        let output = format_results("test", &[]);
735        assert_eq!(output, "No search results found.");
736    }
737
738    #[test]
739    fn format_results_single_result() {
740        let results = vec![SearchResult {
741            title: "Rust Programming".into(),
742            url: "https://rust-lang.org".into(),
743            text: "A systems programming language.".into(),
744        }];
745        let output = format_results("rust", &results);
746        assert!(output.contains("Search results for \"rust\""));
747        assert!(output.contains("1. **Rust Programming**"));
748        assert!(output.contains("https://rust-lang.org"));
749        assert!(output.contains("A systems programming language."));
750    }
751
752    #[test]
753    fn format_results_truncates_long_text() {
754        let long_text = "x".repeat(600);
755        let results = vec![SearchResult {
756            title: "Long".into(),
757            url: "https://example.com".into(),
758            text: long_text,
759        }];
760        let output = format_results("q", &results);
761        assert!(output.contains("..."), "long text should be truncated");
762        let snippet_line = output.lines().find(|l| l.contains("xxx")).unwrap();
763        assert!(
764            snippet_line.len() < 520,
765            "snippet too long: {}",
766            snippet_line.len()
767        );
768    }
769
770    #[test]
771    fn format_results_missing_fields() {
772        let results = vec![SearchResult {
773            title: String::new(),
774            url: String::new(),
775            text: String::new(),
776        }];
777        let output = format_results("q", &results);
778        assert!(
779            output.contains("Untitled"),
780            "missing title should default to Untitled"
781        );
782    }
783
784    #[test]
785    fn format_results_multiple_results() {
786        let results = vec![
787            SearchResult {
788                title: "A".into(),
789                url: "https://a.com".into(),
790                text: "First".into(),
791            },
792            SearchResult {
793                title: "B".into(),
794                url: "https://b.com".into(),
795                text: "Second".into(),
796            },
797        ];
798        let output = format_results("q", &results);
799        assert!(output.contains("1. **A**"));
800        assert!(output.contains("2. **B**"));
801    }
802
803    #[test]
804    fn format_results_with_search_result_struct() {
805        let results = vec![
806            SearchResult {
807                title: "Test Title".into(),
808                url: "https://example.com".into(),
809                text: "Some description text".into(),
810            },
811            SearchResult {
812                title: "Another".into(),
813                url: "https://other.com".into(),
814                text: "More text here".into(),
815            },
816        ];
817        let output = format_results("my query", &results);
818        assert!(output.contains("Search results for \"my query\""));
819        assert!(output.contains("1. **Test Title**"));
820        assert!(output.contains("https://example.com"));
821        assert!(output.contains("Some description text"));
822        assert!(output.contains("2. **Another**"));
823        assert!(output.contains("https://other.com"));
824    }
825
826    #[test]
827    fn parse_tavily_response() {
828        let data = json!({
829            "results": [
830                {
831                    "title": "Rust Lang",
832                    "url": "https://rust-lang.org",
833                    "content": "A systems programming language focused on safety."
834                },
835                {
836                    "title": "Crates.io",
837                    "url": "https://crates.io",
838                    "content": "The Rust community's crate registry."
839                }
840            ]
841        });
842        let results = parse_tavily_results(&data);
843        assert_eq!(results.len(), 2);
844        assert_eq!(results[0].title, "Rust Lang");
845        assert_eq!(results[0].url, "https://rust-lang.org");
846        assert_eq!(
847            results[0].text,
848            "A systems programming language focused on safety."
849        );
850        assert_eq!(results[1].title, "Crates.io");
851    }
852
853    #[test]
854    fn parse_tavily_response_empty() {
855        let data = json!({"results": []});
856        let results = parse_tavily_results(&data);
857        assert!(results.is_empty());
858    }
859
860    #[test]
861    fn parse_brave_response() {
862        let data = json!({
863            "web": {
864                "results": [
865                    {
866                        "title": "Brave Search",
867                        "url": "https://brave.com",
868                        "description": "A privacy-focused search engine."
869                    },
870                    {
871                        "title": "Wikipedia",
872                        "url": "https://en.wikipedia.org",
873                        "description": "The free encyclopedia."
874                    }
875                ]
876            }
877        });
878        let results = parse_brave_results(&data);
879        assert_eq!(results.len(), 2);
880        assert_eq!(results[0].title, "Brave Search");
881        assert_eq!(results[0].url, "https://brave.com");
882        assert_eq!(results[0].text, "A privacy-focused search engine.");
883        assert_eq!(results[1].title, "Wikipedia");
884    }
885
886    #[test]
887    fn parse_brave_response_empty() {
888        let data = json!({"web": {"results": []}});
889        let results = parse_brave_results(&data);
890        assert!(results.is_empty());
891    }
892
893    #[test]
894    fn parse_brave_response_missing_web() {
895        let data = json!({});
896        let results = parse_brave_results(&data);
897        assert!(results.is_empty());
898    }
899
900    #[test]
901    fn parse_duckduckgo_html_results() {
902        let html = r#"
903        <div class="result">
904            <a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Frust-lang.org&rut=abc" class="result__a">
905                <b>Rust</b> Programming Language
906            </a>
907            <a class="result__snippet">A language empowering everyone to build reliable software.</a>
908        </div>
909        <div class="result">
910            <a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fcrates.io" class="result__a">
911                Crates.io
912            </a>
913            <a class="result__snippet">The Rust package registry.</a>
914        </div>
915        "#;
916        let results = parse_duckduckgo_html(html, 10);
917        assert_eq!(results.len(), 2);
918        assert_eq!(results[0].title, "Rust Programming Language");
919        assert_eq!(results[0].url, "https://rust-lang.org");
920        assert_eq!(
921            results[0].text,
922            "A language empowering everyone to build reliable software."
923        );
924        assert_eq!(results[1].title, "Crates.io");
925        assert_eq!(results[1].url, "https://crates.io");
926        assert_eq!(results[1].text, "The Rust package registry.");
927    }
928
929    #[test]
930    fn parse_duckduckgo_html_respects_limit() {
931        let html = r#"
932        <a href="https://a.com" class="result__a">A</a>
933        <a class="result__snippet">Desc A</a>
934        <a href="https://b.com" class="result__a">B</a>
935        <a class="result__snippet">Desc B</a>
936        <a href="https://c.com" class="result__a">C</a>
937        <a class="result__snippet">Desc C</a>
938        "#;
939        let results = parse_duckduckgo_html(html, 2);
940        assert_eq!(results.len(), 2);
941    }
942
943    #[test]
944    fn parse_duckduckgo_html_empty() {
945        let results = parse_duckduckgo_html("<html><body>No results</body></html>", 10);
946        assert!(results.is_empty());
947    }
948
949    // Panic-safety (audit 2026-06-09): the backwards href window used to slice
950    // at `abs_marker - 200`, an arbitrary byte offset into third-party HTML.
951    // When the preceding result's snippet is multibyte-dense (French/Japanese/
952    // emoji titles), that offset lands mid-codepoint and the slice panics. The
953    // pad loop shifts the offset byte-by-byte so at least one iteration is
954    // guaranteed to land inside a 2-byte char with the old code.
955    #[test]
956    fn parse_duckduckgo_html_multibyte_before_marker_does_not_panic() {
957        for pad in 0..4 {
958            let mut html = String::new();
959            html.push_str("<a href=\"https://a.example\" class=\"result__a\">First</a>");
960            html.push_str(&"x".repeat(pad));
961            html.push_str(&"é".repeat(150)); // 300 bytes of 2-byte chars
962            html.push_str("<a class=\"result__a\" href=\"https://b.example\">Second</a>");
963
964            let results = parse_duckduckgo_html(&html, 10);
965            assert_eq!(results.len(), 2, "pad={pad}: both results expected");
966            assert_eq!(results[1].title, "Second", "pad={pad}");
967        }
968    }
969
970    #[test]
971    fn strip_html_tags_basic() {
972        assert_eq!(strip_html_tags("<b>bold</b> text"), "bold text");
973        assert_eq!(strip_html_tags("no tags"), "no tags");
974        assert_eq!(strip_html_tags("<a href=\"x\">link</a>"), "link");
975    }
976
977    #[test]
978    fn strip_html_tags_decodes_entities() {
979        assert_eq!(strip_html_tags("foo &amp; bar"), "foo & bar");
980        assert_eq!(strip_html_tags("&lt;not a tag&gt;"), "<not a tag>");
981        assert_eq!(strip_html_tags("it&#39;s"), "it's");
982        assert_eq!(strip_html_tags("a&nbsp;b"), "a b");
983    }
984
985    #[test]
986    fn url_decode_basic() {
987        assert_eq!(
988            url_decode("https%3A%2F%2Fexample.com"),
989            "https://example.com"
990        );
991        assert_eq!(url_decode("hello%20world"), "hello world");
992        assert_eq!(url_decode("noencode"), "noencode");
993    }
994
995    #[test]
996    fn decode_ddg_url_with_uddg() {
997        let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com&rut=abc";
998        assert_eq!(decode_ddg_url(href), "https://example.com");
999    }
1000
1001    #[test]
1002    fn decode_ddg_url_direct() {
1003        assert_eq!(decode_ddg_url("https://example.com"), "https://example.com");
1004    }
1005
1006    #[test]
1007    fn url_decode_multibyte() {
1008        assert_eq!(url_decode("%C3%A9"), "é");
1009        assert_eq!(url_decode("%E4%B8%AD%E6%96%87"), "中文");
1010    }
1011
1012    #[tokio::test]
1013    async fn websearch_auto_cascade_no_keys() {
1014        // With no API keys set, Auto should still include DuckDuckGo.
1015        // We can't actually hit the network in unit tests, but we verify the
1016        // provider list logic.
1017        let providers = detect_providers();
1018        assert!(providers.contains(&SearchProvider::DuckDuckGo));
1019    }
1020}