use crate::cli::SearchBy;
use crate::output::Progress;
use anyhow::{bail, Context, Result};
use reqwest::Client;
use serde::Deserialize;
use std::collections::{HashMap, HashSet, VecDeque};
use std::env;
use std::hash::Hash;
use std::sync::Mutex;
use std::time::Duration;
pub(crate) const INFO_CHUNK: usize = 150;
pub(crate) const INFO_CACHE_LIMIT: usize = 4096;
pub(crate) const SEARCH_CACHE_LIMIT: usize = 128;
pub(crate) const SEARCH_CACHE_RESULT_LIMIT: usize = 2048;
pub(crate) const MAX_PKG_BUILD_BYTES: usize = 1024 * 1024;
#[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<BoundedCache<String, AurPackage>>,
search_cache: Mutex<BoundedCache<(SearchBy, String), Vec<AurPackage>>>,
progress_enabled: bool,
}
struct BoundedCache<K, V> {
values: HashMap<K, V>,
order: VecDeque<K>,
capacity: usize,
}
impl<K, V> BoundedCache<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
fn new(capacity: usize) -> Self {
Self {
values: HashMap::with_capacity(capacity.min(256)),
order: VecDeque::with_capacity(capacity.min(256)),
capacity,
}
}
fn contains_key(&self, key: &K) -> bool {
self.values.contains_key(key)
}
fn get_cloned(&mut self, key: &K) -> Option<V> {
let value = self.values.get(key).cloned()?;
self.touch(key);
Some(value)
}
fn insert(&mut self, key: K, value: V) {
if self.capacity == 0 {
return;
}
if self.values.contains_key(&key) {
self.touch(&key);
self.values.insert(key, value);
return;
}
self.order.push_back(key.clone());
self.values.insert(key, value);
while self.values.len() > self.capacity {
if let Some(oldest) = self.order.pop_front() {
self.values.remove(&oldest);
}
}
}
fn touch(&mut self, key: &K) {
self.order.retain(|existing| existing != key);
self.order.push_back(key.clone());
}
}
impl AurClient {
pub fn from_env_with_progress(progress_enabled: bool) -> 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(BoundedCache::new(INFO_CACHE_LIMIT)),
search_cache: Mutex::new(BoundedCache::new(SEARCH_CACHE_LIMIT)),
progress_enabled,
})
}
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 mut 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());
}
if response
.content_length()
.is_some_and(|len| len > MAX_PKG_BUILD_BYTES as u64)
{
bail!("PKGBUILD for {base} exceeded {MAX_PKG_BUILD_BYTES} bytes");
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.context("read PKGBUILD response")? {
if bytes.len().saturating_add(chunk.len()) > MAX_PKG_BUILD_BYTES {
bail!("PKGBUILD for {base} exceeded {MAX_PKG_BUILD_BYTES} bytes");
}
bytes.extend_from_slice(&chunk);
}
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
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());
}
}
}
let mut progress = Progress::new("AUR info", missing.len(), self.progress_enabled);
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);
}
progress.advance(chunk.len());
}
progress.finish();
let mut cache = self.info_cache.lock().expect("AUR info cache poisoned");
Ok(names
.iter()
.filter_map(|name| cache.get_cloned(name))
.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 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_cloned(&key)
{
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;
if results.len() <= SEARCH_CACHE_RESULT_LIMIT {
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"]);
}
#[test]
fn bounded_cache_evicts_least_recently_used_entries() {
let mut cache = BoundedCache::new(2);
cache.insert("a".to_string(), 1);
cache.insert("b".to_string(), 2);
assert_eq!(cache.get_cloned(&"a".to_string()), Some(1));
cache.insert("c".to_string(), 3);
assert_eq!(cache.get_cloned(&"a".to_string()), Some(1));
assert_eq!(cache.get_cloned(&"b".to_string()), None);
assert_eq!(cache.get_cloned(&"c".to_string()), Some(3));
}
}