search_hub

at 4551d56 Raw

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"
        );
    }
}