use std::sync::Arc;
use brain_core::metrics::SubsystemMetrics;
use crate::resilience::{resilient_send, CircuitBreaker};
fn make_cb(
name: &str,
resilience: &brain_core::config::ResilienceConfig,
metrics: Option<Arc<SubsystemMetrics>>,
) -> CircuitBreaker {
let cb = CircuitBreaker::new(
name,
resilience.circuit_breaker_threshold,
resilience.circuit_breaker_cooldown_secs,
);
if let Some(m) = metrics {
cb.with_metrics(m)
} else {
cb
}
}
fn parse_search_results(
candidates: Vec<serde_json::Value>,
top_k: usize,
) -> Vec<cortex::actions::SearchHit> {
candidates
.into_iter()
.filter_map(|entry| {
let title = entry
.get("title")
.and_then(serde_json::Value::as_str)
.or_else(|| entry.get("name").and_then(serde_json::Value::as_str))
.unwrap_or("untitled")
.to_string();
let url = entry
.get("url")
.and_then(serde_json::Value::as_str)
.or_else(|| entry.get("link").and_then(serde_json::Value::as_str))
.unwrap_or_default()
.to_string();
if url.is_empty() {
return None;
}
let snippet = entry
.get("snippet")
.and_then(serde_json::Value::as_str)
.or_else(|| entry.get("description").and_then(serde_json::Value::as_str))
.or_else(|| entry.get("content").and_then(serde_json::Value::as_str))
.unwrap_or_default()
.to_string();
Some(cortex::actions::SearchHit {
title,
url,
snippet,
})
})
.take(top_k.max(1))
.collect()
}
pub fn build_search_client(timeout_ms: u64) -> anyhow::Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(timeout_ms.max(1)))
.build()
.map_err(|e| anyhow::anyhow!("search client init failed: {e}"))
}
pub struct DuckDuckGoSearchBackend {
client: reqwest::Client,
circuit_breaker: Arc<CircuitBreaker>,
max_retries: u32,
retry_base_ms: u64,
}
impl DuckDuckGoSearchBackend {
pub fn new(
timeout_ms: u64,
resilience: &brain_core::config::ResilienceConfig,
) -> anyhow::Result<Self> {
Self::new_with_metrics(timeout_ms, resilience, None)
}
pub fn new_with_metrics(
timeout_ms: u64,
resilience: &brain_core::config::ResilienceConfig,
metrics: Option<Arc<SubsystemMetrics>>,
) -> anyhow::Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(timeout_ms.max(1)))
.user_agent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/124.0.0.0 Safari/537.36",
)
.redirect(reqwest::redirect::Policy::limited(3))
.build()
.map_err(|e| anyhow::anyhow!("DuckDuckGo client init failed: {e}"))?;
Ok(Self {
client,
circuit_breaker: Arc::new(make_cb("duckduckgo", resilience, metrics)),
max_retries: resilience.max_retries,
retry_base_ms: resilience.retry_base_ms,
})
}
}
#[async_trait::async_trait]
impl cortex::actions::WebSearchBackend for DuckDuckGoSearchBackend {
async fn search(
&self,
query: &str,
top_k: usize,
) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
let from_ia = self.search_instant_answer(query, top_k).await?;
if !from_ia.is_empty() {
return Ok(from_ia);
}
self.search_html(query, top_k).await
}
}
impl DuckDuckGoSearchBackend {
async fn search_instant_answer(
&self,
query: &str,
top_k: usize,
) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
let url = "https://api.duckduckgo.com/";
let client = self.client.clone();
let q = query.to_string();
let resp = resilient_send(
|| {
client.get(url).query(&[
("q", q.as_str()),
("format", "json"),
("no_html", "1"),
("skip_disambig", "1"),
])
},
&self.circuit_breaker,
self.max_retries,
self.retry_base_ms,
)
.await?;
if !resp.status().is_success() {
return Ok(Vec::new());
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(_) => return Ok(Vec::new()),
};
Ok(parse_instant_answer(&body, top_k))
}
async fn search_html(
&self,
query: &str,
top_k: usize,
) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
let url = "https://html.duckduckgo.com/html/";
let client = self.client.clone();
let q = query.to_string();
let response = resilient_send(
|| {
client
.post(url)
.header(
reqwest::header::ACCEPT,
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
)
.header(reqwest::header::ACCEPT_LANGUAGE, "en-US,en;q=0.9")
.header(reqwest::header::REFERER, "https://duckduckgo.com/")
.header(reqwest::header::ORIGIN, "https://duckduckgo.com")
.form(&[("q", q.as_str())])
},
&self.circuit_breaker,
self.max_retries,
self.retry_base_ms,
)
.await?;
if !response.status().is_success() {
return Err(cortex::actions::ActionError::ExecutionFailed(format!(
"DuckDuckGo returned HTTP {}",
response.status()
)));
}
let html = response
.text()
.await
.map_err(|e| cortex::actions::ActionError::ExecutionFailed(e.to_string()))?;
if html.contains("anomaly-modal") {
return Err(cortex::actions::ActionError::ExecutionFailed(
"DuckDuckGo served a bot-challenge page instead of results. \
Run `brain deps up` to use SearXNG, or set \
`actions.web_search.provider: tavily` with an API key."
.into(),
));
}
Ok(parse_duckduckgo_html(&html, top_k))
}
}
fn parse_instant_answer(body: &serde_json::Value, top_k: usize) -> Vec<cortex::actions::SearchHit> {
let mut hits = Vec::new();
let abstract_text = body
.get("AbstractText")
.and_then(|v| v.as_str())
.unwrap_or("");
let abstract_url = body
.get("AbstractURL")
.and_then(|v| v.as_str())
.unwrap_or("");
let heading = body.get("Heading").and_then(|v| v.as_str()).unwrap_or("");
if !abstract_text.is_empty() && !abstract_url.is_empty() {
hits.push(cortex::actions::SearchHit {
title: if heading.is_empty() {
"DuckDuckGo Instant Answer".to_string()
} else {
heading.to_string()
},
url: abstract_url.to_string(),
snippet: abstract_text.to_string(),
});
}
if let Some(topics) = body.get("RelatedTopics").and_then(|v| v.as_array()) {
for t in topics {
if hits.len() >= top_k.max(1) {
break;
}
let url = t.get("FirstURL").and_then(|v| v.as_str()).unwrap_or("");
let text = t.get("Text").and_then(|v| v.as_str()).unwrap_or("");
if url.is_empty() || text.is_empty() {
continue;
}
let (title, snippet) = match text.split_once(" - ") {
Some((t, s)) => (t.to_string(), s.to_string()),
None => (text.to_string(), text.to_string()),
};
hits.push(cortex::actions::SearchHit {
title,
url: url.to_string(),
snippet,
});
}
}
hits.into_iter().take(top_k.max(1)).collect()
}
fn parse_duckduckgo_html(html: &str, top_k: usize) -> Vec<cortex::actions::SearchHit> {
let mut hits = Vec::new();
let mut cursor = 0usize;
let max = top_k.max(1);
while hits.len() < max {
let Some(rel) = html[cursor..].find("class=\"result__a\"") else {
break;
};
let block_start = cursor + rel;
let Some(a_open) = html[..block_start].rfind("<a ") else {
cursor = block_start + 1;
continue;
};
let after_a_open = a_open + 3;
let Some(href_rel) = html[after_a_open..].find("href=\"") else {
cursor = block_start + 1;
continue;
};
let href_start = after_a_open + href_rel + 6;
let Some(href_end_rel) = html[href_start..].find('"') else {
cursor = block_start + 1;
continue;
};
let href_raw = &html[href_start..href_start + href_end_rel];
let url = decode_ddg_redirect(href_raw);
let Some(gt_rel) = html[block_start..].find('>') else {
cursor = block_start + 1;
continue;
};
let title_start = block_start + gt_rel + 1;
let Some(title_end_rel) = html[title_start..].find("</a>") else {
cursor = block_start + 1;
continue;
};
let title = strip_html(&html[title_start..title_start + title_end_rel]);
let snippet_search_from = title_start + title_end_rel;
let window_end = html[snippet_search_from..]
.find("class=\"result__a\"")
.map(|r| snippet_search_from + r)
.unwrap_or(html.len());
let window = &html[snippet_search_from..window_end];
let snippet = window
.find("class=\"result__snippet\"")
.and_then(|s| {
let from = snippet_search_from + s;
let gt = html[from..].find('>')? + from + 1;
let end = html[gt..].find("</a>")? + gt;
Some(strip_html(&html[gt..end]))
})
.unwrap_or_default();
if !url.is_empty() && !title.is_empty() {
hits.push(cortex::actions::SearchHit {
title,
url,
snippet,
});
}
cursor = window_end;
}
hits
}
fn decode_ddg_redirect(href: &str) -> String {
let trimmed = href
.trim()
.trim_start_matches("https:")
.trim_start_matches("http:");
if !(trimmed.starts_with("//duckduckgo.com/l/") || trimmed.starts_with("/l/?uddg=")) {
if let Some(stripped) = href.strip_prefix("//") {
return format!("https://{stripped}");
}
return href.to_string();
}
let Some(start) = href.find("uddg=") else {
return href.to_string();
};
let after = &href[start + 5..];
let end = after.find('&').unwrap_or(after.len());
percent_decode(&after[..end])
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = hex_digit(bytes[i + 1]);
let lo = hex_digit(bytes[i + 2]);
if let (Some(h), Some(l)) = (hi, lo) {
out.push((h << 4) | l);
i += 3;
continue;
}
}
if bytes[i] == b'+' {
out.push(b' ');
} else {
out.push(bytes[i]);
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn strip_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_tag = false;
for ch in s.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if in_tag => {}
_ => out.push(ch),
}
}
let decoded = out
.replace("&", "&")
.replace("'", "'")
.replace("'", "'")
.replace(""", "\"")
.replace("<", "<")
.replace(">", ">")
.replace(" ", " ");
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub struct SearxngSearchBackend {
endpoint: String,
client: reqwest::Client,
circuit_breaker: Arc<CircuitBreaker>,
max_retries: u32,
retry_base_ms: u64,
}
impl SearxngSearchBackend {
pub fn new(
endpoint: &str,
timeout_ms: u64,
resilience: &brain_core::config::ResilienceConfig,
) -> anyhow::Result<Self> {
Self::new_with_metrics(endpoint, timeout_ms, resilience, None)
}
pub fn new_with_metrics(
endpoint: &str,
timeout_ms: u64,
resilience: &brain_core::config::ResilienceConfig,
metrics: Option<Arc<SubsystemMetrics>>,
) -> anyhow::Result<Self> {
Ok(Self {
endpoint: endpoint.trim_end_matches('/').to_string(),
client: build_search_client(timeout_ms)?,
circuit_breaker: Arc::new(make_cb("searxng", resilience, metrics)),
max_retries: resilience.max_retries,
retry_base_ms: resilience.retry_base_ms,
})
}
}
#[async_trait::async_trait]
impl cortex::actions::WebSearchBackend for SearxngSearchBackend {
async fn search(
&self,
query: &str,
top_k: usize,
) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
let url = format!("{}/search", self.endpoint);
let client = self.client.clone();
let url_clone = url.clone();
let query_owned = query.to_string();
let response = resilient_send(
|| {
client
.get(&url_clone)
.query(&[("q", query_owned.as_str()), ("format", "json")])
},
&self.circuit_breaker,
self.max_retries,
self.retry_base_ms,
)
.await?;
if !response.status().is_success() {
return Err(cortex::actions::ActionError::ExecutionFailed(format!(
"SearXNG returned HTTP {}",
response.status()
)));
}
let body = response
.json::<serde_json::Value>()
.await
.map_err(|e| cortex::actions::ActionError::ExecutionFailed(e.to_string()))?;
let candidates = match body.get("results").and_then(|v| v.as_array()) {
Some(arr) => arr.clone(),
None => {
tracing::warn!(
backend = "searxng",
"Response missing 'results' array — returning empty"
);
Vec::new()
}
};
Ok(parse_search_results(candidates, top_k))
}
}
pub struct TavilySearchBackend {
endpoint: String,
api_key: String,
client: reqwest::Client,
circuit_breaker: Arc<CircuitBreaker>,
max_retries: u32,
retry_base_ms: u64,
}
impl TavilySearchBackend {
pub fn new(
endpoint: &str,
api_key: &str,
timeout_ms: u64,
resilience: &brain_core::config::ResilienceConfig,
) -> anyhow::Result<Self> {
Self::new_with_metrics(endpoint, api_key, timeout_ms, resilience, None)
}
pub fn new_with_metrics(
endpoint: &str,
api_key: &str,
timeout_ms: u64,
resilience: &brain_core::config::ResilienceConfig,
metrics: Option<Arc<SubsystemMetrics>>,
) -> anyhow::Result<Self> {
Ok(Self {
endpoint: endpoint.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
client: build_search_client(timeout_ms)?,
circuit_breaker: Arc::new(make_cb("tavily", resilience, metrics)),
max_retries: resilience.max_retries,
retry_base_ms: resilience.retry_base_ms,
})
}
}
#[async_trait::async_trait]
impl cortex::actions::WebSearchBackend for TavilySearchBackend {
async fn search(
&self,
query: &str,
top_k: usize,
) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
let url = format!("{}/search", self.endpoint);
let client = self.client.clone();
let url_clone = url.clone();
let api_key = self.api_key.clone();
let query_owned = query.to_string();
let response = resilient_send(
|| {
client
.post(&url_clone)
.bearer_auth(&api_key)
.json(&serde_json::json!({
"query": query_owned,
"max_results": top_k,
"search_depth": "basic",
}))
},
&self.circuit_breaker,
self.max_retries,
self.retry_base_ms,
)
.await?;
if !response.status().is_success() {
return Err(cortex::actions::ActionError::ExecutionFailed(format!(
"Tavily returned HTTP {}",
response.status()
)));
}
let body = response
.json::<serde_json::Value>()
.await
.map_err(|e| cortex::actions::ActionError::ExecutionFailed(e.to_string()))?;
let candidates = match body.get("results").and_then(|v| v.as_array()) {
Some(arr) => {
if !arr.is_empty() && arr[0].get("url").is_none() {
tracing::warn!(
backend = "tavily",
"Results missing 'url' field — response schema may have changed"
);
}
arr.clone()
}
None => {
tracing::warn!(
backend = "tavily",
"Response missing 'results' array — returning empty"
);
Vec::new()
}
};
Ok(parse_search_results(candidates, top_k))
}
}
pub struct CustomSearchBackend {
endpoint: String,
client: reqwest::Client,
circuit_breaker: Arc<CircuitBreaker>,
max_retries: u32,
retry_base_ms: u64,
}
impl CustomSearchBackend {
pub fn new(
endpoint: &str,
timeout_ms: u64,
resilience: &brain_core::config::ResilienceConfig,
) -> anyhow::Result<Self> {
Self::new_with_metrics(endpoint, timeout_ms, resilience, None)
}
pub fn new_with_metrics(
endpoint: &str,
timeout_ms: u64,
resilience: &brain_core::config::ResilienceConfig,
metrics: Option<Arc<SubsystemMetrics>>,
) -> anyhow::Result<Self> {
Ok(Self {
endpoint: endpoint.to_string(),
client: build_search_client(timeout_ms)?,
circuit_breaker: Arc::new(make_cb("custom-search", resilience, metrics)),
max_retries: resilience.max_retries,
retry_base_ms: resilience.retry_base_ms,
})
}
}
#[async_trait::async_trait]
impl cortex::actions::WebSearchBackend for CustomSearchBackend {
async fn search(
&self,
query: &str,
top_k: usize,
) -> Result<Vec<cortex::actions::SearchHit>, cortex::actions::ActionError> {
let client = self.client.clone();
let endpoint = self.endpoint.clone();
let query_owned = query.to_string();
let response = resilient_send(
|| {
client.post(&endpoint).json(&serde_json::json!({
"query": query_owned,
"top_k": top_k,
}))
},
&self.circuit_breaker,
self.max_retries,
self.retry_base_ms,
)
.await?;
if !response.status().is_success() {
return Err(cortex::actions::ActionError::ExecutionFailed(format!(
"search endpoint returned HTTP {}",
response.status()
)));
}
let body = response
.json::<serde_json::Value>()
.await
.map_err(|e| cortex::actions::ActionError::ExecutionFailed(e.to_string()))?;
let candidates: Vec<serde_json::Value> = body
.get("hits")
.and_then(|v| v.as_array())
.cloned()
.or_else(|| body.get("results").and_then(|v| v.as_array()).cloned())
.or_else(|| body.as_array().cloned())
.unwrap_or_default();
Ok(parse_search_results(candidates, top_k))
}
}
#[cfg(test)]
mod ddg_parser_tests {
use super::*;
const SAMPLE_HTML: &str = r#"
<div class="result results_links">
<h2 class="result__title">
<a class="result__a" rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.rust-lang.org%2F&rut=abc">
The Rust Programming Language
</a>
</h2>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.rust-lang.org%2F">
A language empowering everyone to build <b>reliable</b> and efficient software.
</a>
</div>
<div class="result results_links">
<h2 class="result__title">
<a class="result__a" rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fbook%2F&rut=def">
The Rust Programming Language - The Rust Book
</a>
</h2>
<a class="result__snippet">
An introductory book about Rust's ownership & lifetimes.
</a>
</div>
"#;
#[test]
fn parses_title_url_and_snippet_from_real_layout() {
let hits = parse_duckduckgo_html(SAMPLE_HTML, 10);
assert_eq!(hits.len(), 2, "should pick up both result blocks");
assert_eq!(hits[0].title, "The Rust Programming Language");
assert_eq!(hits[0].url, "https://www.rust-lang.org/");
assert!(hits[0].snippet.contains("reliable and efficient"));
assert_eq!(hits[1].url, "https://doc.rust-lang.org/book/");
assert!(hits[1].snippet.contains("Rust's ownership & lifetimes"));
}
#[test]
fn respects_top_k() {
let hits = parse_duckduckgo_html(SAMPLE_HTML, 1);
assert_eq!(hits.len(), 1);
}
#[test]
fn returns_empty_on_unrecognised_layout() {
let hits = parse_duckduckgo_html("<html><body>no results</body></html>", 5);
assert!(hits.is_empty());
}
#[test]
fn decodes_redirect_and_passes_direct_urls_through() {
assert_eq!(
decode_ddg_redirect("//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=x"),
"https://example.com/a"
);
assert_eq!(
decode_ddg_redirect("https://example.com/direct"),
"https://example.com/direct"
);
assert_eq!(
decode_ddg_redirect("//example.com/x"),
"https://example.com/x"
);
}
#[test]
fn percent_decoder_handles_plus_and_hex() {
assert_eq!(percent_decode("hello+world"), "hello world");
assert_eq!(percent_decode("a%20b%2Fc"), "a b/c");
assert_eq!(percent_decode("a%ZZb"), "a%ZZb");
}
}
#[cfg(test)]
mod tests {
use super::*;
use cortex::actions::WebSearchBackend;
fn fast_resilience() -> brain_core::config::ResilienceConfig {
brain_core::config::ResilienceConfig {
max_retries: 0,
retry_base_ms: 10,
circuit_breaker_threshold: 5,
circuit_breaker_cooldown_secs: 60,
}
}
#[tokio::test]
async fn test_searxng_successful_search() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/search")
.match_query(mockito::Matcher::UrlEncoded("q".into(), "rust".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"results": [
{"title": "Rust docs", "url": "https://doc.rust-lang.org", "content": "language docs"},
{"title": "Rust book", "url": "https://rust-book.rs", "content": "book"}
]
}"#,
)
.create_async()
.await;
let backend = SearxngSearchBackend::new(&server.url(), 5000, &fast_resilience()).unwrap();
let hits = backend.search("rust", 10).await.unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].title, "Rust docs");
assert_eq!(hits[0].url, "https://doc.rust-lang.org");
assert_eq!(hits[0].snippet, "language docs");
}
#[tokio::test]
async fn test_searxng_empty_results() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/search")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results": []}"#)
.create_async()
.await;
let backend = SearxngSearchBackend::new(&server.url(), 5000, &fast_resilience()).unwrap();
let hits = backend.search("nothing", 10).await.unwrap();
assert!(hits.is_empty());
}
#[tokio::test]
async fn test_searxng_5xx_surfaces_as_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/search")
.match_query(mockito::Matcher::Any)
.with_status(500)
.with_body("internal error")
.expect_at_least(1)
.create_async()
.await;
let backend = SearxngSearchBackend::new(&server.url(), 5000, &fast_resilience()).unwrap();
let result = backend.search("boom", 10).await;
assert!(result.is_err(), "expected 5xx to surface as error");
}
#[tokio::test]
async fn test_searxng_top_k_limit() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/search")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"results": [
{"title": "a", "url": "https://a.com", "content": ""},
{"title": "b", "url": "https://b.com", "content": ""},
{"title": "c", "url": "https://c.com", "content": ""},
{"title": "d", "url": "https://d.com", "content": ""}
]
}"#,
)
.create_async()
.await;
let backend = SearxngSearchBackend::new(&server.url(), 5000, &fast_resilience()).unwrap();
let hits = backend.search("q", 2).await.unwrap();
assert_eq!(hits.len(), 2);
}
#[tokio::test]
async fn test_searxng_missing_results_field_returns_empty() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/search")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"something": "else"}"#)
.create_async()
.await;
let backend = SearxngSearchBackend::new(&server.url(), 5000, &fast_resilience()).unwrap();
let hits = backend.search("q", 10).await.unwrap();
assert!(hits.is_empty());
}
#[tokio::test]
async fn test_tavily_successful_search_sends_bearer() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/search")
.match_header("authorization", "Bearer tvly-test-key")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"results": [
{"title": "Tavily hit", "url": "https://example.com", "content": "snippet"}
]
}"#,
)
.create_async()
.await;
let backend =
TavilySearchBackend::new(&server.url(), "tvly-test-key", 5000, &fast_resilience())
.unwrap();
let hits = backend.search("question", 5).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "Tavily hit");
}
}