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