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