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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum SearchProvider {
16 #[default]
19 Auto,
20 Exa,
21 Tavily,
22 Brave,
23 DuckDuckGo,
24}
25
26pub struct WebSearchTool {
34 client: reqwest::Client,
35 provider: SearchProvider,
36}
37
38impl WebSearchTool {
39 pub fn new() -> Self {
44 Self::try_new().expect("failed to build reqwest client")
45 }
46
47 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 #[allow(dead_code)] pub fn with_provider(provider: SearchProvider) -> Self {
71 Self::try_with_provider(provider).expect("failed to build reqwest client")
72 }
73
74 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 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 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 .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 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
295fn 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
332pub 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 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 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 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 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_html_tags(&html[text_start..text_start + end])
452 })
453 })
454 .unwrap_or_default();
455
456 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 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 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
489fn 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_html_entities(&out)
504}
505
506fn decode_html_entities(input: &str) -> String {
507 input
508 .replace("&", "&")
509 .replace("<", "<")
510 .replace(">", ">")
511 .replace(""", "\"")
512 .replace("'", "'")
513 .replace("'", "'")
514 .replace(" ", " ")
515 .replace("'", "'")
516}
517
518fn 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
535fn 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 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 #[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&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 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 #[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)); 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 & bar"), "foo & bar");
980 assert_eq!(strip_html_tags("<not a tag>"), "<not a tag>");
981 assert_eq!(strip_html_tags("it's"), "it's");
982 assert_eq!(strip_html_tags("a 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 let providers = detect_providers();
1018 assert!(providers.contains(&SearchProvider::DuckDuckGo));
1019 }
1020}