darash 0.6.0

All-in-one research crate: async search, page fetch, and HTML extraction with no API keys
Documentation
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use url::{form_urlencoded, Url};

use crate::{Citation, ProviderStatus, SearchFilters, SearchResponse, SearchResult};

#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WebsurfxSearchResponse {
    #[serde(default)]
    pub results: Vec<WebsurfxSearchResult>,
    #[serde(default)]
    pub engine_errors_info: Vec<WebsurfxEngineError>,
    #[serde(default)]
    pub disallowed: bool,
    #[serde(default)]
    pub filtered: bool,
    #[serde(default)]
    pub safe_search_level: u8,
    #[serde(default)]
    pub no_engines_selected: bool,
}

pub type WebsurfxResponse = WebsurfxSearchResponse;

#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WebsurfxSearchResult {
    #[serde(default)]
    pub title: String,
    #[serde(default)]
    pub url: String,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub engine: Vec<String>,
    #[serde(default)]
    pub relevance_score: f32,
}

pub type WebsurfxResult = WebsurfxSearchResult;

#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct WebsurfxEngineError {
    #[serde(default)]
    pub error: String,
    #[serde(default)]
    pub engine: String,
    #[serde(default)]
    pub severity_color: String,
}

pub type WebsurfxError = WebsurfxEngineError;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebsurfxMetadata {
    pub engine_errors_info: Vec<WebsurfxEngineError>,
    pub disallowed: bool,
    pub filtered: bool,
    pub safe_search_level: u8,
    pub no_engines_selected: bool,
}

#[derive(Clone, Debug, PartialEq)]
pub struct WebsurfxMappedResponse {
    pub response: SearchResponse,
    pub metadata: WebsurfxMetadata,
}

impl WebsurfxSearchResponse {
    pub fn into_search_response(self, query: impl Into<String>) -> WebsurfxMappedResponse {
        let results = self
            .results
            .into_iter()
            .map(WebsurfxSearchResult::into_search_result)
            .collect::<Vec<_>>();

        let sources = Self::extract_citations(&results);
        let provider_status = Self::compute_provider_status(&results, &self.engine_errors_info);

        let safe_search_level = self.safe_search_level;
        let metadata = WebsurfxMetadata {
            engine_errors_info: self.engine_errors_info,
            disallowed: self.disallowed,
            filtered: self.filtered,
            safe_search_level,
            no_engines_selected: self.no_engines_selected,
        };
        let response = SearchResponse {
            query: query.into(),
            number_of_results: results.len() as u64,
            results,
            answers: Vec::new(),
            answer: None,
            sources,
            corrections: Vec::new(),
            suggestions: Vec::new(),
            provider_status,
            filters: SearchFilters {
                safe_search_level,
                time_range: None,
                filtered: self.filtered,
                disallowed: self.disallowed,
                no_providers_selected: self.no_engines_selected,
            },
        };
        WebsurfxMappedResponse { response, metadata }
    }

    fn extract_citations(results: &[SearchResult]) -> Vec<Citation> {
        results
            .iter()
            .map(SearchResult::citation)
            .collect::<Vec<Citation>>()
    }

    fn compute_provider_status(
        results: &[SearchResult],
        engine_errors: &[WebsurfxEngineError],
    ) -> Vec<ProviderStatus> {
        let mut provider_counts = BTreeMap::new();
        for result in results {
            for engine in &result.engines {
                *provider_counts.entry(engine.as_str()).or_insert(0) += 1;
            }
        }
        provider_counts
            .into_iter()
            .map(|(provider, count)| ProviderStatus::success(provider, count))
            .chain(
                engine_errors
                    .iter()
                    .map(|error| ProviderStatus::failed(error.engine.clone(), error.error.clone())),
            )
            .collect()
    }
}

impl WebsurfxSearchResult {
    pub fn into_search_result(self) -> SearchResult {
        SearchResult {
            title: self.title,
            url: self.url,
            content: self.description,
            engine: self.engine.first().cloned(),
            engines: self.engine,
            category: None,
            published_date: None,
            score: Some(self.relevance_score as f64),
        }
    }
}

impl From<WebsurfxSearchResult> for SearchResult {
    fn from(result: WebsurfxSearchResult) -> Self {
        result.into_search_result()
    }
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct WebsurfxQuery {
    query: String,
    page: Option<u32>,
    safe_search: Option<u8>,
}

impl WebsurfxQuery {
    pub fn new(query: impl Into<String>) -> Self {
        Self {
            query: query.into(),
            page: None,
            safe_search: None,
        }
    }

    pub fn query(&self) -> &str {
        &self.query
    }

