use crate::aur::utils::{arrs, s, u64_of};
use crate::aur::validation::validate_package_names;
use crate::cache::cache_key_info;
use crate::client::{
ArchClient, extract_retry_after, is_archlinux_url, rate_limit_archlinux,
reset_archlinux_backoff, retry_with_policy,
};
use crate::error::{ArchToolkitError, Result};
use crate::types::AurPackageDetails;
use reqwest::Client;
use serde_json::Value;
use tracing::{debug, warn};
pub async fn info(client: &ArchClient, names: &[&str]) -> Result<Vec<AurPackageDetails>> {
let validation_config = client.validation_config();
validate_package_names(names, Some(validation_config))?;
if names.is_empty() {
return Ok(Vec::new());
}
if let Some(cache_config) = client.cache_config()
&& cache_config.enable_info
&& let Some(cache) = client.cache()
{
let cache_key = cache_key_info(names);
if let Some(cached) = cache.get::<Vec<AurPackageDetails>>(&cache_key) {
debug!(names = ?names, "cache hit for info");
return Ok(cached);
}
}
let mut url = String::from("https://aur.archlinux.org/rpc/v5/info?");
for (i, name) in names.iter().enumerate() {
if i > 0 {
url.push('&');
}
url.push_str("arg[]=");
url.push_str(name);
}
debug!(names = ?names, url = %url, "fetching AUR package info");
let _permit = if is_archlinux_url(&url) {
rate_limit_archlinux().await
} else {
return Err(ArchToolkitError::InvalidInput(format!(
"Unexpected URL domain: {url}"
)));
};
let retry_policy = client.retry_policy();
let http_client = client.http_client();
let result = if retry_policy.enabled && retry_policy.retry_info {
retry_with_policy(retry_policy, "info", &names.join(", "), || async {
perform_info_request(http_client, &url, names).await
})
.await
} else {
perform_info_request(http_client, &url, names).await
}?;
if let Some(cache_config) = client.cache_config()
&& cache_config.enable_info
&& let Some(cache) = client.cache()
{
let cache_key = cache_key_info(names);
let _ = cache.set(&cache_key, &result, cache_config.info_ttl);
}
Ok(result)
}
async fn perform_info_request(
client: &Client,
url: &str,
package_names: &[&str],
) -> Result<Vec<AurPackageDetails>> {
let response = match client.get(url).send().await {
Ok(resp) => {
reset_archlinux_backoff();
resp
}
Err(e) => {
warn!(error = %e, packages = ?package_names, "AUR info request failed");
return Err(ArchToolkitError::info_failed(package_names, e));
}
};
let _retry_after = extract_retry_after(&response);
let response = match response.error_for_status() {
Ok(resp) => resp,
Err(e) => {
warn!(error = %e, packages = ?package_names, "AUR info returned non-success status");
return Err(ArchToolkitError::info_failed(package_names, e));
}
};
let json: Value = match response.json().await {
Ok(json) => json,
Err(e) => {
warn!(error = %e, packages = ?package_names, "failed to parse AUR info JSON");
return Err(ArchToolkitError::info_failed(package_names, e));
}
};
let mut packages = Vec::new();
if let Some(results) = json.get("results").and_then(Value::as_array) {
for pkg in results {
let name = s(pkg, "Name");
if name.is_empty() {
continue;
}
let version = s(pkg, "Version");
let description = s(pkg, "Description");
let url = s(pkg, "URL");
let licenses = arrs(pkg, &["License", "Licenses"]);
let groups = arrs(pkg, &["Groups", "Group"]);
let provides = arrs(pkg, &["Provides"]);
let depends = arrs(pkg, &["Depends"]);
let make_depends = arrs(pkg, &["MakeDepends"]);
let opt_depends = arrs(pkg, &["OptDepends"]);
let conflicts = arrs(pkg, &["Conflicts"]);
let replaces = arrs(pkg, &["Replaces"]);
let maintainer_str = s(pkg, "Maintainer");
let maintainer = if maintainer_str.is_empty() {
None
} else {
Some(maintainer_str)
};
let first_submitted = pkg
.get("FirstSubmitted")
.and_then(Value::as_i64)
.filter(|&ts| ts > 0);
let last_modified = pkg
.get("LastModified")
.and_then(Value::as_i64)
.filter(|&ts| ts > 0);
let popularity = pkg.get("Popularity").and_then(Value::as_f64);
let num_votes = u64_of(pkg, &["NumVotes", "Votes"]);
let out_of_date = pkg
.get("OutOfDate")
.and_then(Value::as_i64)
.and_then(|ts| u64::try_from(ts).ok())
.filter(|&ts| ts > 0);
let orphaned = maintainer.is_none();
packages.push(AurPackageDetails {
name,
version,
description,
url,
licenses,
groups,
provides,
depends,
make_depends,
opt_depends,
conflicts,
replaces,
maintainer,
first_submitted,
last_modified,
popularity,
num_votes,
out_of_date,
orphaned,
});
}
}
debug!(found = packages.len(), "AUR info fetch completed");
Ok(packages)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ArchToolkitError;
use serde_json::json;
#[test]
fn test_info_error_includes_package_context() {
let packages = &["yay", "paru"];
#[allow(clippy::unwrap_used)]
let cert_result = reqwest::Certificate::from_pem(b"invalid cert");
let mock_error = match cert_result {
Ok(cert) => reqwest::Client::builder()
.add_root_certificate(cert)
.build()
.expect_err("Should fail to build client with invalid cert"),
Err(e) => e,
};
let error = ArchToolkitError::info_failed(packages, mock_error);
let error_msg = format!("{error}");
assert!(
error_msg.contains("yay"),
"Error message should include package names: {error_msg}"
);
assert!(
error_msg.contains("paru"),
"Error message should include all package names: {error_msg}"
);
assert!(
error_msg.contains("AUR info fetch failed"),
"Error message should indicate info operation: {error_msg}"
);
}
#[test]
fn test_info_parses_valid_response() {
let json = json!({
"results": [
{
"Name": "yay",
"Version": "12.3.4-1",
"Description": "AUR helper",
"URL": "https://github.com/Jguer/yay",
"License": ["MIT"],
"Groups": [],
"Provides": [],
"Depends": ["git", "go"],
"MakeDepends": ["git"],
"OptDepends": ["sudo: privilege escalation"],
"Conflicts": [],
"Replaces": [],
"Maintainer": "someuser",
"FirstSubmitted": 1_234_567_890,
"LastModified": 1_234_567_891,
"Popularity": 3.0,
"NumVotes": 100,
"OutOfDate": null
}
]
});
let results = json
.get("results")
.and_then(Value::as_array)
.expect("test JSON should have results array");
let mut packages = Vec::new();
for pkg in results {
let name = s(pkg, "Name");
if name.is_empty() {
continue;
}
let version = s(pkg, "Version");
let description = s(pkg, "Description");
let url = s(pkg, "URL");
let licenses = arrs(pkg, &["License", "Licenses"]);
let groups = arrs(pkg, &["Groups", "Group"]);
let provides = arrs(pkg, &["Provides"]);
let depends = arrs(pkg, &["Depends"]);
let make_depends = arrs(pkg, &["MakeDepends"]);
let opt_depends = arrs(pkg, &["OptDepends"]);
let conflicts = arrs(pkg, &["Conflicts"]);
let replaces = arrs(pkg, &["Replaces"]);
let maintainer_str = s(pkg, "Maintainer");
let maintainer = if maintainer_str.is_empty() {
None
} else {
Some(maintainer_str)
};
let first_submitted = pkg
.get("FirstSubmitted")
.and_then(Value::as_i64)
.filter(|&ts| ts > 0);
let last_modified = pkg
.get("LastModified")
.and_then(Value::as_i64)
.filter(|&ts| ts > 0);
let popularity = pkg.get("Popularity").and_then(Value::as_f64);
let num_votes = u64_of(pkg, &["NumVotes", "Votes"]);
let out_of_date = pkg
.get("OutOfDate")
.and_then(Value::as_i64)
.and_then(|ts| u64::try_from(ts).ok())
.filter(|&ts| ts > 0);
let orphaned = maintainer.is_none();
packages.push(AurPackageDetails {
name,
version,
description,
url,
licenses,
groups,
provides,
depends,
make_depends,
opt_depends,
conflicts,
replaces,
maintainer,
first_submitted,
last_modified,
popularity,
num_votes,
out_of_date,
orphaned,
});
}
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].name, "yay");
assert_eq!(packages[0].version, "12.3.4-1");
assert_eq!(packages[0].depends, vec!["git", "go"]);
assert_eq!(packages[0].opt_depends, vec!["sudo: privilege escalation"]);
assert_eq!(packages[0].num_votes, Some(100));
}
}