Skip to main content

rpi_tools/
error.rs

1//! Mirrors `packages/agent/src/harness/types.ts` — the `FileError` /
2//! `ExecutionError` shapes + their stable code enums used by the `ExecutionEnv`
3//! trait. Drops the TS `ok/err/getOrThrow` helpers (Rust has these natively) and
4//! the `CompactionError`/`BranchSummaryError` (those live in `pi-harness`).
5//!
6//! The stable code enums are backend-independent: `OsExecutionEnv` and
7//! `InMemoryExecutionEnv` both classify failures into the same codes so tool
8//! logic and tests can match on them without knowing the backend.
9
10use std::fmt;
11
12/// Backend-independent file-error codes. Mirrors TS `FileErrorCode`.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum FileErrorCode {
15    Aborted,
16    NotFound,
17    PermissionDenied,
18    NotDirectory,
19    IsDirectory,
20    Invalid,
21    NotSupported,
22    Unknown,
23}
24
25impl FileErrorCode {
26    pub fn as_str(self) -> &'static str {
27        match self {
28            FileErrorCode::Aborted => "aborted",
29            FileErrorCode::NotFound => "not_found",
30            FileErrorCode::PermissionDenied => "permission_denied",
31            FileErrorCode::NotDirectory => "not_directory",
32            FileErrorCode::IsDirectory => "is_directory",
33            FileErrorCode::Invalid => "invalid",
34            FileErrorCode::NotSupported => "not_supported",
35            FileErrorCode::Unknown => "unknown",
36        }
37    }
38}
39
40impl fmt::Display for FileErrorCode {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.write_str(self.as_str())
43    }
44}
45
46/// Error returned by `FileSystem` operations. Mirrors TS `FileError`.
47///
48/// `path` is the absolute addressed path associated with the failure, when
49/// available. `source` carries the underlying backend error (e.g. an
50/// `io::Error`) for diagnostics; it is `Box<dyn Error + Send + Sync>` so both
51/// `OsExecutionEnv` (`io::Error`) and `InMemoryExecutionEnv` (no source) fit.
52#[derive(Debug)]
53pub struct FileError {
54    pub code: FileErrorCode,
55    pub message: String,
56    pub path: Option<String>,
57    pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
58}
59
60impl FileError {
61    pub fn new(code: FileErrorCode, message: impl Into<String>) -> Self {
62        Self {
63            code,
64            message: message.into(),
65            path: None,
66            source: None,
67        }
68    }
69
70    pub fn with_path(mut self, path: impl Into<String>) -> Self {
71        self.path = Some(path.into());
72        self
73    }
74
75    pub fn with_source(mut self, source: Box<dyn std::error::Error + Send + Sync>) -> Self {
76        self.source = Some(source);
77        self
78    }
79
80    /// Convenience for the common `NotFound` at a path.
81    pub fn not_found(path: impl Into<String>) -> Self {
82        let p: String = path.into();
83        Self::new(FileErrorCode::NotFound, format!("path not found: {p}")).with_path(p)
84    }
85
86    /// Convenience for an abort (cancellation).
87    pub fn aborted() -> Self {
88        Self::new(FileErrorCode::Aborted, "operation aborted")
89    }
90}
91
92impl fmt::Display for FileError {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        match &self.path {
95            Some(p) => write!(f, "{}: {} (path: {})", self.code, self.message, p),
96            None => write!(f, "{}: {}", self.code, self.message),
97        }
98    }
99}
100
101impl std::error::Error for FileError {
102    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
103        self.source
104            .as_ref()
105            .map(|b| b.as_ref() as &(dyn std::error::Error + 'static))
106    }
107}
108
109/// Backend-independent execution-error codes. Mirrors TS `ExecutionErrorCode`.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111pub enum ExecutionErrorCode {
112    Aborted,
113    Timeout,
114    ShellUnavailable,
115    SpawnError,
116    CallbackError,
117    Unknown,
118}
119
120impl ExecutionErrorCode {
121    pub fn as_str(self) -> &'static str {
122        match self {
123            ExecutionErrorCode::Aborted => "aborted",
124            ExecutionErrorCode::Timeout => "timeout",
125            ExecutionErrorCode::ShellUnavailable => "shell_unavailable",
126            ExecutionErrorCode::SpawnError => "spawn_error",
127            ExecutionErrorCode::CallbackError => "callback_error",
128            ExecutionErrorCode::Unknown => "unknown",
129        }
130    }
131}
132
133impl fmt::Display for ExecutionErrorCode {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.write_str(self.as_str())
136    }
137}
138
139/// Error returned by `Shell::exec`. Mirrors TS `ExecutionError`.
140///
141/// `source` is stored as a boxed `String`-ified error (not a `dyn Error`) so
142/// `ExecutionError` is `Clone` — the TS `cause` is only reflected into the
143/// `message` for diagnostic purposes, and cloning lets the bash capture layer
144/// carry an `execution_error` through a `Clone` result unmodified.
145#[derive(Debug, Clone)]
146pub struct ExecutionError {
147    pub code: ExecutionErrorCode,
148    pub message: String,
149    pub source_message: Option<String>,
150}
151
152impl ExecutionError {
153    pub fn new(code: ExecutionErrorCode, message: impl Into<String>) -> Self {
154        Self {
155            code,
156            message: message.into(),
157            source_message: None,
158        }
159    }
160
161    pub fn with_source(mut self, source: Box<dyn std::error::Error + Send + Sync>) -> Self {
162        self.source_message = Some(source.to_string());
163        self
164    }
165
166    pub fn with_source_message(mut self, source: impl Into<String>) -> Self {
167        self.source_message = Some(source.into());
168        self
169    }
170
171    pub fn aborted() -> Self {
172        Self::new(ExecutionErrorCode::Aborted, "command aborted")
173    }
174
175    pub fn timeout(seconds: u64) -> Self {
176        Self::new(
177            ExecutionErrorCode::Timeout,
178            format!("command timed out after {seconds}s"),
179        )
180    }
181}
182
183impl fmt::Display for ExecutionError {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        write!(f, "{}: {}", self.code, self.message)
186    }
187}
188
189impl std::error::Error for ExecutionError {
190    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
191        // `source_message` is a plain String, not a boxed error, so there is no
192        // chainable `source`. The message already incorporates the cause text.
193        None
194    }
195}
196
197/// Classify an `io::Error` (from `tokio::fs` / std) into a `FileError` code.
198/// Mirrors the mapping the TS Node.js env applies when it catches `io::Error`.
199pub fn io_to_file_code(err: &std::io::Error) -> FileErrorCode {
200    use std::io::ErrorKind;
201    match err.kind() {
202        ErrorKind::NotFound => FileErrorCode::NotFound,
203        ErrorKind::PermissionDenied => FileErrorCode::PermissionDenied,
204        ErrorKind::AlreadyExists => FileErrorCode::Invalid,
205        ErrorKind::TimedOut => FileErrorCode::Aborted,
206        ErrorKind::Unsupported => FileErrorCode::NotSupported,
207        ErrorKind::Interrupted => FileErrorCode::Aborted,
208        _ => match err.raw_os_error() {
209            // POSIX EISDIR / ENOTDIR. These ErrorKind variants stabilized
210            // after the workspace MSRV, so retain the classification through
211            // their portable Unix errno values.
212            #[cfg(unix)]
213            Some(21) => FileErrorCode::IsDirectory,
214            #[cfg(unix)]
215            Some(20) => FileErrorCode::NotDirectory,
216            // Windows ERROR_DIRECTORY.
217            #[cfg(windows)]
218            Some(267) => FileErrorCode::NotDirectory,
219            _ => FileErrorCode::Unknown,
220        },
221    }
222}
223
224/// Build a `FileError` from an `io::Error`, classifying the code + preserving
225/// the source. `path` is attached when the caller knows it.
226pub fn io_to_file_error(err: std::io::Error, path: Option<&str>) -> FileError {
227    let code = io_to_file_code(&err);
228    let message = err.to_string();
229    let mut e = FileError::new(code, message).with_source(Box::new(err));
230    if let Some(p) = path {
231        e = e.with_path(p);
232    }
233    e
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn file_error_codes_round_trip() {
242        for code in [
243            FileErrorCode::Aborted,
244            FileErrorCode::NotFound,
245            FileErrorCode::PermissionDenied,
246            FileErrorCode::NotDirectory,
247            FileErrorCode::IsDirectory,
248            FileErrorCode::Invalid,
249            FileErrorCode::NotSupported,
250            FileErrorCode::Unknown,
251        ] {
252            assert_eq!(FileErrorCode::as_str(code), code.to_string());
253        }
254    }
255
256    #[test]
257    fn execution_error_codes_round_trip() {
258        for code in [
259            ExecutionErrorCode::Aborted,
260            ExecutionErrorCode::Timeout,
261            ExecutionErrorCode::ShellUnavailable,
262            ExecutionErrorCode::SpawnError,
263            ExecutionErrorCode::CallbackError,
264            ExecutionErrorCode::Unknown,
265        ] {
266            assert_eq!(ExecutionErrorCode::as_str(code), code.to_string());
267        }
268    }
269
270    #[test]
271    fn io_error_classifies_not_found() {
272        let err = std::io::Error::from(std::io::ErrorKind::NotFound);
273        assert_eq!(io_to_file_code(&err), FileErrorCode::NotFound);
274    }
275
276    #[cfg(windows)]
277    #[test]
278    fn io_error_classifies_windows_not_directory() {
279        let err = std::io::Error::from_raw_os_error(267);
280        assert_eq!(io_to_file_code(&err), FileErrorCode::NotDirectory);
281    }
282
283    #[cfg(unix)]
284    #[test]
285    fn io_error_classifies_posix_directory_kinds() {
286        assert_eq!(
287            io_to_file_code(&std::io::Error::from_raw_os_error(21)),
288            FileErrorCode::IsDirectory
289        );
290        assert_eq!(
291            io_to_file_code(&std::io::Error::from_raw_os_error(20)),
292            FileErrorCode::NotDirectory
293        );
294    }
295
296    #[test]
297    fn io_to_file_error_preserves_path_and_source() {
298        let err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
299        let fe = io_to_file_error(err, Some("/x/y"));
300        assert_eq!(fe.code, FileErrorCode::PermissionDenied);
301        assert_eq!(fe.path.as_deref(), Some("/x/y"));
302        assert!(fe.source.is_some());
303    }
304
305    #[test]
306    fn display_formats_path_when_present() {
307        let s = FileError::not_found("/a/b").to_string();
308        assert!(s.contains("not_found"));
309        assert!(s.contains("/a/b"));
310        let s2 = FileError::new(FileErrorCode::Invalid, "bad").to_string();
311        assert!(s2.contains("invalid: bad"));
312        assert!(!s2.contains("path"));
313    }
314}