knott 0.0.1

Fast Rust package manager helper for Arch Linux repos and the AUR
Documentation
use crate::cli::SearchBy;

use anyhow::{bail, Context, Result};
use reqwest::Client;
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::env;
use std::sync::Mutex;
use std::time::Duration;

const INFO_CHUNK: usize = 150;

#[derive(Debug, Clone, Default, Deserialize)]
pub struct AurPackage {
    #[serde(rename = "ID", default)]
    pub id: u64,
    #[serde(rename = "PackageBaseID", default)]
    pub package_base_id: u64,
    #[serde(rename = "Name", default)]
    pub name: String,
    #[serde(rename = "PackageBase", default)]
    pub package_base: String,
    #[serde(rename = "Version", default)]
    pub version: String,
    #[serde(rename = "Description", default)]
    pub description: Option<String>,
    #[serde(rename = "URL", default)]
    pub url: Option<String>,
    #[serde(rename = "NumVotes", default)]
    pub num_votes: i64,
    #[serde(rename = "Popularity", default)]
    pub popularity: f64,
    #[serde(rename = "Maintainer", default)]
    pub maintainer: Option<String>,
    #[serde(rename = "FirstSubmitted", default)]
    pub first_submitted: u64,
    #[serde(rename = "LastModified", default)]
    pub last_modified: u64,
    #[serde(rename = "OutOfDate", default)]
    pub out_of_date: Option<u64>,
    #[serde(rename = "Depends", default)]
    pub depends: Vec<String>,
    #[serde(rename = "MakeDepends", default)]
    pub make_depends: Vec<String>,
    #[serde(rename = "CheckDepends", default)]
    pub check_depends: Vec<String>,
    #[serde(rename = "OptDepends", default)]
    pub opt_depends: Vec<String>,
    #[serde(rename = "Provides", default)]
    pub provides: Vec<String>,
    #[serde(rename = "Conflicts", default)]
    pub conflicts: Vec<String>,
    #[serde(rename = "License", default)]
    pub license: Vec<String>,
    #[serde(rename = "Keywords", default)]
    pub keywords: Vec<String>,
}

impl AurPackage {
    pub fn base(&self) -> &str {
        if self.package_base.is_empty() {
            &self.name
        } else {
            &self.package_base
        }
    }
}

#[derive(Debug, Deserialize)]
struct RpcResponse<T> {
    #[serde(rename = "type")]
    kind: String,
    #[serde(default)]
    resultcount: usize,
    #[serde(default)]
    results: Vec<T>,
    #[serde(default)]
    error: Option<String>,
}

pub struct AurClient {
    base_url: String,
    client: Client,
    info_cache: Mutex<HashMap<String, AurPackage>>,
    search_cache: Mutex<HashMap<(SearchBy, String), Vec<AurPackage>>>,
}

impl AurClient {
    pub fn from_env() -> Result<Self> {
        let base_url = env::var("KNOTT_AUR_URL")
            .unwrap_or_else(|_| "https://aur.archlinux.org".to_string())
            .trim_end_matches('/')
            .to_string();

        let client = Client::builder()
            .user_agent(concat!("knott/", env!("CARGO_PKG_VERSION")))
            .gzip(true)
            .pool_idle_timeout(Duration::from_secs(90))
            .timeout(Duration::from_secs(25))
            .build()?;

        Ok(Self {
            base_url,
            client,
            info_cache: Mutex::new(HashMap::new()),
            search_cache: Mutex::new(HashMap::new()),
        })
    }

    pub fn web_url(&self, pkg: &str) -> String {
        format!("{}/packages/{}", self.base_url, pkg)
    }

    pub fn git_url(&self, base: &str) -> String {
        format!("{}/{}.git", self.base_url, base)
    }

    pub async fn raw_pkgbuild(&self, base: &str) -> Result<String> {
        let url = format!("{}/cgit/aur.git/plain/PKGBUILD", self.base_url);
        let response = self
            .client
            .get(&url)
            .query(&[("h", base)])
            .send()
            .await
            .with_context(|| format!("fetch {url}"))?;

        if !response.status().is_success() {
            bail!("fetch PKGBUILD for {base}: {}", response.status());
        }

        response.text().await.context("read PKGBUILD response")
    }

    pub async fn info(&self, names: &[String]) -> Result<Vec<AurPackage>> {
        let mut missing = Vec::new();
        let mut seen = HashSet::new();

        {
            let cache = self.info_cache.lock().expect("AUR info cache poisoned");
            for name in names {
                if !cache.contains_key(name) && seen.insert(name.clone()) {
                    missing.push(name.clone());
                }
            }
        }

        for chunk in missing.chunks(INFO_CHUNK) {
            let fetched = self.fetch_info(chunk).await?;
            let mut cache = self.info_cache.lock().expect("AUR info cache poisoned");
            for pkg in fetched {
                cache.insert(pkg.name.clone(), pkg);
            }
        }

        let cache = self.info_cache.lock().expect("AUR info cache poisoned");
        Ok(names
            .iter()
            .filter_map(|name| cache.get(name).cloned())
            .collect())
    }

