search_hub

at 27361b0 Raw

use async_trait::async_trait;
use serde::Deserialize;

use crate::search_engines::{EngineError, ResultEntry, SearchEngine};

pub const DEFAULT_API_URL: &str = "https://crates.io/api/v1/crates?q={}&per_page=10";

pub struct CratesIo {
    pub timeout_secs: Option<f32>,
    pub api_url: String,
}

#[derive(Deserialize)]
struct CrateResult {
    name: String,
    description: Option<String>,
    homepage: Option<String>,
    documentation: Option<String>,
    repository: Option<String>,
}

#[derive(Deserialize)]
struct ApiResponse {
    crates: Vec<CrateResult>,
}

#[async_trait]
impl SearchEngine for CratesIo {
    fn id(&self) -> &str {
        "crates.io"
    }

    fn name(&self) -> &str {
        "crates.io"
    }

    fn url_template(&self) -> &str {
        &self.api_url
    }

    fn selector(&self) -> &str {
        ""
    }

    fn timeout(&self) -> std::time::Duration {
        self.timeout_secs
            .map(|s| std::time::Duration::from_secs_f32(s))
            .unwrap_or_else(|| std::time::Duration::from_secs(5))
    }

    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!("fetch failed: {e}")))?
            .text()
            .await
            .map_err(|e| EngineError(format!("read body failed: {e}")))?;

        let resp: ApiResponse = serde_json::from_str(&body)
            .map_err(|e| EngineError(format!("JSON parse failed: {e}")))?;

        let results: Vec<ResultEntry> = resp
            .crates
            .into_iter()
            .map(|c| {
                let title = format!("{}{}",
                    c.name,
                    c.description.as_ref().map(|d| format!(" - {d}")).unwrap_or_default(),
                );
                let url = c
                    .homepage
                    .or(c.documentation)
                    .or(c.repository)
                    .unwrap_or_else(|| format!("https://crates.io/crates/{}", c.name));
                ResultEntry {
                    title,
                    url,
                    description: c.description,
                    engine: "crates.io".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 = CratesIo { timeout_secs: None, api_url: DEFAULT_API_URL.into() };
        assert_eq!(e.id(), "crates.io");
    }

    #[test]
    fn test_name() {
        let e = CratesIo { timeout_secs: None, api_url: DEFAULT_API_URL.into() };
        assert_eq!(e.name(), "crates.io");
    }

    #[test]
    fn test_url_template() {
        let e = CratesIo { timeout_secs: None, api_url: DEFAULT_API_URL.into() };
        assert_eq!(
            e.url_template(),
            "https://crates.io/api/v1/crates?q={}&per_page=10"
        );
    }

    #[test]
    fn test_selector() {
        let e = CratesIo { timeout_secs: None, api_url: DEFAULT_API_URL.into() };
        assert_eq!(e.selector(), "");
    }

    #[test]
    fn test_engine_construct() {
        let e = CratesIo { timeout_secs: None, api_url: DEFAULT_API_URL.into() };
        assert_eq!(e.id(), "crates.io");
    }
}