    pub fn page(&self) -> Option<u32> {
        self.page
    }

    pub fn safe_search(&self) -> Option<u8> {
        self.safe_search
    }

    pub fn with_page(mut self, page: u32) -> Self {
        self.page = Some(page);
        self
    }

    pub fn with_safe_search(mut self, level: u8) -> Self {
        self.safe_search = Some(level);
        self
    }

    pub fn to_query_string(&self) -> String {
        let mut serializer = form_urlencoded::Serializer::new(String::new());
        serializer.append_pair("q", &self.query);
        if let Some(page) = self.page {
            serializer.append_pair("page", &page.to_string());
        }
        if let Some(level) = self.safe_search {
            serializer.append_pair("safesearch", &level.to_string());
        }
        serializer.append_pair("json", "true");
        serializer.finish()
    }
}

pub fn build_search_url(
    endpoint: impl AsRef<str>,
    query: &WebsurfxQuery,
) -> Result<Url, url::ParseError> {
    let mut url = Url::parse(endpoint.as_ref())?;
    let base_path = url.path().trim_end_matches('/');
    let path = if base_path.is_empty() {
        "/search".to_owned()
    } else if base_path == "/search" || base_path.ends_with("/search") {
        base_path.to_owned()
    } else {
        format!("{base_path}/search")
    };
    url.set_path(&path);
    url.set_query(Some(&query.to_query_string()));
    url.set_fragment(None);
    Ok(url)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decodes_and_maps_websurfx_results_and_metadata() {
        let body = r#"{
            "results": [{
                "title": "Rust async",
                "url": "https://example.com/rust#intro",
                "description": "A guide to async Rust.",
                "engine": ["DuckDuckGo", "Wikipedia"],
                "relevanceScore": 0.75
            }],
            "engineErrorsInfo": [{
                "error": "RequestError",
                "engine": "Bing",
                "severity_color": "green"
            }],
            "disallowed": false,
            "filtered": true,
            "safeSearchLevel": 3,
            "noEnginesSelected": false
        }"#;
        let parsed: WebsurfxSearchResponse = serde_json::from_str(body).expect("valid response");
        assert_eq!(parsed.results[0].engine, ["DuckDuckGo", "Wikipedia"]);
        assert_eq!(parsed.results[0].relevance_score, 0.75);

        let mapped = parsed.into_search_response("rust async");
        assert_eq!(mapped.response.query, "rust async");
        assert_eq!(mapped.response.results.len(), 1);
        assert_eq!(mapped.response.results[0].content, "A guide to async Rust.");
        assert_eq!(
            mapped.response.results[0].engines,
            ["DuckDuckGo", "Wikipedia"]
        );
        assert_eq!(
            mapped.response.results[0].engine.as_deref(),
            Some("DuckDuckGo")
        );
        assert_eq!(mapped.response.results[0].score, Some(0.75));
        assert_eq!(mapped.response.provider_status.len(), 3);
        assert_eq!(mapped.response.filters.safe_search_level, 3);
        assert_eq!(mapped.metadata.engine_errors_info[0].engine, "Bing");
        assert!(mapped.metadata.filtered);
        assert_eq!(mapped.metadata.safe_search_level, 3);
    }

    #[test]
    fn gets_websurfx_query_properties() {
        let query = WebsurfxQuery::new("test query");
        assert_eq!(query.query(), "test query");
        assert_eq!(query.page(), None);
        assert_eq!(query.safe_search(), None);

        let query_with_options = WebsurfxQuery::new("test query")
            .with_page(1)
            .with_safe_search(2);
        assert_eq!(query_with_options.query(), "test query");
        assert_eq!(query_with_options.page(), Some(1));
        assert_eq!(query_with_options.safe_search(), Some(2));
    }

    #[test]
    fn sets_safe_search_level_on_query() {
        let query = WebsurfxQuery::new("test query").with_safe_search(4);
        assert_eq!(query.safe_search(), Some(4));
    }

    #[test]
    fn sets_page_on_query() {
        let query = WebsurfxQuery::new("test query").with_page(2);
        assert_eq!(query.page(), Some(2));
    }

    #[test]
    fn builds_websurfx_search_url() {
        let query = WebsurfxQuery::new("rust async")
            .with_page(2)
            .with_safe_search(3);
        assert_eq!(
            query.to_query_string(),
            "q=rust+async&page=2&safesearch=3&json=true"
        );
        let url = build_search_url("http://localhost:8080/", &query).expect("valid endpoint");
        assert_eq!(
            url.as_str(),
            "http://localhost:8080/search?q=rust+async&page=2&safesearch=3&json=true"
        );
    }
}