    pub async fn info_map(&self, names: &[String]) -> Result<HashMap<String, AurPackage>> {
        Ok(self
            .info(names)
            .await?
            .into_iter()
            .map(|pkg| (pkg.name.clone(), pkg))
            .collect())
    }

    pub async fn info_one(&self, name: &str) -> Result<Option<AurPackage>> {
        let result = self.info(&[name.to_string()]).await?;
        Ok(result.into_iter().next())
    }

    pub async fn search_narrowed(&self, terms: &[String], by: SearchBy) -> Result<Vec<AurPackage>> {
        if terms.is_empty() {
            return Ok(Vec::new());
        }

        let mut ordered = terms
            .iter()
            .map(|term| term.to_lowercase())
            .collect::<Vec<_>>();
        ordered.sort_by_key(|term| term.len());

        let first = ordered[0].clone();
        let mut results = self.search_by_term(&first, by).await?;
        let rest = &ordered[1..];

        if !rest.is_empty() {
            results.retain(|pkg| rest.iter().all(|term| package_matches(pkg, term, by)));
        }

        let mut seen = HashSet::new();
        results.retain(|pkg| seen.insert(pkg.name.clone()));
        Ok(results)
    }

    pub async fn search_by_term(&self, term: &str, by: SearchBy) -> Result<Vec<AurPackage>> {
        let key = (by, term.to_lowercase());
        if let Some(cached) = self
            .search_cache
            .lock()
            .expect("AUR search cache poisoned")
            .get(&key)
            .cloned()
        {
            return Ok(cached);
        }

        let url = format!("{}/rpc/", self.base_url);
        let response = self
            .client
            .get(&url)
            .query(&[
                ("v", "5"),
                ("type", "search"),
                ("by", by.as_rpc()),
                ("arg", term),
            ])
            .send()
            .await
            .with_context(|| format!("AUR search for {term}"))?;

        if !response.status().is_success() {
            bail!("AUR search for {term}: {}", response.status());
        }

        let rpc = response
            .json::<RpcResponse<AurPackage>>()
            .await
            .context("decode AUR search response")?;

        if rpc.kind == "error" {
            bail!(rpc.error.unwrap_or_else(|| "AUR search failed".to_string()));
        }

        let results = rpc.results;
        self.search_cache
            .lock()
            .expect("AUR search cache poisoned")
            .insert(key, results.clone());
        Ok(results)
    }

    async fn fetch_info(&self, names: &[String]) -> Result<Vec<AurPackage>> {
        if names.is_empty() {
            return Ok(Vec::new());
        }

        let url = format!("{}/rpc/", self.base_url);
        let mut request = self.client.get(&url).query(&[("v", "5"), ("type", "info")]);

        for name in names {
            request = request.query(&[("arg[]", name)]);
        }

        let response = request
            .send()
            .await
            .with_context(|| format!("AUR info for {} package(s)", names.len()))?;

        if !response.status().is_success() {
            bail!("AUR info request: {}", response.status());
        }

        let rpc = response
            .json::<RpcResponse<AurPackage>>()
            .await
            .context("decode AUR info response")?;

        if rpc.kind == "error" {
            bail!(rpc.error.unwrap_or_else(|| "AUR info failed".to_string()));
        }

        debug_assert_eq!(rpc.resultcount, rpc.results.len());
        Ok(rpc.results)
    }
}

fn package_matches(pkg: &AurPackage, needle: &str, by: SearchBy) -> bool {
    let name = pkg.name.to_lowercase();
    let desc = pkg
        .description
        .as_deref()
        .unwrap_or_default()
        .to_lowercase();

    match by {
        SearchBy::Name => name.contains(needle),
        SearchBy::NameDesc => name.contains(needle) || desc.contains(needle),
        SearchBy::Maintainer => pkg
            .maintainer
            .as_deref()
            .unwrap_or_default()
            .to_lowercase()
            .contains(needle),
        SearchBy::Depends => contains_any(&pkg.depends, needle),
        SearchBy::MakeDepends => contains_any(&pkg.make_depends, needle),
        SearchBy::OptDepends => contains_any(&pkg.opt_depends, needle),
        SearchBy::CheckDepends => contains_any(&pkg.check_depends, needle),
        SearchBy::Provides => contains_any(&pkg.provides, needle),
        SearchBy::Keywords => contains_any(&pkg.keywords, needle),
    }
}

fn contains_any(values: &[String], needle: &str) -> bool {
    values
        .iter()
        .any(|value| value.to_lowercase().contains(needle))
}

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

    #[test]
    fn decodes_rpc_package() {
        let data = r#"{
            "type": "multiinfo",
            "resultcount": 1,
            "results": [{
                "ID": 1,
                "Name": "pkg",
                "PackageBase": "pkgbase",
                "Version": "1.0-1",
                "Description": "test package",
                "NumVotes": 7,
                "Popularity": 1.5,
                "Depends": ["glibc"],
                "Provides": ["virtual=1"]
            }]
        }"#;

        let decoded: RpcResponse<AurPackage> = serde_json::from_str(data).unwrap();
        assert_eq!(decoded.resultcount, 1);
        assert_eq!(decoded.results[0].base(), "pkgbase");
        assert_eq!(decoded.results[0].depends, ["glibc"]);
    }
}