Skip to main content

brainos_backends/
search.rs

1//! Web search backends — DuckDuckGo (zero-config built-in), SearXNG,
2//! Tavily, and custom endpoint providers.
3
4use 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
27/// Parse a JSON array of search results into `SearchHit`s with flexible field names.
28fn 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
74/// DuckDuckGo HTML provider — zero-config, no API key, no Docker.
75/// Hits the public `html.duckduckgo.com` endpoint and parses result
76/// blocks out of the response. Quality is more limited than a metasearch
77/// aggregator like SearXNG (single engine, no rich snippets), but it
78/// works on every install with no setup. Falls back gracefully when
79/// the HTML layout changes — bad parses return empty rather than panic.
80pub 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        // DDG's HTML endpoint inspects the User-Agent and serves the
101        // post-only "we redirected you" stub if it looks too generic.
102        // A normal-looking UA gets the real result list.
103        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        // Strategy: try the official Instant Answer JSON API first (always
130        // reachable, no anti-bot, but only returns hits when DDG has a
131        // categorized answer). If it produces nothing useful, fall back
132        // to scraping `html.duckduckgo.com` — which DDG sometimes blocks
133        // with an anomaly/CAPTCHA page; that case is detected and surfaced
134        // as a clear error pointing to SearXNG / Tavily.
135        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        // POST to the same `html.duckduckgo.com/html/` endpoint with
182        // browser-style headers — this is the form a real search submission
183        // takes, and DDG is somewhat less aggressive about challenging it.
184        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        // DDG's anti-bot interstitial swaps out result markup for an
219        // "anomaly-modal" CAPTCHA. Detect and surface a clean error so
220        // callers know to switch backends instead of seeing silent zero
221        // hits.
222        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
235/// Convert DuckDuckGo Instant Answer API response into search hits.
236/// Picks up the abstract (with source URL) and any RelatedTopics that
237/// have first-class URL+text — skipping disambiguation/category headers.
238fn 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            // Use the first sentence of `Text` as the title, the rest as snippet.
273            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
288/// Pull title/url/snippet out of DuckDuckGo's HTML response. Each result
289/// is wrapped in a `<div class="result …">` block; inside it the title
290/// link has class `result__a`, the snippet has class `result__snippet`.
291/// DDG rewrites destination URLs through `/l/?uddg=<percent-encoded>`,
292/// so we have to undo that to surface the real link.
293fn 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        // Find the next result block. Match on the class anchor that's
300        // stable across DDG's HTML variants: every result row contains
301        // an `<a class="result__a"` for the title link.
302        let Some(rel) = html[cursor..].find("class=\"result__a\"") else {
303            break;
304        };
305        let block_start = cursor + rel;
306        // Walk back to find the opening `<a` of that anchor.
307        let Some(a_open) = html[..block_start].rfind("<a ") else {
308            cursor = block_start + 1;
309            continue;
310        };
311        // Extract href.
312        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        // Title text: everything between the `>` after the anchor open
326        // and the matching `</a>`.
327        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        // Snippet — the next `class="result__snippet"` block within a
340        // bounded window so we don't pull a snippet from the next result.
341        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
369/// DDG wraps every result URL in `https://duckduckgo.com/l/?uddg=…&…` (or
370/// the protocol-relative `//duckduckgo.com/l/?uddg=…`). Extract the
371/// `uddg` parameter and percent-decode it; if the input isn't a redirect,
372/// return it unchanged so direct links still work.
373fn 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        // Not a redirect — but still normalise protocol-relative URLs
380        // so downstream consumers get a fully-qualified scheme.
381        if let Some(stripped) = href.strip_prefix("//") {
382            return format!("https://{stripped}");
383        }
384        return href.to_string();
385    }
386    // Find `uddg=` and percent-decode up to the next `&` or end of string.
387    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
395/// Minimal percent-decoder. We can't pull `percent-encoding` for one
396/// call site without bloating the dep tree; the input is well-formed
397/// query-string output so this covers the cases that matter.
398fn 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
431/// Strip HTML tags + decode the entities DDG actually emits in titles
432/// and snippets (`&amp;`, `&#39;`, `&quot;`, `&lt;`, `&gt;`, `&nbsp;`).
433/// Whitespace runs collapse to a single space so the snippet renders
434/// cleanly when the LLM quotes it back.
435fn 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("&amp;", "&")
448        .replace("&#39;", "'")
449        .replace("&apos;", "'")
450        .replace("&quot;", "\"")
451        .replace("&lt;", "<")
452        .replace("&gt;", ">")
453        .replace("&nbsp;", " ");
454    decoded.split_whitespace().collect::<Vec<_>>().join(" ")
455}
456
457/// SearXNG provider — self-hosted metasearch engine.
458pub 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
541/// Tavily provider — AI-focused search API.
542pub 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
643/// Custom provider — raw JSON POST to a user-configured endpoint.
644pub 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&amp;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&amp;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&#39;s ownership &amp; 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        // HTML entities in the snippet should be decoded.
760        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        // Direct https URL — pass through unchanged.
782        assert_eq!(
783            decode_ddg_redirect("https://example.com/direct"),
784            "https://example.com/direct"
785        );
786        // Protocol-relative non-redirect — normalise to https.
787        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        // Malformed escapes are passed through verbatim instead of panicking.
798        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}