fix: parse Cub AUR responses with serde

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-07 21:15:48 +01:00
parent bcc42cc022
commit 38c73ee03b
@@ -1,27 +1,61 @@
use std::collections::BTreeMap; use serde::Deserialize;
use crate::error::CubError; use crate::error::CubError;
const DEFAULT_AUR_BASE_URL: &str = "https://aur.archlinux.org"; const DEFAULT_AUR_BASE_URL: &str = "https://aur.archlinux.org";
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct AurPackage { pub struct AurPackage {
#[serde(rename = "Name")]
pub name: String, pub name: String,
#[serde(rename = "Version")]
pub version: String, pub version: String,
#[serde(rename = "Description")]
#[serde(default)]
pub description: String, pub description: String,
#[serde(rename = "URL")]
#[serde(default)]
pub url: String, pub url: String,
#[serde(rename = "License")]
#[serde(default)]
pub license: Vec<String>, pub license: Vec<String>,
#[serde(rename = "Depends")]
#[serde(default)]
pub depends: Vec<String>, pub depends: Vec<String>,
#[serde(rename = "MakeDepends")]
#[serde(default)]
pub makedepends: Vec<String>, pub makedepends: Vec<String>,
#[serde(rename = "OptDepends")]
#[serde(default)]
pub optdepends: Vec<String>, pub optdepends: Vec<String>,
#[serde(rename = "Provides")]
#[serde(default)]
pub provides: Vec<String>, pub provides: Vec<String>,
#[serde(rename = "Conflicts")]
#[serde(default)]
pub conflicts: Vec<String>, pub conflicts: Vec<String>,
#[serde(rename = "NumVotes")]
pub num_votes: u64, pub num_votes: u64,
#[serde(rename = "Popularity")]
pub popularity: f64, pub popularity: f64,
#[serde(rename = "LastModified")]
pub last_modified: i64, pub last_modified: i64,
#[serde(rename = "OutOfDate")]
#[serde(default)]
pub out_of_date: Option<bool>, pub out_of_date: Option<bool>,
} }
#[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")] #[cfg(feature = "full")]
pub struct AurClient { pub struct AurClient {
pub base_url: String, pub base_url: String,
@@ -153,526 +187,39 @@ fn non_empty_trimmed(value: &str) -> Option<&str> {
} }
fn aur_error(message: impl Into<String>) -> CubError { fn aur_error(message: impl Into<String>) -> CubError {
CubError::Conversion(format!("AUR: {}", message.into())) CubError::Aur(message.into())
} }
#[cfg(not(feature = "full"))] #[cfg(not(feature = "full"))]
fn feature_not_enabled_error() -> CubError { fn feature_not_enabled_error() -> CubError {
CubError::Conversion("reqwest feature not enabled".into()) CubError::Aur("reqwest feature not enabled".into())
} }
fn parse_rpc_response(body: &str, empty_message: &str) -> Result<Vec<AurPackage>, CubError> { fn parse_rpc_response(body: &str, empty_message: &str) -> Result<Vec<AurPackage>, CubError> {
let root = JsonParser::new(body) let rpc: AurRpcResponse = serde_json::from_str(body)
.parse()
.map_err(|err| aur_error(format!("failed to parse JSON response: {err}")))?; .map_err(|err| aur_error(format!("failed to parse JSON response: {err}")))?;
let object = match root { if rpc.version != 5 {
JsonValue::Object(object) => object, return Err(aur_error(format!("unexpected RPC version: {}", rpc.version)));
_ => {
return Err(aur_error(
"invalid JSON response: expected top-level object",
))
}
};
let version = require_i64(&object, "version")?;
if version != 5 {
return Err(aur_error(format!("unexpected RPC version: {version}")));
} }
let response_type = require_string(&object, "type")?; if rpc.response_type == "error" {
if response_type == "error" { let message = rpc.error.unwrap_or_else(|| "unknown AUR error".to_string());
let message =
optional_string(&object, "error")?.unwrap_or_else(|| "unknown AUR error".to_string());
return Err(aur_error(message)); return Err(aur_error(message));
} }
if response_type != "search" && response_type != "multiinfo" && response_type != "info" { if rpc.response_type != "search" && rpc.response_type != "multiinfo" && rpc.response_type != "info" {
return Err(aur_error(format!( return Err(aur_error(format!(
"unexpected RPC response type: {response_type}" "unexpected RPC response type: {}",
rpc.response_type
))); )));
} }
let resultcount = require_i64(&object, "resultcount")?; if rpc.resultcount <= 0 || rpc.results.is_empty() {
let results = require_array(&object, "results")?;
if resultcount <= 0 || results.is_empty() {
return Err(aur_error(empty_message.to_string())); return Err(aur_error(empty_message.to_string()));
} }
let mut packages = Vec::with_capacity(results.len()); Ok(rpc.results)
for value in results {
packages.push(AurPackage::from_json_value(value)?);
}
if packages.is_empty() {
return Err(aur_error(empty_message.to_string()));
}
Ok(packages)
}
impl AurPackage {
fn from_json_value(value: &JsonValue) -> Result<Self, CubError> {
let object = match value {
JsonValue::Object(object) => object,
_ => return Err(aur_error("invalid package entry: expected object")),
};
Ok(Self {
name: require_string(object, "Name")?,
version: require_string(object, "Version")?,
description: optional_string(object, "Description")?.unwrap_or_default(),
url: optional_string(object, "URL")?.unwrap_or_default(),
license: optional_string_list(object, "License")?,
depends: optional_string_list(object, "Depends")?,
makedepends: optional_string_list(object, "MakeDepends")?,
optdepends: optional_string_list(object, "OptDepends")?,
provides: optional_string_list(object, "Provides")?,
conflicts: optional_string_list(object, "Conflicts")?,
num_votes: optional_u64(object, "NumVotes")?.unwrap_or(0),
popularity: optional_f64(object, "Popularity")?.unwrap_or(0.0),
last_modified: optional_i64(object, "LastModified")?.unwrap_or(0),
out_of_date: optional_out_of_date(object, "OutOfDate")?,
})
}
}
fn require_string(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<String, CubError> {
optional_string(object, key)?.ok_or_else(|| aur_error(format!("missing field '{key}'")))
}
fn optional_string(
object: &BTreeMap<String, JsonValue>,
key: &str,
) -> Result<Option<String>, CubError> {
match object.get(key) {
None | Some(JsonValue::Null) => Ok(None),
Some(JsonValue::String(value)) => Ok(Some(value.clone())),
Some(_) => Err(aur_error(format!("invalid field '{key}': expected string"))),
}
}
fn require_array<'a>(
object: &'a BTreeMap<String, JsonValue>,
key: &str,
) -> Result<&'a [JsonValue], CubError> {
match object.get(key) {
Some(JsonValue::Array(values)) => Ok(values.as_slice()),
Some(_) => Err(aur_error(format!("invalid field '{key}': expected array"))),
None => Err(aur_error(format!("missing field '{key}'"))),
}
}
fn optional_string_list(
object: &BTreeMap<String, JsonValue>,
key: &str,
) -> Result<Vec<String>, CubError> {
match object.get(key) {
None | Some(JsonValue::Null) => Ok(Vec::new()),
Some(JsonValue::String(value)) => Ok(vec![value.clone()]),
Some(JsonValue::Array(values)) => {
let mut items = Vec::with_capacity(values.len());
for value in values {
match value {
JsonValue::String(item) => items.push(item.clone()),
JsonValue::Null => {}
_ => {
return Err(aur_error(format!(
"invalid field '{key}': expected string array"
)))
}
}
}
Ok(items)
}
Some(_) => Err(aur_error(format!(
"invalid field '{key}': expected string array"
))),
}
}
fn require_i64(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<i64, CubError> {
optional_i64(object, key)?.ok_or_else(|| aur_error(format!("missing field '{key}'")))
}
fn optional_i64(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<Option<i64>, CubError> {
match object.get(key) {
None | Some(JsonValue::Null) => Ok(None),
Some(JsonValue::Integer(value)) => Ok(Some(*value)),
Some(_) => Err(aur_error(format!(
"invalid field '{key}': expected integer"
))),
}
}
fn optional_u64(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<Option<u64>, CubError> {
match object.get(key) {
None | Some(JsonValue::Null) => Ok(None),
Some(JsonValue::Integer(value)) if *value >= 0 => Ok(Some(*value as u64)),
Some(_) => Err(aur_error(format!(
"invalid field '{key}': expected unsigned integer"
))),
}
}
fn optional_f64(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<Option<f64>, CubError> {
match object.get(key) {
None | Some(JsonValue::Null) => Ok(None),
Some(JsonValue::Integer(value)) => Ok(Some(*value as f64)),
Some(JsonValue::Float(value)) => Ok(Some(*value)),
Some(_) => Err(aur_error(format!("invalid field '{key}': expected number"))),
}
}
fn optional_out_of_date(
object: &BTreeMap<String, JsonValue>,
key: &str,
) -> Result<Option<bool>, CubError> {
match object.get(key) {
None | Some(JsonValue::Null) => Ok(None),
Some(JsonValue::Bool(value)) => Ok(Some(*value)),
Some(JsonValue::Integer(value)) => Ok(Some(*value != 0)),
Some(JsonValue::Float(value)) => Ok(Some(*value != 0.0)),
Some(_) => Err(aur_error(format!(
"invalid field '{key}': expected bool or number"
))),
}
}
#[derive(Debug, Clone, PartialEq)]
enum JsonValue {
Null,
Bool(bool),
Integer(i64),
Float(f64),
String(String),
Array(Vec<JsonValue>),
Object(BTreeMap<String, JsonValue>),
}
struct JsonParser<'a> {
input: &'a [u8],
position: usize,
}
impl<'a> JsonParser<'a> {
fn new(input: &'a str) -> Self {
Self {
input: input.as_bytes(),
position: 0,
}
}
fn parse(mut self) -> Result<JsonValue, String> {
let value = self.parse_value()?;
self.skip_whitespace();
if self.position != self.input.len() {
return Err(format!(
"unexpected trailing content at byte {}",
self.position
));
}
Ok(value)
}
fn parse_value(&mut self) -> Result<JsonValue, String> {
self.skip_whitespace();
match self.peek_byte() {
Some(b'{') => self.parse_object(),
Some(b'[') => self.parse_array(),
Some(b'"') => self.parse_string().map(JsonValue::String),
Some(b't') => self.parse_true(),
Some(b'f') => self.parse_false(),
Some(b'n') => self.parse_null(),
Some(b'-' | b'0'..=b'9') => self.parse_number(),
Some(byte) => Err(format!(
"unexpected byte '{}' at {}",
byte as char, self.position
)),
None => Err("unexpected end of input".to_string()),
}
}
fn parse_object(&mut self) -> Result<JsonValue, String> {
self.expect_byte(b'{')?;
self.skip_whitespace();
let mut object = BTreeMap::new();
if self.peek_byte() == Some(b'}') {
self.position += 1;
return Ok(JsonValue::Object(object));
}
loop {
self.skip_whitespace();
let key = self.parse_string()?;
self.skip_whitespace();
self.expect_byte(b':')?;
let value = self.parse_value()?;
object.insert(key, value);
self.skip_whitespace();
match self.peek_byte() {
Some(b',') => {
self.position += 1;
}
Some(b'}') => {
self.position += 1;
break;
}
Some(byte) => {
return Err(format!(
"unexpected byte '{}' in object at {}",
byte as char, self.position
))
}
None => return Err("unexpected end of input while parsing object".to_string()),
}
}
Ok(JsonValue::Object(object))
}
fn parse_array(&mut self) -> Result<JsonValue, String> {
self.expect_byte(b'[')?;
self.skip_whitespace();
let mut values = Vec::new();
if self.peek_byte() == Some(b']') {
self.position += 1;
return Ok(JsonValue::Array(values));
}
loop {
values.push(self.parse_value()?);
self.skip_whitespace();
match self.peek_byte() {
Some(b',') => {
self.position += 1;
}
Some(b']') => {
self.position += 1;
break;
}
Some(byte) => {
return Err(format!(
"unexpected byte '{}' in array at {}",
byte as char, self.position
))
}
None => return Err("unexpected end of input while parsing array".to_string()),
}
}
Ok(JsonValue::Array(values))
}
fn parse_string(&mut self) -> Result<String, String> {
self.expect_byte(b'"')?;
let mut value = String::new();
loop {
match self.peek_byte() {
Some(b'"') => {
self.position += 1;
return Ok(value);
}
Some(b'\\') => {
self.position += 1;
value.push(self.parse_escape_sequence()?);
}
Some(0x00..=0x1F) => {
return Err(format!(
"invalid control character in string at {}",
self.position
))
}
Some(_) => value.push_str(&self.parse_raw_string_segment()?),
None => break,
}
}
Err("unexpected end of input while parsing string".to_string())
}
fn parse_raw_string_segment(&mut self) -> Result<String, String> {
let start = self.position;
while let Some(byte) = self.peek_byte() {
if matches!(byte, b'"' | b'\\' | 0x00..=0x1F) {
break;
}
self.position += 1;
}
let segment = std::str::from_utf8(&self.input[start..self.position])
.map_err(|err| format!("invalid UTF-8 in string: {err}"))?;
Ok(segment.to_string())
}
fn parse_escape_sequence(&mut self) -> Result<char, String> {
match self.next_byte() {
Some(b'"') => Ok('"'),
Some(b'\\') => Ok('\\'),
Some(b'/') => Ok('/'),
Some(b'b') => Ok('\u{0008}'),
Some(b'f') => Ok('\u{000C}'),
Some(b'n') => Ok('\n'),
Some(b'r') => Ok('\r'),
Some(b't') => Ok('\t'),
Some(b'u') => self.parse_unicode_escape(),
Some(byte) => Err(format!(
"invalid escape byte '{}' at {}",
byte as char,
self.position.saturating_sub(1)
)),
None => Err("unexpected end of input while parsing escape sequence".to_string()),
}
}
fn parse_unicode_escape(&mut self) -> Result<char, String> {
let start = self.position;
let mut value = 0u32;
for _ in 0..4 {
let byte = self.next_byte().ok_or_else(|| {
"unexpected end of input while parsing unicode escape".to_string()
})?;
value = (value << 4)
| match byte {
b'0'..=b'9' => (byte - b'0') as u32,
b'a'..=b'f' => (byte - b'a' + 10) as u32,
b'A'..=b'F' => (byte - b'A' + 10) as u32,
_ => {
return Err(format!(
"invalid unicode escape at byte {}",
start.saturating_sub(2)
))
}
};
}
char::from_u32(value).ok_or_else(|| format!("invalid unicode scalar value: {value:#X}"))
}
fn parse_true(&mut self) -> Result<JsonValue, String> {
self.expect_keyword(b"true")?;
Ok(JsonValue::Bool(true))
}
fn parse_false(&mut self) -> Result<JsonValue, String> {
self.expect_keyword(b"false")?;
Ok(JsonValue::Bool(false))
}
fn parse_null(&mut self) -> Result<JsonValue, String> {
self.expect_keyword(b"null")?;
Ok(JsonValue::Null)
}
fn parse_number(&mut self) -> Result<JsonValue, String> {
let start = self.position;
if self.peek_byte() == Some(b'-') {
self.position += 1;
}
self.parse_digits()?;
if self.peek_byte() == Some(b'.') {
self.position += 1;
self.parse_digits()?;
}
if matches!(self.peek_byte(), Some(b'e' | b'E')) {
self.position += 1;
if matches!(self.peek_byte(), Some(b'+' | b'-')) {
self.position += 1;
}
self.parse_digits()?;
}
let slice = std::str::from_utf8(&self.input[start..self.position])
.map_err(|err| format!("invalid UTF-8 in number: {err}"))?;
if slice
.as_bytes()
.iter()
.any(|byte| matches!(byte, b'.' | b'e' | b'E'))
{
slice
.parse::<f64>()
.map(JsonValue::Float)
.map_err(|err| format!("invalid number '{slice}': {err}"))
} else {
slice
.parse::<i64>()
.map(JsonValue::Integer)
.map_err(|err| format!("invalid number '{slice}': {err}"))
}
}
fn parse_digits(&mut self) -> Result<(), String> {
let start = self.position;
while matches!(self.peek_byte(), Some(b'0'..=b'9')) {
self.position += 1;
}
if self.position == start {
Err(format!("expected digit at byte {}", self.position))
} else {
Ok(())
}
}
fn expect_keyword(&mut self, keyword: &[u8]) -> Result<(), String> {
for expected in keyword {
match self.next_byte() {
Some(byte) if byte == *expected => {}
Some(byte) => {
return Err(format!(
"unexpected byte '{}' while parsing keyword at {}",
byte as char,
self.position.saturating_sub(1)
))
}
None => return Err("unexpected end of input while parsing keyword".to_string()),
}
}
Ok(())
}
fn skip_whitespace(&mut self) {
while matches!(self.peek_byte(), Some(b' ' | b'\n' | b'\r' | b'\t')) {
self.position += 1;
}
}
fn expect_byte(&mut self, expected: u8) -> Result<(), String> {
match self.next_byte() {
Some(byte) if byte == expected => Ok(()),
Some(byte) => Err(format!(
"expected '{}' but found '{}' at {}",
expected as char,
byte as char,
self.position.saturating_sub(1)
)),
None => Err(format!(
"expected '{}' but reached end of input",
expected as char
)),
}
}
fn peek_byte(&self) -> Option<u8> {
self.input.get(self.position).copied()
}
fn next_byte(&mut self) -> Option<u8> {
let byte = self.peek_byte()?;
self.position += 1;
Some(byte)
}
} }
#[cfg(test)] #[cfg(test)]