use serde::{Deserialize, Serialize};
use thiserror::Error;
pub type CirrusResult<T> = Result<T, CirrusError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SalesforceError {
pub message: String,
#[serde(rename = "errorCode")]
pub error_code: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
}
#[derive(Debug, Error)]
pub enum CirrusError {
#[error("missing required builder field: {0}")]
MissingField(&'static str),
#[error("failed to construct HTTP client: {0}")]
HttpClient(#[source] reqwest::Error),
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Salesforce API error (status {status}): {}", display_errors(.errors, .raw))]
Api {
status: u16,
errors: Vec<SalesforceError>,
raw: Option<String>,
},
#[error("OAuth error: {error}{}", .error_description.as_deref().map(|d| format!(" — {d}")).unwrap_or_default())]
OAuth {
error: String,
error_description: Option<String>,
},
#[error("authentication failed: {0}")]
Auth(String),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("invalid URL: {0}")]
Url(#[from] url::ParseError),
#[error("invalid header value: {0}")]
InvalidHeader(String),
#[error("invalid response: {0}")]
InvalidResponse(String),
}
fn display_errors(errors: &[SalesforceError], raw: &Option<String>) -> String {
if errors.is_empty() {
return raw
.as_deref()
.map(|r| format!("<unparsed body: {r}>"))
.unwrap_or_else(|| "<no body>".to_string());
}
errors
.iter()
.map(|e| {
if e.fields.is_empty() {
format!("[{}] {}", e.error_code, e.message)
} else {
format!(
"[{}] {} (fields: {})",
e.error_code,
e.message,
e.fields.join(", ")
)
}
})
.collect::<Vec<_>>()
.join("; ")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn display_errors_formats_single_error() {
let err = CirrusError::Api {
status: 400,
errors: vec![SalesforceError {
message: "Required field missing".to_string(),
error_code: "REQUIRED_FIELD_MISSING".to_string(),
fields: vec!["Name".to_string()],
}],
raw: None,
};
let msg = err.to_string();
assert!(msg.contains("400"));
assert!(msg.contains("REQUIRED_FIELD_MISSING"));
assert!(msg.contains("Name"));
}
#[test]
fn display_errors_falls_back_to_raw_body() {
let err = CirrusError::Api {
status: 500,
errors: vec![],
raw: Some("Internal Server Error".to_string()),
};
let msg = err.to_string();
assert!(msg.contains("500"));
assert!(msg.contains("Internal Server Error"));
}
#[test]
fn salesforce_error_deserializes_canonical_shape() {
let json = r#"[{"message":"bad","errorCode":"INVALID_FIELD","fields":["Foo"]}]"#;
let parsed: Vec<SalesforceError> = serde_json::from_str(json).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].error_code, "INVALID_FIELD");
assert_eq!(parsed[0].fields, vec!["Foo"]);
}
#[test]
fn salesforce_error_handles_missing_fields() {
let json = r#"[{"message":"bad","errorCode":"INVALID_SESSION_ID"}]"#;
let parsed: Vec<SalesforceError> = serde_json::from_str(json).unwrap();
assert_eq!(parsed.len(), 1);
assert!(parsed[0].fields.is_empty());
}
}