Files
RedBear-OS/local/recipes/system/cub/source/cub-lib/src/aur.rs
T

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(&params)?;
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));
}
}