Skip to main content

rc_core/
error.rs

1//! Error types for rc-core
2//!
3//! Provides a unified error type that can be converted to appropriate exit codes.
4
5use thiserror::Error;
6
7/// Outcome of cleaning up a multipart upload after a copy failure.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum MultipartAbortStatus {
10    /// The service accepted the abort request.
11    Succeeded,
12    /// The abort request failed; the upload may require later cleanup.
13    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
25/// Result type alias for rc-core operations
26pub type Result<T> = std::result::Result<T, Error>;
27
28/// Error types for rc-core operations
29#[derive(Error, Debug)]
30pub enum Error {
31    /// Configuration file error
32    #[error("Configuration error: {0}")]
33    Config(String),
34
35    /// Invalid path format
36    #[error("Invalid path: {0}")]
37    InvalidPath(String),
38
39    /// Alias not found
40    #[error("Alias not found: {0}")]
41    AliasNotFound(String),
42
43    /// Alias already exists
44    #[error("Alias already exists: {0}")]
45    AliasExists(String),
46
47    /// IO error
48    #[error("IO error: {0}")]
49    Io(#[from] std::io::Error),
50
51    /// TOML parsing error
52    #[error("TOML parse error: {0}")]
53    TomlParse(#[from] toml::de::Error),
54
55    /// TOML serialization error
56    #[error("TOML serialization error: {0}")]
57    TomlSerialize(#[from] toml::ser::Error),
58
59    /// JSON error
60    #[error("JSON error: {0}")]
61    Json(#[from] serde_json::Error),
62
63    /// URL parsing error
64    #[error("Invalid URL: {0}")]
65    InvalidUrl(#[from] url::ParseError),
66
67    /// Authentication error
68    #[error("Authentication failed: {0}")]
69    Auth(String),
70
71    /// Resource not found
72    #[error("Not found: {0}")]
73    NotFound(String),
74
75    /// A requested historical object version does not exist.
76    #[error("Object version '{version_id}' not found: {path}")]
77    VersionNotFound {
78        /// Object path.
79        path: String,
80        /// Requested version identifier.
81        version_id: String,
82    },
83
84    /// A read or metadata request selected a delete marker instead of object data.
85    #[error("Object version '{version_id}' is a delete marker: {path}")]
86    DeleteMarker {
87        /// Object path.
88        path: String,
89        /// Delete marker version identifier.
90        version_id: String,
91    },
92
93    /// Object Lock governance retention rejected a delete request.
94    #[error("Governance retention denied deletion: {path} (version: {version_id:?})")]
95    GovernanceDenied {
96        /// Object path.
97        path: String,
98        /// Requested version identifier, when one was supplied.
99        version_id: Option<String>,
100    },
101
102    /// Network error (retryable)
103    #[error("Network error: {0}")]
104    Network(String),
105
106    /// Conflict error
107    #[error("Conflict: {0}")]
108    Conflict(String),
109
110    /// Feature not supported by backend
111    #[error("Unsupported feature: {0}")]
112    UnsupportedFeature(String),
113
114    /// General error
115    #[error("{0}")]
116    General(String),
117
118    /// Request rejected locally before any network operation was attempted
119    #[error("{0}")]
120    RequestRejected(String),
121
122    /// Operation stopped through an explicit cooperative cancellation request.
123    #[error("Operation interrupted: {0}")]
124    Interrupted(String),
125}
126
127impl Error {
128    /// Get the appropriate exit code for this error
129    pub const fn exit_code(&self) -> i32 {
130        match self {
131            Error::InvalidPath(_) => 2, // UsageError
132            Error::Config(_) => 2,      // UsageError
133            Error::Network(_) => 3,     // NetworkError
134            Error::Auth(_) => 4,        // AuthError
135            Error::NotFound(_)
136            | Error::VersionNotFound { .. }
137            | Error::DeleteMarker { .. }
138            | Error::AliasNotFound(_) => 5, // NotFound
139            Error::Conflict(_) | Error::GovernanceDenied { .. } | Error::AliasExists(_) => 6, // Conflict
140            Error::UnsupportedFeature(_) => 7, // UnsupportedFeature
141            Error::Interrupted(_) => 130,      // Interrupted
142            Error::RequestRejected(_) => 1,    // GeneralError
143            _ => 1,                            // GeneralError
144        }
145    }
146
147    /// Add multipart cleanup diagnostics without changing the primary error class.
148    pub fn with_multipart_copy_context(
149        self,
150        upload_id: &str,
151        abort_status: MultipartAbortStatus,
152    ) -> Self {
153        // Upload IDs originate at the service boundary. Escaping and bounding
154        // them prevents terminal control sequences or oversized diagnostics.
155        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}