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};
const MAX_AUR_INFO_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
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(&crate::aur::utils::percent_encode(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 resource_label = format!("AUR info for packages [{}]", package_names.join(", "));
let text = crate::http::read_bounded_response_text(
response,
MAX_AUR_INFO_RESPONSE_BYTES,
&resource_label,
|error| ArchToolkitError::info_failed(package_names, error),
)
.await?;
let json: Value = serde_json::from_str(&text).map_err(|error| {
ArchToolkitError::Parse(format!("failed to parse {resource_label} JSON: {error}"))
})?;
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;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn test_info_error_includes_package_context() {
let packages = &["yay", "paru"];
let mock_error = crate::aur::utils::mock_reqwest_error();
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}"
);
}
#[tokio::test]
async fn oversized_aur_info_response_is_rejected() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/info"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![
b'x';
MAX_AUR_INFO_RESPONSE_BYTES
+ 1
]))
.mount(&server)
.await;
let error =
perform_info_request(&Client::new(), &format!("{}/info", server.uri()), &["yay"])
.await
.expect_err("oversized AUR info response must fail");
let message = error.to_string();
assert!(matches!(
error,
ArchToolkitError::InputTooLong {
max_length: MAX_AUR_INFO_RESPONSE_BYTES,
..
}
));
assert!(message.contains("info"));
assert!(message.contains("yay"));
}
#[tokio::test]
async fn empty_and_malformed_aur_info_bodies_are_contextual() {
for (path_value, body) in [("/empty", ""), ("/malformed", "{")] {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(path_value))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(&server)
.await;
let error = perform_info_request(
&Client::new(),
&format!("{}{path_value}", server.uri()),
&["yay"],
)
.await
.expect_err("invalid AUR info JSON must fail");
let message = error.to_string();
assert!(matches!(error, ArchToolkitError::Parse(_)));
assert!(message.contains("AUR info"));
assert!(message.contains("yay"));
}
}
#[tokio::test]
async fn non_success_aur_info_status_is_contextual() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/info"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let error =
perform_info_request(&Client::new(), &format!("{}/info", server.uri()), &["yay"])
.await
.expect_err("non-success AUR info status must fail");
let message = error.to_string();
assert!(matches!(error, ArchToolkitError::InfoFailed { .. }));
assert!(message.contains("yay"));
assert!(message.contains("503"));
}
#[tokio::test]
async fn normal_aur_info_fixture_is_parsed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"results": [{
"Name": "yay",
"Version": "12.3.4-1",
"Description": "AUR helper",
"Depends": ["git"]
}]
})))
.mount(&server)
.await;
let packages =
perform_info_request(&Client::new(), &format!("{}/info", server.uri()), &["yay"])
.await
.expect("normal AUR info fixture");
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].name, "yay");
assert_eq!(packages[0].depends, ["git"]);
}
#[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));
}
}