darash 0.2.0

Provider-neutral async client with embedded Websurfx and SearxNG support
Documentation
use crate::{Error, SearchResponse, SearchResult};
use serde::Deserialize;
use std::{
    fs,
    path::PathBuf,
    sync::{mpsc, OnceLock},
    thread,
    time::{SystemTime, UNIX_EPOCH},
};
use tokio::{net::TcpListener, runtime::Builder};
use url::Url;

static ENDPOINT: OnceLock<Result<Url, String>> = OnceLock::new();

pub(crate) fn endpoint() -> Result<Url, Error> {
    ENDPOINT
        .get_or_init(|| start().map_err(|error| error.to_string()))
        .clone()
        .map_err(Error::Embedded)
}

pub(crate) fn parse_response(query: &str, body: &str) -> Result<SearchResponse, serde_json::Error> {
    let response: WebsurfxResponse = serde_json::from_str(body)?;
    let results = response
        .results
        .into_iter()
        .map(|result| SearchResult {
            title: result.title,
            url: result.url,
            content: result.description,
            engine: result.engine.first().cloned(),
            engines: result.engine,
            category: None,
            published_date: None,
            score: Some(f64::from(result.relevance_score)),
        })
        .collect::<Vec<_>>();
    let sources = results
        .iter()
        .map(SearchResult::citation)
        .collect::<Vec<_>>();
    Ok(SearchResponse {
        query: query.to_owned(),
        number_of_results: results.len() as u64,
        results,
        answers: Vec::new(),
        answer: None,
        sources,
        corrections: Vec::new(),
        suggestions: Vec::new(),
    })
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebsurfxResponse {
    #[serde(default)]
    results: Vec<WebsurfxResult>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebsurfxResult {
    title: String,
    url: String,
    description: String,
    #[serde(default)]
    engine: Vec<String>,
    relevance_score: f32,
}

fn start() -> Result<Url, Error> {
    let root = runtime_root()?;
    fs::create_dir_all(root.join("public")).map_err(|error| Error::Embedded(error.to_string()))?;
    fs::create_dir_all(root.join("websurfx"))
        .map_err(|error| Error::Embedded(error.to_string()))?;
    fs::write(root.join("websurfx/config.lua"), config())
        .map_err(|error| Error::Embedded(error.to_string()))?;

    let (sender, receiver) = mpsc::sync_channel(1);
    thread::Builder::new()
        .name("darash-websurfx".to_owned())
        .spawn(move || {
            let result = run_server(root, sender.clone());
            if let Err(error) = result {
                let _ = sender.send(Err(error));
            }
        })
        .map_err(|error| Error::Embedded(error.to_string()))?;

    receiver
        .recv()
        .map_err(|error| Error::Embedded(error.to_string()))?
        .map_err(Error::Embedded)
}

fn run_server(root: PathBuf, sender: mpsc::SyncSender<Result<Url, String>>) -> Result<(), String> {
    let runtime = Builder::new_multi_thread()
        .enable_all()
        .build()
        .map_err(|error| error.to_string())?;
    let data_root = root.to_string_lossy().into_owned();
    let (server, address) = runtime.block_on(async {
        websurfx::set_data_root(data_root).map_err(|error| error.to_string())?;
        let config = websurfx::parser::Config::parse(true)
            .await
            .map_err(|error| error.to_string())?;
        let config = Box::leak(Box::new(config));
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .map_err(|error| error.to_string())?;
        let address = listener.local_addr().map_err(|error| error.to_string())?;
        let server = websurfx::run(listener, config)
            .await
            .map_err(|error| error.to_string())?;
        Ok::<_, String>((server, address))
    })?;
    let endpoint = Url::parse(&format!("http://{address}")).map_err(|error| error.to_string())?;
    sender
        .send(Ok(endpoint))
        .map_err(|error| error.to_string())?;
    runtime.block_on(server).map_err(|error| error.to_string())
}

fn runtime_root() -> Result<PathBuf, Error> {
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|error| Error::Embedded(error.to_string()))?
        .as_nanos();
    Ok(std::env::temp_dir().join(format!(
        "darash-websurfx-{}-{timestamp}",
        std::process::id()
    )))
}

fn config() -> &'static str {
    "logging = false
debug = false
threads = 1
port = 0
binding_ip = \"127.0.0.1\"
production_use = false
request_timeout = 15
tcp_connection_keep_alive = 30
pool_idle_connection_timeout = 30
rate_limiter = { number_of_requests = 20, time_limit = 3 }
adaptive_window = true
operating_system_tls_certificates = true
number_of_https_connections = 4
client_connection_keep_alive = 30
safe_search = 0
colorscheme = \"catppuccin-mocha\"
theme = \"simple\"
animation = \"simple-frosted-glow\"
http_cache_expiry_time = 60
upstream_search_engines = { DuckDuckGo = true, Wikipedia = true }
proxy = nil
"
}

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

    #[test]
    fn maps_websurfx_results_to_citations() {
        let response = parse_response(
            "rust",
            r#"{"results":[{"title":"Rust","url":"https://rust-lang.org","description":"A language","engine":["DuckDuckGo"],"relevanceScore":0.75}]}"#,
        )
        .expect("valid Websurfx response");

        assert_eq!(response.query, "rust");
        assert_eq!(response.number_of_results, 1);
        assert_eq!(response.results[0].content, "A language");
        assert_eq!(
            response.citations(),
            [Citation {
                title: "Rust".to_owned(),
                url: "https://rust-lang.org".to_owned(),
                snippet: "A language".to_owned(),
                source: Some("DuckDuckGo".to_owned()),
                published_date: None,
            }]
        );
    }
}