use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MultipartAbortStatus {
Succeeded,
Failed,
}
impl std::fmt::Display for MultipartAbortStatus {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Succeeded => formatter.write_str("succeeded"),
Self::Failed => formatter.write_str("failed"),
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Error, Debug)]
pub enum Error {
#[error("Configuration error: {0}")]
Config(String),
#[error("Invalid path: {0}")]
InvalidPath(String),
#[error("Alias not found: {0}")]
AliasNotFound(String),
#[error("Alias already exists: {0}")]
AliasExists(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parse error: {0}")]
TomlParse(#[from] toml::de::Error),
#[error("TOML serialization error: {0}")]
TomlSerialize(#[from] toml::ser::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
#[error("Authentication failed: {0}")]
Auth(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Object version '{version_id}' not found: {path}")]
VersionNotFound {
path: String,
version_id: String,
},
#[error("Object version '{version_id}' is a delete marker: {path}")]
DeleteMarker {
path: String,
version_id: String,
},
#[error("Governance retention denied deletion: {path} (version: {version_id:?})")]
GovernanceDenied {
path: String,
version_id: Option<String>,
},
#[error("Network error: {0}")]
Network(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Unsupported feature: {0}")]
UnsupportedFeature(String),
#[error("{0}")]
General(String),
#[error("{0}")]
RequestRejected(String),
#[error("Operation interrupted: {0}")]
Interrupted(String),
}
impl Error {
pub const fn exit_code(&self) -> i32 {
match self {
Error::InvalidPath(_) => 2, Error::Config(_) => 2, Error::Network(_) => 3, Error::Auth(_) => 4, Error::NotFound(_)
| Error::VersionNotFound { .. }
| Error::DeleteMarker { .. }
| Error::AliasNotFound(_) => 5, Error::Conflict(_) | Error::GovernanceDenied { .. } | Error::AliasExists(_) => 6, Error::UnsupportedFeature(_) => 7, Error::Interrupted(_) => 130, Error::RequestRejected(_) => 1, _ => 1, }
}
pub fn with_multipart_copy_context(
self,
upload_id: &str,
abort_status: MultipartAbortStatus,
) -> Self {
let safe_upload_id = upload_id
.chars()
.flat_map(char::escape_default)
.take(256)
.collect::<String>();
let context = format!("multipart upload ID: {safe_upload_id}, abort: {abort_status}");
match self {
Self::Auth(message) => Self::Auth(format!("{message}; {context}")),
Self::Network(message) => Self::Network(format!("{message}; {context}")),
Self::NotFound(message) => Self::NotFound(format!("{message}; {context}")),
Self::VersionNotFound { path, version_id } => Self::VersionNotFound {
path: format!("{path} ({context})"),
version_id,
},
Self::DeleteMarker { path, version_id } => Self::DeleteMarker {
path: format!("{path} ({context})"),
version_id,
},
Self::GovernanceDenied { path, version_id } => Self::GovernanceDenied {
path: format!("{path} ({context})"),
version_id,
},
Self::Conflict(message) => Self::Conflict(format!("{message}; {context}")),
Self::UnsupportedFeature(message) => {
Self::UnsupportedFeature(format!("{message}; {context}"))
}
Self::InvalidPath(message) => Self::InvalidPath(format!("{message}; {context}")),
Self::Config(message) => Self::Config(format!("{message}; {context}")),
Self::AliasNotFound(message) => Self::AliasNotFound(format!("{message}; {context}")),
Self::AliasExists(message) => Self::AliasExists(format!("{message}; {context}")),
Self::RequestRejected(message) => {
Self::RequestRejected(format!("{message}; {context}"))
}
Self::Interrupted(message) => Self::Interrupted(format!("{message}; {context}")),
source => Self::General(format!("{source}; {context}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_exit_codes() {
assert_eq!(Error::InvalidPath("test".into()).exit_code(), 2);
assert_eq!(Error::Config("test".into()).exit_code(), 2);
assert_eq!(Error::Network("test".into()).exit_code(), 3);
assert_eq!(Error::Auth("test".into()).exit_code(), 4);
assert_eq!(Error::NotFound("test".into()).exit_code(), 5);
assert_eq!(Error::AliasNotFound("test".into()).exit_code(), 5);
assert_eq!(Error::Conflict("test".into()).exit_code(), 6);
assert_eq!(Error::AliasExists("test".into()).exit_code(), 6);
assert_eq!(Error::UnsupportedFeature("test".into()).exit_code(), 7);
assert_eq!(Error::RequestRejected("test".into()).exit_code(), 1);
assert_eq!(Error::General("test".into()).exit_code(), 1);
}
#[test]
fn test_error_display() {
let err = Error::AliasNotFound("myalias".into());
assert_eq!(err.to_string(), "Alias not found: myalias");
let err = Error::InvalidPath("/bad/path".into());
assert_eq!(err.to_string(), "Invalid path: /bad/path");
}
#[test]
fn versioning_errors_are_distinct_and_stable() {
let missing = Error::VersionNotFound {
path: "local/bucket/object.txt".to_string(),
version_id: "v1".to_string(),
};
let marker = Error::DeleteMarker {
path: "local/bucket/object.txt".to_string(),
version_id: "v2".to_string(),
};
let governance = Error::GovernanceDenied {
path: "local/bucket/object.txt".to_string(),
version_id: Some("v3".to_string()),
};
assert_eq!(missing.exit_code(), 5);
assert!(missing.to_string().contains("version 'v1'"));
assert_eq!(marker.exit_code(), 5);
assert!(marker.to_string().contains("delete marker"));
assert_eq!(governance.exit_code(), 6);
assert!(governance.to_string().contains("Governance retention"));
}
#[test]
fn multipart_copy_failure_preserves_primary_classification() {
let error = Error::Auth("access denied".to_string())
.with_multipart_copy_context("upload-123", MultipartAbortStatus::Succeeded);
assert_eq!(error.exit_code(), 4);
assert!(error.to_string().contains("upload-123"));
assert!(error.to_string().contains("abort: succeeded"));
let interrupted = Error::Interrupted("cancelled".to_string())
.with_multipart_copy_context("upload-456", MultipartAbortStatus::Succeeded);
assert_eq!(interrupted.exit_code(), 130);
assert!(interrupted.to_string().contains("upload-456"));
}
}