456 lines
13 KiB
Rust
456 lines
13 KiB
Rust
use serde::Deserialize;
|
|
|
|
use crate::error::CubError;
|
|
|
|
const DEFAULT_AUR_BASE_URL: &str = "https://aur.archlinux.org";
|
|
|
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
|
pub struct AurPackage {
|
|
#[serde(rename = "Name")]
|
|
pub name: String,
|
|
#[serde(rename = "Version")]
|
|
pub version: String,
|
|
#[serde(rename = "Description")]
|
|
#[serde(default, deserialize_with = "deserialize_nullable_string")]
|
|
pub description: String,
|
|
#[serde(rename = "URL")]
|
|
#[serde(default, deserialize_with = "deserialize_nullable_string")]
|
|
pub url: String,
|
|
#[serde(rename = "License")]
|
|
#[serde(default, deserialize_with = "deserialize_aur_list")]
|
|
pub license: Vec<String>,
|
|
#[serde(rename = "Depends")]
|
|
#[serde(default, deserialize_with = "deserialize_aur_list")]
|
|
pub depends: Vec<String>,
|
|
#[serde(rename = "MakeDepends")]
|
|
#[serde(default, deserialize_with = "deserialize_aur_list")]
|
|
pub makedepends: Vec<String>,
|
|
#[serde(rename = "OptDepends")]
|
|
#[serde(default, deserialize_with = "deserialize_aur_list")]
|
|
pub optdepends: Vec<String>,
|
|
#[serde(rename = "Provides")]
|
|
#[serde(default, deserialize_with = "deserialize_aur_list")]
|
|
pub provides: Vec<String>,
|
|
#[serde(rename = "Conflicts")]
|
|
#[serde(default, deserialize_with = "deserialize_aur_list")]
|
|
pub conflicts: Vec<String>,
|
|
#[serde(rename = "NumVotes")]
|
|
pub num_votes: u64,
|
|
#[serde(rename = "Popularity")]
|
|
pub popularity: f64,
|
|
#[serde(rename = "LastModified")]
|
|
pub last_modified: i64,
|
|
#[serde(rename = "OutOfDate")]
|
|
#[serde(default, deserialize_with = "deserialize_out_of_date")]
|
|
pub out_of_date: Option<bool>,
|
|
}
|
|
|
|
fn deserialize_nullable_string<'de, D>(deserializer: D) -> Result<String, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
use serde::de::{self, Visitor};
|
|
|
|
struct NullableStringVisitor;
|
|
|
|
impl<'de> Visitor<'de> for NullableStringVisitor {
|
|
type Value = String;
|
|
|
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
formatter.write_str("null or string")
|
|
}
|
|
|
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
|
Ok(v.to_string())
|
|
}
|
|
|
|
fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
|
|
Ok(v)
|
|
}
|
|
|
|
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
|
|
Ok(String::new())
|
|
}
|
|
|
|
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
|
|
Ok(String::new())
|
|
}
|
|
}
|
|
|
|
deserializer.deserialize_any(NullableStringVisitor)
|
|
}
|
|
|
|
fn deserialize_aur_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
use serde::de::{self, SeqAccess, Visitor};
|
|
|
|
struct AurListVisitor;
|
|
|
|
impl<'de> Visitor<'de> for AurListVisitor {
|
|
type Value = Vec<String>;
|
|
|
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
formatter.write_str("null, string, or array of strings")
|
|
}
|
|
|
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
|
if v.is_empty() {
|
|
Ok(Vec::new())
|
|
} else {
|
|
Ok(vec![v.to_string()])
|
|
}
|
|
}
|
|
|
|
fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
|
|
if v.is_empty() {
|
|
Ok(Vec::new())
|
|
} else {
|
|
Ok(vec![v])
|
|
}
|
|
}
|
|
|
|
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
|
|
let mut items = Vec::new();
|
|
while let Some(item) = seq.next_element::<String>()? {
|
|
items.push(item);
|
|
}
|
|
Ok(items)
|
|
}
|
|
}
|
|
|
|
deserializer.deserialize_any(AurListVisitor)
|
|
}
|
|
|
|
fn deserialize_out_of_date<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
use serde::de::{self, Visitor};
|
|
|
|
struct OutOfDateVisitor;
|
|
|
|
impl<'de> Visitor<'de> for OutOfDateVisitor {
|
|
type Value = Option<bool>;
|
|
|
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
formatter.write_str("null, boolean, or integer")
|
|
}
|
|
|
|
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
|
|
Ok(None)
|
|
}
|
|
|
|
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
|
|
Ok(None)
|
|
}
|
|
|
|
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
|
|
Ok(Some(v))
|
|
}
|
|
|
|
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
|
|
Ok(Some(v != 0))
|
|
}
|
|
|
|
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
|
|
Ok(Some(v != 0))
|
|
}
|
|
|
|
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Self::Value, E> {
|
|
Ok(Some(v != 0.0))
|
|
}
|
|
}
|
|
|
|
deserializer.deserialize_any(OutOfDateVisitor)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct AurRpcResponse {
|
|
version: i64,
|
|
#[serde(rename = "type")]
|
|
response_type: String,
|
|
resultcount: i64,
|
|
results: Vec<AurPackage>,
|
|
#[serde(default)]
|
|
error: Option<String>,
|
|
}
|
|
|
|
#[cfg(feature = "full")]
|
|
pub struct AurClient {
|
|
pub base_url: String,
|
|
client: reqwest::blocking::Client,
|
|
}
|
|
|
|
#[cfg(not(feature = "full"))]
|
|
pub struct AurClient {
|
|
pub base_url: String,
|
|
}
|
|
|
|
impl AurClient {
|
|
pub fn new() -> Self {
|
|
#[cfg(feature = "full")]
|
|
{
|
|
Self {
|
|
base_url: DEFAULT_AUR_BASE_URL.to_string(),
|
|
client: reqwest::blocking::Client::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(feature = "full"))]
|
|
{
|
|
Self {
|
|
base_url: DEFAULT_AUR_BASE_URL.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn search(&self, query: &str, by: Option<&str>) -> Result<Vec<AurPackage>, CubError> {
|
|
#[cfg(feature = "full")]
|
|
{
|
|
let trimmed_query = query.trim();
|
|
if trimmed_query.is_empty() {
|
|
return Err(aur_error("search query cannot be empty"));
|
|
}
|
|
|
|
let mut params = vec![("v", "5"), ("type", "search"), ("arg", trimmed_query)];
|
|
if let Some(field) = by.and_then(non_empty_trimmed) {
|
|
params.push(("by", field));
|
|
}
|
|
|
|
let url = self.rpc_url(¶ms)?;
|
|
self.fetch(
|
|
url,
|
|
format!("no results found for search query '{trimmed_query}'"),
|
|
)
|
|
}
|
|
|
|
#[cfg(not(feature = "full"))]
|
|
{
|
|
let _ = (query, by);
|
|
Err(feature_not_enabled_error())
|
|
}
|
|
}
|
|
|
|
pub fn info(&self, pkgs: &[&str]) -> Result<Vec<AurPackage>, CubError> {
|
|
#[cfg(feature = "full")]
|
|
{
|
|
if pkgs.is_empty() {
|
|
return Err(aur_error("info request requires at least one package"));
|
|
}
|
|
|
|
let mut url = self.rpc_url(&[("v", "5"), ("type", "info")])?;
|
|
let mut appended = 0usize;
|
|
{
|
|
let mut pairs = url.query_pairs_mut();
|
|
for pkg in pkgs.iter().copied().filter_map(non_empty_trimmed) {
|
|
pairs.append_pair("arg[]", pkg);
|
|
appended += 1;
|
|
}
|
|
}
|
|
|
|
if appended == 0 {
|
|
return Err(aur_error("info request requires at least one package"));
|
|
}
|
|
|
|
self.fetch(url, "no packages found for info request".to_string())
|
|
}
|
|
|
|
#[cfg(not(feature = "full"))]
|
|
{
|
|
let _ = pkgs;
|
|
Err(feature_not_enabled_error())
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "full")]
|
|
fn rpc_url(&self, params: &[(&str, &str)]) -> Result<reqwest::Url, CubError> {
|
|
let mut url = reqwest::Url::parse(&self.base_url)
|
|
.map_err(|err| aur_error(format!("invalid base URL '{}': {err}", self.base_url)))?;
|
|
url.set_path("rpc");
|
|
|
|
{
|
|
let mut pairs = url.query_pairs_mut();
|
|
for (key, value) in params {
|
|
pairs.append_pair(key, value);
|
|
}
|
|
}
|
|
|
|
Ok(url)
|
|
}
|
|
|
|
#[cfg(feature = "full")]
|
|
fn fetch(&self, url: reqwest::Url, empty_message: String) -> Result<Vec<AurPackage>, CubError> {
|
|
let response = self
|
|
.client
|
|
.get(url)
|
|
.send()
|
|
.map_err(|err| aur_error(format!("request failed: {err}")))?
|
|
.error_for_status()
|
|
.map_err(|err| aur_error(format!("request failed: {err}")))?;
|
|
|
|
let body = response
|
|
.text()
|
|
.map_err(|err| aur_error(format!("failed to read response body: {err}")))?;
|
|
|
|
parse_rpc_response(&body, &empty_message)
|
|
}
|
|
}
|
|
|
|
fn non_empty_trimmed(value: &str) -> Option<&str> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed)
|
|
}
|
|
}
|
|
|
|
fn aur_error(message: impl Into<String>) -> CubError {
|
|
CubError::Aur(message.into())
|
|
}
|
|
|
|
#[cfg(not(feature = "full"))]
|
|
fn feature_not_enabled_error() -> CubError {
|
|
CubError::Aur("reqwest feature not enabled".into())
|
|
}
|
|
|
|
fn parse_rpc_response(body: &str, empty_message: &str) -> Result<Vec<AurPackage>, CubError> {
|
|
let rpc: AurRpcResponse = serde_json::from_str(body)
|
|
.map_err(|err| aur_error(format!("failed to parse JSON response: {err}")))?;
|
|
|
|
if rpc.version != 5 {
|
|
return Err(aur_error(format!(
|
|
"unexpected RPC version: {}",
|
|
rpc.version
|
|
)));
|
|
}
|
|
|
|
if rpc.response_type == "error" {
|
|
let message = rpc.error.unwrap_or_else(|| "unknown AUR error".to_string());
|
|
return Err(aur_error(message));
|
|
}
|
|
|
|
if rpc.response_type != "search"
|
|
&& rpc.response_type != "multiinfo"
|
|
&& rpc.response_type != "info"
|
|
{
|
|
return Err(aur_error(format!(
|
|
"unexpected RPC response type: {}",
|
|
rpc.response_type
|
|
)));
|
|
}
|
|
|
|
if rpc.resultcount <= 0 || rpc.results.is_empty() {
|
|
return Err(aur_error(empty_message.to_string()));
|
|
}
|
|
|
|
Ok(rpc.results)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const SAMPLE_RESPONSE: &str = r#"
|
|
{
|
|
"version": 5,
|
|
"type": "search",
|
|
"resultcount": 1,
|
|
"results": [
|
|
{
|
|
"Name": "ripgrep-all",
|
|
"Version": "0.10.6-1",
|
|
"Description": "ripgrep, but also search in PDFs, E-Books, Office documents, zip, tar.gz, etc.",
|
|
"URL": "https://github.com/phiresky/ripgrep-all",
|
|
"License": ["AGPL-3.0-only"],
|
|
"Depends": ["poppler", "ffmpeg"],
|
|
"MakeDepends": ["rust"],
|
|
"OptDepends": ["pandoc: search markdown conversions"],
|
|
"Provides": ["rga"],
|
|
"Conflicts": ["rga-git"],
|
|
"NumVotes": 412,
|
|
"Popularity": 4.52,
|
|
"LastModified": 1718300444,
|
|
"OutOfDate": null
|
|
}
|
|
]
|
|
}
|
|
"#;
|
|
|
|
const OUT_OF_DATE_RESPONSE: &str = r#"
|
|
{
|
|
"version": 5,
|
|
"type": "multiinfo",
|
|
"resultcount": 1,
|
|
"results": [
|
|
{
|
|
"Name": "demo-pkg",
|
|
"Version": "1.2.3-1",
|
|
"Description": "demo",
|
|
"URL": null,
|
|
"License": "MIT",
|
|
"Depends": null,
|
|
"MakeDepends": [],
|
|
"OptDepends": [],
|
|
"Provides": [],
|
|
"Conflicts": [],
|
|
"NumVotes": 3,
|
|
"Popularity": 0.75,
|
|
"LastModified": 1718300000,
|
|
"OutOfDate": 1719000000
|
|
}
|
|
]
|
|
}
|
|
"#;
|
|
|
|
#[test]
|
|
fn deserializes_aur_package_from_sample_json() {
|
|
let packages =
|
|
parse_rpc_response(SAMPLE_RESPONSE, "no results").expect("parse sample AUR JSON");
|
|
let package = packages.first().expect("sample package");
|
|
|
|
assert_eq!(package.name, "ripgrep-all");
|
|
assert_eq!(package.version, "0.10.6-1");
|
|
assert_eq!(
|
|
package.description,
|
|
"ripgrep, but also search in PDFs, E-Books, Office documents, zip, tar.gz, etc."
|
|
);
|
|
assert_eq!(package.url, "https://github.com/phiresky/ripgrep-all");
|
|
assert_eq!(package.license, vec!["AGPL-3.0-only"]);
|
|
assert_eq!(package.depends, vec!["poppler", "ffmpeg"]);
|
|
assert_eq!(package.makedepends, vec!["rust"]);
|
|
assert_eq!(
|
|
package.optdepends,
|
|
vec!["pandoc: search markdown conversions"]
|
|
);
|
|
assert_eq!(package.provides, vec!["rga"]);
|
|
assert_eq!(package.conflicts, vec!["rga-git"]);
|
|
assert_eq!(package.num_votes, 412);
|
|
assert_eq!(package.popularity, 4.52);
|
|
assert_eq!(package.last_modified, 1718300444);
|
|
assert_eq!(package.out_of_date, None);
|
|
}
|
|
|
|
#[test]
|
|
fn converts_non_null_out_of_date_to_true() {
|
|
let packages =
|
|
parse_rpc_response(OUT_OF_DATE_RESPONSE, "no results").expect("parse sample AUR JSON");
|
|
let package = packages.first().expect("sample package");
|
|
|
|
assert_eq!(package.url, "");
|
|
assert_eq!(package.license, vec!["MIT"]);
|
|
assert_eq!(package.depends, Vec::<String>::new());
|
|
assert_eq!(package.out_of_date, Some(true));
|
|
}
|
|
}
|