1use std::sync::Arc;
5
6use brain_core::metrics::SubsystemMetrics;
7
8use crate::resilience::{resilient_send, CircuitBreaker};
9
10fn make_cb(
11 name: &str,
12 resilience: &brain_core::config::ResilienceConfig,
13 metrics: Option<Arc<SubsystemMetrics>>,
14) -> CircuitBreaker {
15 let cb = CircuitBreaker::new(
16 name,
17 resilience.circuit_breaker_threshold,
18 resilience.circuit_breaker_cooldown_secs,
19 );
20 if let Some(m) = metrics {
21 cb.with_metrics(m)
22 } else {
23 cb
24 }
25}
26
27fn parse_search_results(
29 candidates: Vec<serde_json::Value>,
30 top_k: usize,
31) -> Vec<cortex::actions::SearchHit> {
32 candidates
33 .into_iter()
34 .filter_map(|entry| {
35 let title = entry
36 .get("title")
37 .and_then(serde_json::Value::as_str)
38 .or_else(|| entry.get("name").and_then(serde_json::Value::as_str))
39 .unwrap_or("untitled")
40 .to_string();
41 let url = entry
42 .get("url")
43 .and_then(serde_json::Value::as_str)
44 .or_else(|| entry.get("link").and_then(serde_json::Value::as_str))
45 .unwrap_or_default()
46 .to_string();
47 if url.is_empty() {
48 return None;
49 }
50 let snippet = entry
51 .get("snippet")
52 .and_then(serde_json::Value::as_str)
53 .or_else(|| entry.get("description").and_then(serde_json::Value::as_str))
54 .or_else(|| entry.get("content").and_then(serde_json::Value::as_str))
55 .unwrap_or_default()
56 .to_string();
57 Some(cortex::actions::SearchHit {
58 title,
59 url,
60 snippet,
61 })
62 })
63 .take(top_k.max(1))
64 .collect()
65}
66
67pub fn build_search_client(timeout_ms: u64) -> anyhow::Result<reqwest::Client> {
68 reqwest::Client::builder()
69 .timeout(std::time::Duration::from_millis(timeout_ms.max(1)))
70 .build()
71 .map_err(|e| anyhow::anyhow!("search client init failed: {e}"))
72}
73
74pub struct DuckDuckGoSearchBackend {
81 client: reqwest::Client,
82 circuit_breaker: Arc<CircuitBreaker>,
83 max_retries: u32,
84 retry_base_ms: u64,
85}
86
87impl DuckDuckGoSearchBackend {
88 pub fn new(
89 timeout_ms: u64,
90 resilience: &brain_core::config::ResilienceConfig,
91 ) -> anyhow::Result<Self> {
92 Self::new_with_metrics(timeout_ms, resilience, None)
93 }
94
95 pub fn new_with_metrics(
96 timeout_ms: u64,
97 resilience: &brain_core::config::ResilienceConfig,
98 metrics: Option<Arc<SubsystemMetrics>>,
99 ) -> anyhow::Result<Self> {
100 let client = reqwest::Client::builder()
104 .timeout(std::time::Duration::from_millis(timeout_ms.max(1)))
105 .user_agent(
106 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
107 AppleWebKit/537.36 (KHTML, like Gecko) \
108 Chrome/124.0.0.0 Safari/537.36",
109 )
110 .redirect(reqwest::redirect::Policy::limited(3))
111 .build()
112 .map_err(|e| anyhow::anyhow!("DuckDuckGo client init failed: {e}"))?;
113 Ok(Self {
114 client,
115 circuit_breaker: Arc::new(make_cb("duckduckgo", resilience, metrics)),
116 max_retries: resilience.max_retries,
117 retry_base_ms: resilience.retry_base_ms,
118 })
119 }
120}
121
122#[async_trait::async_trait]
123impl cortex::actions::WebSearchBackend for DuckDuckGoSearchBackend {
124 async fn search(
125 &self,
126 query: &str,
127 top_k: usize,
128 ) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
129 let from_ia = self.search_instant_answer(query, top_k).await?;
136 if !from_ia.is_empty() {
137 return Ok(from_ia);
138 }
139 self.search_html(query, top_k).await
140 }
141}
142
143impl DuckDuckGoSearchBackend {
144 async fn search_instant_answer(
145 &self,
146 query: &str,
147 top_k: usize,
148 ) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
149 let url = "https://api.duckduckgo.com/";
150 let client = self.client.clone();
151 let q = query.to_string();
152 let resp = resilient_send(
153 || {
154 client.get(url).query(&[
155 ("q", q.as_str()),
156 ("format", "json"),
157 ("no_html", "1"),
158 ("skip_disambig", "1"),
159 ])
160 },
161 &self.circuit_breaker,
162 self.max_retries,
163 self.retry_base_ms,
164 )
165 .await?;
166 if !resp.status().is_success() {
167 return Ok(Vec::new());
168 }
169 let body: serde_json::Value = match resp.json().await {
170 Ok(v) => v,
171 Err(_) => return Ok(Vec::new()),
172 };
173 Ok(parse_instant_answer(&body, top_k))
174 }
175
176 async fn search_html(
177 &self,
178 query: &str,
179 top_k: usize,
180 ) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
181 let url = "https://html.duckduckgo.com/html/";
185 let client = self.client.clone();
186 let q = query.to_string();
187 let response = resilient_send(
188 || {
189 client
190 .post(url)
191 .header(
192 reqwest::header::ACCEPT,
193 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
194 )
195 .header(reqwest::header::ACCEPT_LANGUAGE, "en-US,en;q=0.9")
196 .header(reqwest::header::REFERER, "https://duckduckgo.com/")
197 .header(reqwest::header::ORIGIN, "https://duckduckgo.com")
198 .form(&[("q", q.as_str())])
199 },
200 &self.circuit_breaker,
201 self.max_retries,
202 self.retry_base_ms,
203 )
204 .await?;
205
206 if !response.status().is_success() {
207 return Err(cortex::actions::ActionError::ExecutionFailed(format!(
208 "DuckDuckGo returned HTTP {}",
209 response.status()
210 )));
211 }
212
213 let html = response
214 .text()
215 .await
216 .map_err(|e| cortex::actions::ActionError::ExecutionFailed(e.to_string()))?;
217
218 if html.contains("anomaly-modal") {
223 return Err(cortex::actions::ActionError::ExecutionFailed(
224 "DuckDuckGo served a bot-challenge page instead of results. \
225 Run `brain deps up` to use SearXNG, or set \
226 `actions.web_search.provider: tavily` with an API key."
227 .into(),
228 ));
229 }
230
231 Ok(parse_duckduckgo_html(&html, top_k))
232 }
233}
234
235fn parse_instant_answer(body: &serde_json::Value, top_k: usize) -> Vec<cortex::actions::SearchHit> {
239 let mut hits = Vec::new();
240
241 let abstract_text = body
242 .get("AbstractText")
243 .and_then(|v| v.as_str())
244 .unwrap_or("");
245 let abstract_url = body
246 .get("AbstractURL")
247 .and_then(|v| v.as_str())
248 .unwrap_or("");
249 let heading = body.get("Heading").and_then(|v| v.as_str()).unwrap_or("");
250 if !abstract_text.is_empty() && !abstract_url.is_empty() {
251 hits.push(cortex::actions::SearchHit {
252 title: if heading.is_empty() {
253 "DuckDuckGo Instant Answer".to_string()
254 } else {
255 heading.to_string()
256 },
257 url: abstract_url.to_string(),
258 snippet: abstract_text.to_string(),
259 });
260 }
261
262 if let Some(topics) = body.get("RelatedTopics").and_then(|v| v.as_array()) {
263 for t in topics {
264 if hits.len() >= top_k.max(1) {
265 break;
266 }
267 let url = t.get("FirstURL").and_then(|v| v.as_str()).unwrap_or("");
268 let text = t.get("Text").and_then(|v| v.as_str()).unwrap_or("");
269 if url.is_empty() || text.is_empty() {
270 continue;
271 }
272 let (title, snippet) = match text.split_once(" - ") {
274 Some((t, s)) => (t.to_string(), s.to_string()),
275 None => (text.to_string(), text.to_string()),
276 };
277 hits.push(cortex::actions::SearchHit {
278 title,
279 url: url.to_string(),
280 snippet,
281 });
282 }
283 }
284
285 hits.into_iter().take(top_k.max(1)).collect()
286}
287
288fn parse_duckduckgo_html(html: &str, top_k: usize) -> Vec<cortex::actions::SearchHit> {
294 let mut hits = Vec::new();
295 let mut cursor = 0usize;
296 let max = top_k.max(1);
297
298 while hits.len() < max {
299 let Some(rel) = html[cursor..].find("class=\"result__a\"") else {
303 break;
304 };
305 let block_start = cursor + rel;
306 let Some(a_open) = html[..block_start].rfind("<a ") else {
308 cursor = block_start + 1;
309 continue;
310 };
311 let after_a_open = a_open + 3;
313 let Some(href_rel) = html[after_a_open..].find("href=\"") else {
314 cursor = block_start + 1;
315 continue;
316 };
317 let href_start = after_a_open + href_rel + 6;
318 let Some(href_end_rel) = html[href_start..].find('"') else {
319 cursor = block_start + 1;
320 continue;
321 };
322 let href_raw = &html[href_start..href_start + href_end_rel];
323 let url = decode_ddg_redirect(href_raw);
324
325 let Some(gt_rel) = html[block_start..].find('>') else {
328 cursor = block_start + 1;
329 continue;
330 };
331 let title_start = block_start + gt_rel + 1;
332 let Some(title_end_rel) = html[title_start..].find("</a>") else {
333 cursor = block_start + 1;
334 continue;
335 };
336 let title = strip_html(&html[title_start..title_start + title_end_rel]);
337 let snippet_search_from = title_start + title_end_rel;
338
339 let window_end = html[snippet_search_from..]
342 .find("class=\"result__a\"")
343 .map(|r| snippet_search_from + r)
344 .unwrap_or(html.len());
345 let window = &html[snippet_search_from..window_end];
346 let snippet = window
347 .find("class=\"result__snippet\"")
348 .and_then(|s| {
349 let from = snippet_search_from + s;
350 let gt = html[from..].find('>')? + from + 1;
351 let end = html[gt..].find("</a>")? + gt;
352 Some(strip_html(&html[gt..end]))
353 })
354 .unwrap_or_default();
355
356 if !url.is_empty() && !title.is_empty() {
357 hits.push(cortex::actions::SearchHit {
358 title,
359 url,
360 snippet,
361 });
362 }
363 cursor = window_end;
364 }
365
366 hits
367}
368
369fn decode_ddg_redirect(href: &str) -> String {
374 let trimmed = href
375 .trim()
376 .trim_start_matches("https:")
377 .trim_start_matches("http:");
378 if !(trimmed.starts_with("//duckduckgo.com/l/") || trimmed.starts_with("/l/?uddg=")) {
379 if let Some(stripped) = href.strip_prefix("//") {
382 return format!("https://{stripped}");
383 }
384 return href.to_string();
385 }
386 let Some(start) = href.find("uddg=") else {
388 return href.to_string();
389 };
390 let after = &href[start + 5..];
391 let end = after.find('&').unwrap_or(after.len());
392 percent_decode(&after[..end])
393}
394
395fn percent_decode(s: &str) -> String {
399 let bytes = s.as_bytes();
400 let mut out = Vec::with_capacity(bytes.len());
401 let mut i = 0;
402 while i < bytes.len() {
403 if bytes[i] == b'%' && i + 2 < bytes.len() {
404 let hi = hex_digit(bytes[i + 1]);
405 let lo = hex_digit(bytes[i + 2]);
406 if let (Some(h), Some(l)) = (hi, lo) {
407 out.push((h << 4) | l);
408 i += 3;
409 continue;
410 }
411 }
412 if bytes[i] == b'+' {
413 out.push(b' ');
414 } else {
415 out.push(bytes[i]);
416 }
417 i += 1;
418 }
419 String::from_utf8_lossy(&out).into_owned()
420}
421
422fn hex_digit(b: u8) -> Option<u8> {
423 match b {
424 b'0'..=b'9' => Some(b - b'0'),
425 b'a'..=b'f' => Some(b - b'a' + 10),
426 b'A'..=b'F' => Some(b - b'A' + 10),
427 _ => None,
428 }
429}
430
431fn strip_html(s: &str) -> String {
436 let mut out = String::with_capacity(s.len());
437 let mut in_tag = false;
438 for ch in s.chars() {
439 match ch {
440 '<' => in_tag = true,
441 '>' => in_tag = false,
442 _ if in_tag => {}
443 _ => out.push(ch),
444 }
445 }
446 let decoded = out
447 .replace("&", "&")
448 .replace("'", "'")
449 .replace("'", "'")
450 .replace(""", "\"")
451 .replace("<", "<")
452 .replace(">", ">")
453 .replace(" ", " ");
454 decoded.split_whitespace().collect::<Vec<_>>().join(" ")
455}
456
457pub struct SearxngSearchBackend {
459 endpoint: String,
460 client: reqwest::Client,
461 circuit_breaker: Arc<CircuitBreaker>,
462 max_retries: u32,
463 retry_base_ms: u64,
464}
465
466impl SearxngSearchBackend {
467 pub fn new(
468 endpoint: &str,
469 timeout_ms: u64,
470 resilience: &brain_core::config::ResilienceConfig,
471 ) -> anyhow::Result<Self> {
472 Self::new_with_metrics(endpoint, timeout_ms, resilience, None)
473 }
474
475 pub fn new_with_metrics(
476 endpoint: &str,
477 timeout_ms: u64,
478 resilience: &brain_core::config::ResilienceConfig,
479 metrics: Option<Arc<SubsystemMetrics>>,
480 ) -> anyhow::Result<Self> {
481 Ok(Self {
482 endpoint: endpoint.trim_end_matches('/').to_string(),
483 client: build_search_client(timeout_ms)?,
484 circuit_breaker: Arc::new(make_cb("searxng", resilience, metrics)),
485 max_retries: resilience.max_retries,
486 retry_base_ms: resilience.retry_base_ms,
487 })
488 }
489}
490
491#[async_trait::async_trait]
492impl cortex::actions::WebSearchBackend for SearxngSearchBackend {
493 async fn search(
494 &self,
495 query: &str,
496 top_k: usize,
497 ) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
498 let url = format!("{}/search", self.endpoint);
499 let client = self.client.clone();
500 let url_clone = url.clone();
501 let query_owned = query.to_string();
502 let response = resilient_send(
503 || {
504 client
505 .get(&url_clone)
506 .query(&[("q", query_owned.as_str()), ("format", "json")])
507 },
508 &self.circuit_breaker,
509 self.max_retries,
510 self.retry_base_ms,
511 )
512 .await?;
513
514 if !response.status().is_success() {
515 return Err(cortex::actions::ActionError::ExecutionFailed(format!(
516 "SearXNG returned HTTP {}",
517 response.status()
518 )));
519 }
520
521 let body = response
522 .json::<serde_json::Value>()
523 .await
524 .map_err(|e| cortex::actions::ActionError::ExecutionFailed(e.to_string()))?;
525
526 let candidates = match body.get("results").and_then(|v| v.as_array()) {
527 Some(arr) => arr.clone(),
528 None => {
529 tracing::warn!(
530 backend = "searxng",
531 "Response missing 'results' array — returning empty"
532 );
533 Vec::new()
534 }
535 };
536
537 Ok(parse_search_results(candidates, top_k))
538 }
539}
540
541pub struct TavilySearchBackend {
543 endpoint: String,
544 api_key: String,
545 client: reqwest::Client,
546 circuit_breaker: Arc<CircuitBreaker>,
547 max_retries: u32,
548 retry_base_ms: u64,
549}
550
551impl TavilySearchBackend {
552 pub fn new(
553 endpoint: &str,
554 api_key: &str,
555 timeout_ms: u64,
556 resilience: &brain_core::config::ResilienceConfig,
557 ) -> anyhow::Result<Self> {
558 Self::new_with_metrics(endpoint, api_key, timeout_ms, resilience, None)
559 }
560
561 pub fn new_with_metrics(
562 endpoint: &str,
563 api_key: &str,
564 timeout_ms: u64,
565 resilience: &brain_core::config::ResilienceConfig,
566 metrics: Option<Arc<SubsystemMetrics>>,
567 ) -> anyhow::Result<Self> {
568 Ok(Self {
569 endpoint: endpoint.trim_end_matches('/').to_string(),
570 api_key: api_key.to_string(),
571 client: build_search_client(timeout_ms)?,
572 circuit_breaker: Arc::new(make_cb("tavily", resilience, metrics)),
573 max_retries: resilience.max_retries,
574 retry_base_ms: resilience.retry_base_ms,
575 })
576 }
577}
578
579#[async_trait::async_trait]
580impl cortex::actions::WebSearchBackend for TavilySearchBackend {
581 async fn search(
582 &self,
583 query: &str,
584 top_k: usize,
585 ) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
586 let url = format!("{}/search", self.endpoint);
587 let client = self.client.clone();
588 let url_clone = url.clone();
589 let api_key = self.api_key.clone();
590 let query_owned = query.to_string();
591 let response = resilient_send(
592 || {
593 client
594 .post(&url_clone)
595 .bearer_auth(&api_key)
596 .json(&serde_json::json!({
597 "query": query_owned,
598 "max_results": top_k,
599 "search_depth": "basic",
600 }))
601 },
602 &self.circuit_breaker,
603 self.max_retries,
604 self.retry_base_ms,
605 )
606 .await?;
607
608 if !response.status().is_success() {
609 return Err(cortex::actions::ActionError::ExecutionFailed(format!(
610 "Tavily returned HTTP {}",
611 response.status()
612 )));
613 }
614
615 let body = response
616 .json::<serde_json::Value>()
617 .await
618 .map_err(|e| cortex::actions::ActionError::ExecutionFailed(e.to_string()))?;
619
620 let candidates = match body.get("results").and_then(|v| v.as_array()) {
621 Some(arr) => {
622 if !arr.is_empty() && arr[0].get("url").is_none() {
623 tracing::warn!(
624 backend = "tavily",
625 "Results missing 'url' field — response schema may have changed"
626 );
627 }
628 arr.clone()
629 }
630 None => {
631 tracing::warn!(
632 backend = "tavily",
633 "Response missing 'results' array — returning empty"
634 );
635 Vec::new()
636 }
637 };
638
639 Ok(parse_search_results(candidates, top_k))
640 }
641}
642
643pub struct CustomSearchBackend {
645 endpoint: String,
646 client: reqwest::Client,
647 circuit_breaker: Arc<CircuitBreaker>,
648 max_retries: u32,
649 retry_base_ms: u64,
650}
651
652impl CustomSearchBackend {
653 pub fn new(
654 endpoint: &str,
655 timeout_ms: u64,
656 resilience: &brain_core::config::ResilienceConfig,
657 ) -> anyhow::Result<Self> {
658 Self::new_with_metrics(endpoint, timeout_ms, resilience, None)
659 }
660
661 pub fn new_with_metrics(
662 endpoint: &str,
663 timeout_ms: u64,
664 resilience: &brain_core::config::ResilienceConfig,
665 metrics: Option<Arc<SubsystemMetrics>>,
666 ) -> anyhow::Result<Self> {
667 Ok(Self {
668 endpoint: endpoint.to_string(),
669 client: build_search_client(timeout_ms)?,
670 circuit_breaker: Arc::new(make_cb("custom-search", resilience, metrics)),
671 max_retries: resilience.max_retries,
672 retry_base_ms: resilience.retry_base_ms,
673 })
674 }
675}
676
677#[async_trait::async_trait]
678impl cortex::actions::WebSearchBackend for CustomSearchBackend {
679 async fn search(
680 &self,
681 query: &str,
682 top_k: usize,
683 ) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
684 let client = self.client.clone();
685 let endpoint = self.endpoint.clone();
686 let query_owned = query.to_string();
687 let response = resilient_send(
688 || {
689 client.post(&endpoint).json(&serde_json::json!({
690 "query": query_owned,
691 "top_k": top_k,
692 }))
693 },
694 &self.circuit_breaker,
695 self.max_retries,
696 self.retry_base_ms,
697 )
698 .await?;
699
700 if !response.status().is_success() {
701 return Err(cortex::actions::ActionError::ExecutionFailed(format!(
702 "search endpoint returned HTTP {}",
703 response.status()
704 )));
705 }
706
707 let body = response
708 .json::<serde_json::Value>()
709 .await
710 .map_err(|e| cortex::actions::ActionError::ExecutionFailed(e.to_string()))?;
711
712 let candidates: Vec<serde_json::Value> = body
713 .get("hits")
714 .and_then(|v| v.as_array())
715 .cloned()
716 .or_else(|| body.get("results").and_then(|v| v.as_array()).cloned())
717 .or_else(|| body.as_array().cloned())
718 .unwrap_or_default();
719
720 Ok(parse_search_results(candidates, top_k))
721 }
722}
723
724#[cfg(test)]
725mod ddg_parser_tests {
726 use super::*;
727
728 const SAMPLE_HTML: &str = r#"
729 <div class="result results_links">
730 <h2 class="result__title">
731 <a class="result__a" rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.rust-lang.org%2F&rut=abc">
732 The Rust Programming Language
733 </a>
734 </h2>
735 <a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.rust-lang.org%2F">
736 A language empowering everyone to build <b>reliable</b> and efficient software.
737 </a>
738 </div>
739 <div class="result results_links">
740 <h2 class="result__title">
741 <a class="result__a" rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fbook%2F&rut=def">
742 The Rust Programming Language - The Rust Book
743 </a>
744 </h2>
745 <a class="result__snippet">
746 An introductory book about Rust's ownership & lifetimes.
747 </a>
748 </div>
749 "#;
750
751 #[test]
752 fn parses_title_url_and_snippet_from_real_layout() {
753 let hits = parse_duckduckgo_html(SAMPLE_HTML, 10);
754 assert_eq!(hits.len(), 2, "should pick up both result blocks");
755 assert_eq!(hits[0].title, "The Rust Programming Language");
756 assert_eq!(hits[0].url, "https://www.rust-lang.org/");
757 assert!(hits[0].snippet.contains("reliable and efficient"));
758 assert_eq!(hits[1].url, "https://doc.rust-lang.org/book/");
759 assert!(hits[1].snippet.contains("Rust's ownership & lifetimes"));
761 }
762
763 #[test]
764 fn respects_top_k() {
765 let hits = parse_duckduckgo_html(SAMPLE_HTML, 1);
766 assert_eq!(hits.len(), 1);
767 }
768
769 #[test]
770 fn returns_empty_on_unrecognised_layout() {
771 let hits = parse_duckduckgo_html("<html><body>no results</body></html>", 5);
772 assert!(hits.is_empty());
773 }
774
775 #[test]
776 fn decodes_redirect_and_passes_direct_urls_through() {
777 assert_eq!(
778 decode_ddg_redirect("//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=x"),
779 "https://example.com/a"
780 );
781 assert_eq!(
783 decode_ddg_redirect("https://example.com/direct"),
784 "https://example.com/direct"
785 );
786 assert_eq!(
788 decode_ddg_redirect("//example.com/x"),
789 "https://example.com/x"
790 );
791 }
792
793 #[test]
794 fn percent_decoder_handles_plus_and_hex() {
795 assert_eq!(percent_decode("hello+world"), "hello world");
796 assert_eq!(percent_decode("a%20b%2Fc"), "a b/c");
797 assert_eq!(percent_decode("a%ZZb"), "a%ZZb");
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805 use cortex::actions::WebSearchBackend;
806
807 fn fast_resilience() -> brain_core::config::ResilienceConfig {
808 brain_core::config::ResilienceConfig {
809 max_retries: 0,
810 retry_base_ms: 10,
811 circuit_breaker_threshold: 5,
812 circuit_breaker_cooldown_secs: 60,
813 }
814 }
815
816 #[tokio::test]
817 async fn test_searxng_successful_search() {
818 let mut server = mockito::Server::new_async().await;
819 let _mock = server
820 .mock("GET", "/search")
821 .match_query(mockito::Matcher::UrlEncoded("q".into(), "rust".into()))
822 .with_status(200)
823 .with_header("content-type", "application/json")
824 .with_body(
825 r#"{
826 "results": [
827 {"title": "Rust docs", "url": "https://doc.rust-lang.org", "content": "language docs"},
828 {"title": "Rust book", "url": "https://rust-book.rs", "content": "book"}
829 ]
830 }"#,
831 )
832 .create_async()
833 .await;
834
835 let backend = SearxngSearchBackend::new(&server.url(), 5000, &fast_resilience()).unwrap();
836 let hits = backend.search("rust", 10).await.unwrap();
837 assert_eq!(hits.len(), 2);
838 assert_eq!(hits[0].title, "Rust docs");
839 assert_eq!(hits[0].url, "https://doc.rust-lang.org");
840 assert_eq!(hits[0].snippet, "language docs");
841 }
842
843 #[tokio::test]
844 async fn test_searxng_empty_results() {
845 let mut server = mockito::Server::new_async().await;
846 let _mock = server
847 .mock("GET", "/search")
848 .match_query(mockito::Matcher::Any)
849 .with_status(200)
850 .with_header("content-type", "application/json")
851 .with_body(r#"{"results": []}"#)
852 .create_async()
853 .await;
854
855 let backend = SearxngSearchBackend::new(&server.url(), 5000, &fast_resilience()).unwrap();
856 let hits = backend.search("nothing", 10).await.unwrap();
857 assert!(hits.is_empty());
858 }
859
860 #[tokio::test]
861 async fn test_searxng_5xx_surfaces_as_error() {
862 let mut server = mockito::Server::new_async().await;
863 let _mock = server
864 .mock("GET", "/search")
865 .match_query(mockito::Matcher::Any)
866 .with_status(500)
867 .with_body("internal error")
868 .expect_at_least(1)
869 .create_async()
870 .await;
871
872 let backend = SearxngSearchBackend::new(&server.url(), 5000, &fast_resilience()).unwrap();
873 let result = backend.search("boom", 10).await;
874 assert!(result.is_err(), "expected 5xx to surface as error");
875 }
876
877 #[tokio::test]
878 async fn test_searxng_top_k_limit() {
879 let mut server = mockito::Server::new_async().await;
880 let _mock = server
881 .mock("GET", "/search")
882 .match_query(mockito::Matcher::Any)
883 .with_status(200)
884 .with_header("content-type", "application/json")
885 .with_body(
886 r#"{
887 "results": [
888 {"title": "a", "url": "https://a.com", "content": ""},
889 {"title": "b", "url": "https://b.com", "content": ""},
890 {"title": "c", "url": "https://c.com", "content": ""},
891 {"title": "d", "url": "https://d.com", "content": ""}
892 ]
893 }"#,
894 )
895 .create_async()
896 .await;
897
898 let backend = SearxngSearchBackend::new(&server.url(), 5000, &fast_resilience()).unwrap();
899 let hits = backend.search("q", 2).await.unwrap();
900 assert_eq!(hits.len(), 2);
901 }
902
903 #[tokio::test]
904 async fn test_searxng_missing_results_field_returns_empty() {
905 let mut server = mockito::Server::new_async().await;
906 let _mock = server
907 .mock("GET", "/search")
908 .match_query(mockito::Matcher::Any)
909 .with_status(200)
910 .with_header("content-type", "application/json")
911 .with_body(r#"{"something": "else"}"#)
912 .create_async()
913 .await;
914
915 let backend = SearxngSearchBackend::new(&server.url(), 5000, &fast_resilience()).unwrap();
916 let hits = backend.search("q", 10).await.unwrap();
917 assert!(hits.is_empty());
918 }
919
920 #[tokio::test]
921 async fn test_tavily_successful_search_sends_bearer() {
922 let mut server = mockito::Server::new_async().await;
923 let _mock = server
924 .mock("POST", "/search")
925 .match_header("authorization", "Bearer tvly-test-key")
926 .with_status(200)
927 .with_header("content-type", "application/json")
928 .with_body(
929 r#"{
930 "results": [
931 {"title": "Tavily hit", "url": "https://example.com", "content": "snippet"}
932 ]
933 }"#,
934 )
935 .create_async()
936 .await;
937
938 let backend =
939 TavilySearchBackend::new(&server.url(), "tvly-test-key", 5000, &fast_resilience())
940 .unwrap();
941 let hits = backend.search("question", 5).await.unwrap();
942 assert_eq!(hits.len(), 1);
943 assert_eq!(hits[0].title, "Tavily hit");
944 }
945}