1use thiserror::Error;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum MultipartAbortStatus {
10 Succeeded,
12 Failed,
14}
15
16impl std::fmt::Display for MultipartAbortStatus {
17 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 Self::Succeeded => formatter.write_str("succeeded"),
20 Self::Failed => formatter.write_str("failed"),
21 }
22 }
23}
24
25pub type Result<T> = std::result::Result<T, Error>;
27
28#[derive(Error, Debug)]
30pub enum Error {
31 #[error("Configuration error: {0}")]
33 Config(String),
34
35 #[error("Invalid path: {0}")]
37 InvalidPath(String),
38
39 #[error("Alias not found: {0}")]
41 AliasNotFound(String),
42
43 #[error("Alias already exists: {0}")]
45 AliasExists(String),
46
47 #[error("IO error: {0}")]
49 Io(#[from] std::io::Error),
50
51 #[error("TOML parse error: {0}")]
53 TomlParse(#[from] toml::de::Error),
54
55 #[error("TOML serialization error: {0}")]
57 TomlSerialize(#[from] toml::ser::Error),
58
59 #[error("JSON error: {0}")]
61 Json(#[from] serde_json::Error),
62
63 #[error("Invalid URL: {0}")]
65 InvalidUrl(#[from] url::ParseError),
66
67 #[error("Authentication failed: {0}")]
69 Auth(String),
70
71 #[error("Not found: {0}")]
73 NotFound(String),
74
75 #[error("Object version '{version_id}' not found: {path}")]
77 VersionNotFound {
78 path: String,
80 version_id: String,
82 },
83
84 #[error("Object version '{version_id}' is a delete marker: {path}")]
86 DeleteMarker {
87 path: String,
89 version_id: String,
91 },
92
93 #[error("Governance retention denied deletion: {path} (version: {version_id:?})")]
95 GovernanceDenied {
96 path: String,
98 version_id: Option<String>,
100 },
101
102 #[error("Network error: {0}")]
104 Network(String),
105
106 #[error("Conflict: {0}")]
108 Conflict(String),
109
110 #[error("Unsupported feature: {0}")]
112 UnsupportedFeature(String),
113
114 #[error("{0}")]
116 General(String),
117
118 #[error("{0}")]
120 RequestRejected(String),
121
122 #[error("Operation interrupted: {0}")]
124 Interrupted(String),
125}
126
127impl Error {
128 pub const fn exit_code(&self) -> i32 {
130 match self {
131 Error::InvalidPath(_) => 2, Error::Config(_) => 2, Error::Network(_) => 3, Error::Auth(_) => 4, Error::NotFound(_)
136 | Error::VersionNotFound { .. }
137 | Error::DeleteMarker { .. }
138 | Error::AliasNotFound(_) => 5, Error::Conflict(_) | Error::GovernanceDenied { .. } | Error::AliasExists(_) => 6, Error::UnsupportedFeature(_) => 7, Error::Interrupted(_) => 130, Error::RequestRejected(_) => 1, _ => 1, }
145 }
146
147 pub fn with_multipart_copy_context(
149 self,
150 upload_id: &str,
151 abort_status: MultipartAbortStatus,
152 ) -> Self {
153 let safe_upload_id = upload_id
156 .chars()
157 .flat_map(char::escape_default)
158 .take(256)
159 .collect::<String>();
160 let context = format!("multipart upload ID: {safe_upload_id}, abort: {abort_status}");
161 match self {
162 Self::Auth(message) => Self::Auth(format!("{message}; {context}")),
163 Self::Network(message) => Self::Network(format!("{message}; {context}")),
164 Self::NotFound(message) => Self::NotFound(format!("{message}; {context}")),
165 Self::VersionNotFound { path, version_id } => Self::VersionNotFound {
166 path: format!("{path} ({context})"),
167 version_id,
168 },
169 Self::DeleteMarker { path, version_id } => Self::DeleteMarker {
170 path: format!("{path} ({context})"),
171 version_id,
172 },
173 Self::GovernanceDenied { path, version_id } => Self::GovernanceDenied {
174 path: format!("{path} ({context})"),
175 version_id,
176 },
177 Self::Conflict(message) => Self::Conflict(format!("{message}; {context}")),
178 Self::UnsupportedFeature(message) => {
179 Self::UnsupportedFeature(format!("{message}; {context}"))
180 }
181 Self::InvalidPath(message) => Self::InvalidPath(format!("{message}; {context}")),
182 Self::Config(message) => Self::Config(format!("{message}; {context}")),
183 Self::AliasNotFound(message) => Self::AliasNotFound(format!("{message}; {context}")),
184 Self::AliasExists(message) => Self::AliasExists(format!("{message}; {context}")),
185 Self::RequestRejected(message) => {
186 Self::RequestRejected(format!("{message}; {context}"))
187 }
188 Self::Interrupted(message) => Self::Interrupted(format!("{message}; {context}")),
189 source => Self::General(format!("{source}; {context}")),
190 }
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn test_error_exit_codes() {
200 assert_eq!(Error::InvalidPath("test".into()).exit_code(), 2);
201 assert_eq!(Error::Config("test".into()).exit_code(), 2);
202 assert_eq!(Error::Network("test".into()).exit_code(), 3);
203 assert_eq!(Error::Auth("test".into()).exit_code(), 4);
204 assert_eq!(Error::NotFound("test".into()).exit_code(), 5);
205 assert_eq!(Error::AliasNotFound("test".into()).exit_code(), 5);
206 assert_eq!(Error::Conflict("test".into()).exit_code(), 6);
207 assert_eq!(Error::AliasExists("test".into()).exit_code(), 6);
208 assert_eq!(Error::UnsupportedFeature("test".into()).exit_code(), 7);
209 assert_eq!(Error::RequestRejected("test".into()).exit_code(), 1);
210 assert_eq!(Error::General("test".into()).exit_code(), 1);
211 }
212
213 #[test]
214 fn test_error_display() {
215 let err = Error::AliasNotFound("myalias".into());
216 assert_eq!(err.to_string(), "Alias not found: myalias");
217
218 let err = Error::InvalidPath("/bad/path".into());
219 assert_eq!(err.to_string(), "Invalid path: /bad/path");
220 }
221
222 #[test]
223 fn versioning_errors_are_distinct_and_stable() {
224 let missing = Error::VersionNotFound {
225 path: "local/bucket/object.txt".to_string(),
226 version_id: "v1".to_string(),
227 };
228 let marker = Error::DeleteMarker {
229 path: "local/bucket/object.txt".to_string(),
230 version_id: "v2".to_string(),
231 };
232 let governance = Error::GovernanceDenied {
233 path: "local/bucket/object.txt".to_string(),
234 version_id: Some("v3".to_string()),
235 };
236
237 assert_eq!(missing.exit_code(), 5);
238 assert!(missing.to_string().contains("version 'v1'"));
239 assert_eq!(marker.exit_code(), 5);
240 assert!(marker.to_string().contains("delete marker"));
241 assert_eq!(governance.exit_code(), 6);
242 assert!(governance.to_string().contains("Governance retention"));
243 }
244
245 #[test]
246 fn multipart_copy_failure_preserves_primary_classification() {
247 let error = Error::Auth("access denied".to_string())
248 .with_multipart_copy_context("upload-123", MultipartAbortStatus::Succeeded);
249
250 assert_eq!(error.exit_code(), 4);
251 assert!(error.to_string().contains("upload-123"));
252 assert!(error.to_string().contains("abort: succeeded"));
253
254 let interrupted = Error::Interrupted("cancelled".to_string())
255 .with_multipart_copy_context("upload-456", MultipartAbortStatus::Succeeded);
256 assert_eq!(interrupted.exit_code(), 130);
257 assert!(interrupted.to_string().contains("upload-456"));
258 }
259}