use crate::scgi::{send_request, ScgiError, ScgiRequest}; use quick_xml::de::from_str; use quick_xml::se::to_string; use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Error, Debug)] pub enum XmlRpcError { #[error("SCGI Error: {0}")] Scgi(#[from] ScgiError), #[error("Serialization Error: {0}")] Serialization(String), #[error("Deserialization Error: {0}")] Deserialization(#[from] quick_xml::de::DeError), #[error("XML Parse Error: {0}")] Parse(String), } // --- Request Parameters Enum --- #[derive(Debug, Clone)] pub enum RpcParam { String(String), Int(i64), } impl From<&str> for RpcParam { fn from(s: &str) -> Self { RpcParam::String(s.to_string()) } } impl From for RpcParam { fn from(s: String) -> Self { RpcParam::String(s) } } impl From for RpcParam { fn from(i: i64) -> Self { RpcParam::Int(i) } } impl From for RpcParam { fn from(i: i32) -> Self { RpcParam::Int(i as i64) } } // --- Request Models --- #[derive(Debug, Serialize)] #[serde(rename = "methodCall")] struct MethodCall<'a> { #[serde(rename = "methodName")] method_name: &'a str, params: RequestParams<'a>, } #[derive(Debug, Serialize)] struct RequestParams<'a> { param: Vec>, } #[derive(Debug, Serialize)] struct RequestParam<'a> { value: RequestValueInner<'a>, } #[derive(Debug, Serialize)] struct RequestValueInner<'a> { #[serde(skip_serializing_if = "Option::is_none")] string: Option<&'a str>, // rTorrent standard is often i4 (32-bit signed int). i8 might not be supported by all XML-RPC libs. // Casting i64 to i32 is safe for typical speed limits. #[serde(skip_serializing_if = "Option::is_none")] i4: Option, } // --- Response Models for d.multicall2 --- #[derive(Debug, Deserialize)] #[serde(rename = "methodResponse")] struct MulticallResponse { params: MulticallResponseParams, } #[derive(Debug, Deserialize)] struct MulticallResponseParams { param: MulticallResponseParam, } #[derive(Debug, Deserialize)] struct MulticallResponseParam { value: MulticallResponseValueArray, } #[derive(Debug, Deserialize)] struct MulticallResponseValueArray { array: MulticallResponseDataOuter, } #[derive(Debug, Deserialize)] struct MulticallResponseDataOuter { data: MulticallResponseDataOuterValue, } #[derive(Debug, Deserialize)] struct MulticallResponseDataOuterValue { #[serde(rename = "value", default)] values: Vec, } #[derive(Debug, Deserialize)] struct MulticallRowValue { array: MulticallResponseDataInner, } #[derive(Debug, Deserialize)] struct MulticallResponseDataInner { data: MulticallResponseDataInnerValue, } #[derive(Debug, Deserialize)] struct MulticallResponseDataInnerValue { #[serde(rename = "value", default)] values: Vec, } #[derive(Debug, Deserialize)] struct MulticallItemValue { #[serde(rename = "string", default)] string: Option, #[serde(rename = "i4", default)] i4: Option, #[serde(rename = "i8", default)] i8: Option, } impl MulticallItemValue { fn to_string_lossy(&self) -> String { if let Some(s) = &self.string { s.clone() } else if let Some(i) = self.i4 { i.to_string() } else if let Some(i) = self.i8 { i.to_string() } else { String::new() } } } // --- Response Model for simple string --- #[derive(Debug, Deserialize)] #[serde(rename = "methodResponse")] struct StringResponse { params: StringResponseParams, } #[derive(Debug, Deserialize)] struct StringResponseParams { param: StringResponseParam, } #[derive(Debug, Deserialize)] struct StringResponseParam { value: StringResponseValue, } #[derive(Debug, Deserialize)] struct StringResponseValue { string: String, } // --- Response Model for simple integer (i8/i4) --- #[derive(Debug, Deserialize)] #[serde(rename = "methodResponse")] struct IntegerResponse { params: IntegerResponseParams, } #[derive(Debug, Deserialize)] struct IntegerResponseParams { param: IntegerResponseParam, } #[derive(Debug, Deserialize)] struct IntegerResponseParam { value: IntegerResponseValue, } #[derive(Debug, Deserialize)] struct IntegerResponseValue { #[serde(rename = "i8", default)] i8: Option, #[serde(rename = "i4", default)] i4: Option, #[serde(rename = "string", default)] string: Option, } // --- Client Implementation --- pub struct RtorrentClient { socket_path: String, } impl RtorrentClient { pub fn new(socket_path: &str) -> Self { Self { socket_path: socket_path.to_string(), } } /// Helper to build and serialize XML-RPC method call fn build_method_call(&self, method: &str, params: &[RpcParam]) -> Result { let req_params = RequestParams { param: params .iter() .map(|p| RequestParam { value: match p { RpcParam::String(s) => RequestValueInner { string: Some(s), i4: None, }, RpcParam::Int(i) => RequestValueInner { string: None, i4: Some(*i as i32), }, }, }) .collect(), }; let call = MethodCall { method_name: method, params: req_params, }; let xml_body = to_string(&call).map_err(|e| XmlRpcError::Serialization(e.to_string()))?; Ok(format!("\n{}", xml_body)) } pub async fn call(&self, method: &str, params: &[RpcParam]) -> Result { let xml = self.build_method_call(method, params)?; let req = ScgiRequest::new().body(xml.into_bytes()); let bytes = send_request(&self.socket_path, req).await?; let s = String::from_utf8_lossy(&bytes).to_string(); Ok(s) } } pub fn parse_multicall_response(xml: &str) -> Result>, XmlRpcError> { let response: MulticallResponse = from_str(xml)?; let mut result = Vec::new(); for row in response.params.param.value.array.data.values { let mut row_vec = Vec::new(); for item in row.array.data.values { row_vec.push(item.to_string_lossy()); } result.push(row_vec); } Ok(result) } pub fn parse_string_response(xml: &str) -> Result { let response: StringResponse = from_str(xml)?; Ok(response.params.param.value.string) } pub fn parse_i64_response(xml: &str) -> Result { let response: IntegerResponse = from_str(xml)?; if let Some(val) = response.params.param.value.i8 { Ok(val) } else if let Some(val) = response.params.param.value.i4 { Ok(val) } else if let Some(ref s) = response.params.param.value.string { s.parse() .map_err(|_| XmlRpcError::Parse("Not an integer string".to_string())) } else { Err(XmlRpcError::Parse("No integer value found".to_string())) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_build_method_call() { let client = RtorrentClient::new("dummy"); let params = vec![ RpcParam::String("".to_string()), RpcParam::String("main".to_string()), RpcParam::String("d.name=".to_string()), ]; let xml = client.build_method_call("d.multicall2", ¶ms).unwrap(); assert!(xml.contains("d.multicall2")); assert!(xml.contains("main")); } #[test] fn test_build_method_call_int() { let client = RtorrentClient::new("dummy"); let params = vec![RpcParam::Int(1024)]; let xml = client.build_method_call("test.int", ¶ms).unwrap(); // Should produce 1024 assert!(xml.contains("1024")); } #[test] fn test_parse_multicall_response() { let xml = r#" HASH123 Ubuntu ISO 1024 "#; let result = parse_multicall_response(xml).expect("Failed to parse"); assert_eq!(result.len(), 1); assert_eq!(result[0][0], "HASH123"); assert_eq!(result[0][1], "Ubuntu ISO"); assert_eq!(result[0][2], "1024"); } }