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:
@@ -1,27 +1,61 @@
|
||||
use std::collections::BTreeMap;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::CubError;
|
||||
|
||||
const DEFAULT_AUR_BASE_URL: &str = "https://aur.archlinux.org";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[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)]
|
||||
pub description: String,
|
||||
#[serde(rename = "URL")]
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(rename = "License")]
|
||||
#[serde(default)]
|
||||
pub license: Vec<String>,
|
||||
#[serde(rename = "Depends")]
|
||||
#[serde(default)]
|
||||
pub depends: Vec<String>,
|
||||
#[serde(rename = "MakeDepends")]
|
||||
#[serde(default)]
|
||||
pub makedepends: Vec<String>,
|
||||
#[serde(rename = "OptDepends")]
|
||||
#[serde(default)]
|
||||
pub optdepends: Vec<String>,
|
||||
#[serde(rename = "Provides")]
|
||||
#[serde(default)]
|
||||
pub provides: Vec<String>,
|
||||
#[serde(rename = "Conflicts")]
|
||||
#[serde(default)]
|
||||
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)]
|
||||
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")]
|
||||
pub struct AurClient {
|
||||
pub base_url: String,
|
||||
@@ -153,526 +187,39 @@ fn non_empty_trimmed(value: &str) -> Option<&str> {
|
||||
}
|
||||
|
||||
fn aur_error(message: impl Into<String>) -> CubError {
|
||||
CubError::Conversion(format!("AUR: {}", message.into()))
|
||||
CubError::Aur(message.into())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "full"))]
|
||||
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> {
|
||||
let root = JsonParser::new(body)
|
||||
.parse()
|
||||
let rpc: AurRpcResponse = serde_json::from_str(body)
|
||||
.map_err(|err| aur_error(format!("failed to parse JSON response: {err}")))?;
|
||||
|
||||
let object = match root {
|
||||
JsonValue::Object(object) => object,
|
||||
_ => {
|
||||
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}")));
|
||||
if rpc.version != 5 {
|
||||
return Err(aur_error(format!("unexpected RPC version: {}", rpc.version)));
|
||||
}
|
||||
|
||||
let response_type = require_string(&object, "type")?;
|
||||
if response_type == "error" {
|
||||
let message =
|
||||
optional_string(&object, "error")?.unwrap_or_else(|| "unknown AUR error".to_string());
|
||||
if rpc.response_type == "error" {
|
||||
let message = rpc.error.unwrap_or_else(|| "unknown AUR error".to_string());
|
||||
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!(
|
||||
"unexpected RPC response type: {response_type}"
|
||||
"unexpected RPC response type: {}",
|
||||
rpc.response_type
|
||||
)));
|
||||
}
|
||||
|
||||
let resultcount = require_i64(&object, "resultcount")?;
|
||||
let results = require_array(&object, "results")?;
|
||||
|
||||
if resultcount <= 0 || results.is_empty() {
|
||||
if rpc.resultcount <= 0 || rpc.results.is_empty() {
|
||||
return Err(aur_error(empty_message.to_string()));
|
||||
}
|
||||
|
||||
let mut packages = Vec::with_capacity(results.len());
|
||||
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)
|
||||
}
|
||||
Ok(rpc.results)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user