use async_trait::async_trait; use serde::Deserialize; use crate::search_engines::{EngineError, ResultEntry, SearchEngine}; pub struct SearXng { pub instance: String, pub url_tpl: String, } impl SearXng { pub fn from_config(config: &toml::Table) -> Option<Box<dyn SearchEngine>> { let instance = config.get("instance")?.as_str()?.to_string(); let url_tpl = format!("{}/search?format=json&q={{}}", instance.trim_end_matches('/')); Some(Box::new(SearXng { instance, url_tpl })) } } #[derive(Deserialize)] struct SearXngResult { title: Option<String>, url: Option<String>, content: Option<String>, engine: Option<String>, } #[derive(Deserialize)] struct SearXngResponse { results: Vec<SearXngResult>, } #[async_trait] impl SearchEngine for SearXng { fn id(&self) -> &str { "searxng" } fn name(&self) -> &str { "SearXNG" } fn url_template(&self) -> &str { &self.url_tpl } fn selector(&self) -> &str { "" } async fn fetch_results( &self, query: &str, client: &reqwest::Client, ) -> Result<Vec<ResultEntry>, EngineError> { let url = self.search_url(query); let body = client .get(&url) .header("Accept", "application/json") .send() .await .map_err(|e| EngineError(format!("searxng fetch failed: {e}")))? .text() .await .map_err(|e| EngineError(format!("searxng read body failed: {e}")))?; let resp: SearXngResponse = serde_json::from_str(&body) .map_err(|e| EngineError(format!("searxng JSON parse failed: {e}")))?; let results: Vec<ResultEntry> = resp .results .into_iter() .filter_map(|r| { let title = r.title.unwrap_or_default(); let url = r.url?; if title.is_empty() { return None; } Some(ResultEntry { title, url, description: r.content, engine: r.engine.unwrap_or_else(|| "searxng".into()), }) }) .collect(); if results.is_empty() { Err(EngineError("no results found".into())) } else { Ok(results) } } } #[cfg(test)] mod tests { use super::*; use crate::search_engines::SearchEngine; #[test] fn test_id() { let e = SearXng { instance: "https://example.com".into(), url_tpl: "https://example.com/search?format=json&q={}".into(), }; assert_eq!(e.id(), "searxng"); } #[test] fn test_name() { let e = SearXng { instance: "https://example.com".into(), url_tpl: "https://example.com/search?format=json&q={}".into(), }; assert_eq!(e.name(), "SearXNG"); } #[test] fn test_selector() { let e = SearXng { instance: "https://example.com".into(), url_tpl: "https://example.com/search?format=json&q={}".into(), }; assert_eq!(e.selector(), ""); } #[test] fn test_url_template_returns_from_struct() { let e = SearXng { instance: "https://my-instance.net".into(), url_tpl: "https://my-instance.net/search?format=json&q={}".into(), }; assert_eq!( e.url_template(), "https://my-instance.net/search?format=json&q={}" ); } #[test] fn test_from_config_valid() { let mut config = toml::Table::new(); config.insert("instance".into(), toml::Value::String("https://search.example.com".into())); let result = SearXng::from_config(&config); assert!(result.is_some()); let engine = result.unwrap(); assert_eq!(engine.id(), "searxng"); assert_eq!( engine.url_template(), "https://search.example.com/search?format=json&q={}" ); } #[test] fn test_from_config_trailing_slash_stripped() { let mut config = toml::Table::new(); config.insert("instance".into(), toml::Value::String("https://search.example.com/".into())); let result = SearXng::from_config(&config); assert!(result.is_some()); let engine = result.unwrap(); assert_eq!( engine.url_template(), "https://search.example.com/search?format=json&q={}" ); } #[test] fn test_from_config_missing_instance() { let config = toml::Table::new(); assert!(SearXng::from_config(&config).is_none()); } #[test] fn test_from_config_non_string_instance() { let mut config = toml::Table::new(); config.insert("instance".into(), toml::Value::Integer(42)); assert!(SearXng::from_config(&config).is_none()); } #[test] fn test_search_url_uses_template() { let e = SearXng { instance: "https://example.com".into(), url_tpl: "https://example.com/search?format=json&q={}".into(), }; assert_eq!( e.search_url("tokio"), "https://example.com/search?format=json&q=tokio" ); } }