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 metrics::SubsystemMetrics;
7
8use crate::resilience::{http_breaker, resilient_send, CircuitBreaker};
9
10/// The capability this backend declares: outbound HTTP for web search + URL
11/// fetch, gated on `actions.web_search.enabled` (the same flag the dispatcher
12/// keys the search/fetch backends off).
13pub 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
46/// Parse a JSON array of search results into `SearchHit`s with flexible field names.
47fn 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
95/// DuckDuckGo HTML provider — zero-config, no API key, no Docker.
96/// Hits the public `html.duckduckgo.com` endpoint and parses result
97/// blocks out of the response. Quality is more limited than a metasearch
98/// aggregator like SearXNG (single engine, no rich snippets), but it
99/// works on every install with no setup. Falls back gracefully when
100/// the HTML layout changes — bad parses return empty rather than panic.
101pub 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        // DDG's HTML endpoint inspects the User-Agent and serves the
122        // post-only "we redirected you" stub if it looks too generic.
123        // A normal-looking UA gets the real result list.
124        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        // Strategy: try the official Instant Answer JSON API first (always
151        // reachable, no anti-bot, but only returns hits when DDG has a
152        // categorized answer). If it produces nothing useful, fall back
153        // to scraping `html.duckduckgo.com` — which DDG sometimes blocks
154        // with an anomaly/CAPTCHA page; that case is detected and surfaced
155        // as a clear error pointing to SearXNG / Tavily.
156        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        // POST to the same `html.duckduckgo.com/html/` endpoint with
204        // browser-style headers — this is the form a real search submission
205        // takes, and DDG is somewhat less aggressive about challenging it.
206        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        // DDG's anti-bot interstitial swaps out result markup for an
242        // "anomaly-modal" CAPTCHA. Detect and surface a clean error so
243        // callers know to switch backends instead of seeing silent zero
244        // hits.
245        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
258/// Convert DuckDuckGo Instant Answer API response into search hits.
259/// Picks up the abstract (with source URL) and any RelatedTopics that
260/// have first-class URL+text — skipping disambiguation/category headers.
261fn 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            // Use the first sentence of `Text` as the title, the rest as snippet.
296            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
311/// Pull title/url/snippet out of DuckDuckGo's HTML response. Each result
312/// is wrapped in a `<div class="result …">` block; inside it the title
313/// link has class `result__a`, the snippet has class `result__snippet`.
314/// DDG rewrites destination URLs through `/l/?uddg=<percent-encoded>`,
315/// so we have to undo that to surface the real link.
316fn 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        // Find the next result block. Match on the class anchor that's
323        // stable across DDG's HTML variants: every result row contains
324        // an `<a class="result__a"` for the title link.
325        let Some(rel) = html[cursor..].find("class=\"result__a\"") else {
326            break;
327        };
328        let block_start = cursor + rel;
329        // Walk back to find the opening `<a` of that anchor.
330        let Some(a_open) = html[..block_start].rfind("<a ") else {
331            cursor = block_start + 1;
332            continue;
333        };
334        // Extract href.
335        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        // Title text: everything between the `>` after the anchor open
349        // and the matching `</a>`.
350        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        // Snippet — the next `class="result__snippet"` block within a
363        // bounded window so we don't pull a snippet from the next result.
364        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
392/// DDG wraps every result URL in `https://duckduckgo.com/l/?uddg=…&…` (or
393/// the protocol-relative `//duckduckgo.com/l/?uddg=…`). Extract the
394/// `uddg` parameter and percent-decode it; if the input isn't a redirect,
395/// return it unchanged so direct links still work.
396fn 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        // Not a redirect — but still normalise protocol-relative URLs
403        // so downstream consumers get a fully-qualified scheme.
404        if let Some(stripped) = href.strip_prefix("//") {
405            return format!("https://{stripped}");
406        }
407        return href.to_string();
408    }
409    // Find `uddg=` and percent-decode up to the next `&` or end of string.
410    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
418/// Minimal percent-decoder. We can't pull `percent-encoding` for one
419/// call site without bloating the dep tree; the input is well-formed
420/// query-string output so this covers the cases that matter.
421fn 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
454/// Strip HTML tags + decode the entities DDG actually emits in titles
455/// and snippets (`&amp;`, `&#39;`, `&quot;`, `&lt;`, `&gt;`, `&nbsp;`).
456/// Whitespace runs collapse to a single space so the snippet renders
457/// cleanly when the LLM quotes it back.
458fn 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("&amp;", "&")
471        .replace("&#39;", "'")
472        .replace("&apos;", "'")
473        .replace("&quot;", "\"")
474        .replace("&lt;", "<")
475        .replace("&gt;", ">")
476        .replace("&nbsp;", " ");
477    decoded.split_whitespace().collect::<Vec<_>>().join(" ")
478}
479
480/// SearXNG provider — self-hosted metasearch engine.
481pub 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
565/// Tavily provider — AI-focused search API.
566pub 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
668/// Custom provider — raw JSON POST to a user-configured endpoint.
669pub 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&amp;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&amp;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&#39;s ownership &amp; 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        // HTML entities in the snippet should be decoded.
786        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        // Direct https URL — pass through unchanged.
808        assert_eq!(
809            decode_ddg_redirect("https://example.com/direct"),
810            "https://example.com/direct"
811        );
812        // Protocol-relative non-redirect — normalise to https.
813        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        // Malformed escapes are passed through verbatim instead of panicking.
824        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}