use crate::types::{DaedraResult, SearchArgs, SearchResponse};
use async_trait::async_trait;
use tracing::{info, warn};
#[async_trait]
pub trait SearchBackend: Send + Sync {
async fn search(&self, args: &SearchArgs) -> DaedraResult<SearchResponse>;
fn name(&self) -> &str;
fn requires_api_key(&self) -> bool { false }
fn is_available(&self) -> bool { true }
}
pub struct SearchProvider {
backends: Vec<Box<dyn SearchBackend>>,
}
impl SearchProvider {
pub fn new(backends: Vec<Box<dyn SearchBackend>>) -> Self {
Self { backends }
}
pub fn auto() -> Self {
let mut backends: Vec<Box<dyn SearchBackend>> = Vec::new();
if let Ok(key) = std::env::var("SERPER_API_KEY") {
if !key.is_empty() {
info!("Serper backend enabled (SERPER_API_KEY set)");
backends.push(Box::new(super::serper::SerperBackend::new(key)));
}
}
if let Ok(key) = std::env::var("TAVILY_API_KEY") {
if !key.is_empty() {
info!("Tavily backend enabled (TAVILY_API_KEY set)");
backends.push(Box::new(super::tavily::TavilyBackend::new(key)));
}
}
info!("Bing backend enabled (no API key, may be blocked from datacenter IPs)");
backends.push(Box::new(super::bing::BingBackend::new()));
info!("Wikipedia backend enabled (always works, knowledge-focused)");
backends.push(Box::new(super::wikipedia::WikipediaBackend::new()));
info!("StackExchange backend enabled (always works, technical)");
backends.push(Box::new(super::stackexchange::StackExchangeBackend::new()));
info!("GitHub backend enabled (always works, code/repos)");
backends.push(Box::new(super::github::GitHubBackend::new()));
info!("Wiby backend enabled (always works, indie web)");
backends.push(Box::new(super::wiby::WibyBackend::new()));
info!("DDG Instant Answers backend enabled (always works, knowledge)");
backends.push(Box::new(super::ddg_instant::DdgInstantBackend::new()));
info!("DuckDuckGo HTML backend enabled (last resort)");
backends.push(Box::new(super::search::SearchClient::new().unwrap()));
Self { backends }
}
pub async fn search(&self, args: &SearchArgs) -> DaedraResult<SearchResponse> {
let opts = args.options.clone().unwrap_or_default();
let target_count = opts.num_results;
let futures: Vec<_> = self.backends.iter()
.filter(|b| b.is_available())
.map(|b| {
let a = args.clone();
let name = b.name().to_string();
async move {
info!(backend = %name, query = %a.query, "Querying backend");
(name, b.search(&a).await)
}
})
.collect();
let results = futures::future::join_all(futures).await;
let mut by_source: Vec<(String, Vec<crate::types::SearchResult>)> = Vec::new();
let mut any_success = false;
for (name, result) in results {
match result {
Ok(response) if !response.data.is_empty() => {
info!(backend = %name, count = response.data.len(), "Backend returned results");
any_success = true;
by_source.push((name, response.data));
}
Ok(_) => {
warn!(backend = %name, "Backend returned 0 results");
}
Err(e) => {
warn!(backend = %name, error = %e, "Backend failed");
}
}
}
if !any_success {
return Err(crate::types::DaedraError::SearchError(
"All search backends returned 0 results".into(),
));
}
let mut merged: Vec<crate::types::SearchResult> = Vec::new();
let mut seen_urls: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut indices: Vec<usize> = vec![0; by_source.len()];
loop {
let mut added_this_round = false;
for (i, (_name, results)) in by_source.iter().enumerate() {
if merged.len() >= target_count { break; }
while indices[i] < results.len() {
let r = &results[indices[i]];
indices[i] += 1;
if seen_urls.insert(r.url.clone()) {
merged.push(r.clone());
added_this_round = true;
break;
}
}
}
if !added_this_round || merged.len() >= target_count { break; }
}
let sources: Vec<String> = by_source.iter().map(|(n, _)| n.clone()).collect();
info!(
total = merged.len(),
sources = ?sources,
"Aggregated results from {} backends",
sources.len()
);
Ok(SearchResponse::new(args.query.clone(), merged, &opts))
}
pub fn available_backends(&self) -> Vec<&str> {
self.backends.iter()
.filter(|b| b.is_available())
.map(|b| b.name())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::SearchArgs;
#[test]
fn test_auto_has_backends() {
let provider = SearchProvider::auto();
let backends = provider.available_backends();
assert!(backends.len() >= 7, "Expected at least 7 backends, got {}", backends.len());
assert!(backends.contains(&"bing"));
assert!(backends.contains(&"wikipedia"));
assert!(backends.contains(&"stackoverflow"));
assert!(backends.contains(&"github"));
assert!(backends.contains(&"wiby"));
assert!(backends.contains(&"ddg-instant"));
assert!(backends.contains(&"duckduckgo"));
}
#[test]
fn test_empty_provider() {
let provider = SearchProvider::new(vec![]);
assert!(provider.available_backends().is_empty());
}
#[tokio::test]
async fn test_fallback_chain_live() {
let provider = SearchProvider::auto();
let args = SearchArgs {
query: "Rust programming".to_string(),
options: Some(crate::types::SearchOptions {
num_results: 3,
..Default::default()
}),
};
let response = provider.search(&args).await;
assert!(response.is_ok(), "Fallback chain should find results from at least one backend");
let data = response.unwrap();
assert!(!data.data.is_empty(), "Should have at least 1 result");
}
}