Skip to main content

blotter/
error.rs

1use serde_json::{Value, json};
2use std::collections::BTreeMap;
3use thiserror::Error;
4
5pub type AppResult<T> = Result<T, AppError>;
6
7#[derive(Debug, Error)]
8#[error("{message}")]
9pub struct AppError {
10    pub code: &'static str,
11    pub message: String,
12    pub details: Value,
13    pub retryable: bool,
14    pub suggested_fix: String,
15    pub exit_code: i32,
16}
17
18/// Single source of truth for every public error code, its exit code, and the
19/// description published in `blotter schema`.
20pub struct ErrorContract {
21    pub code: &'static str,
22    pub exit_code: i32,
23    pub description: &'static str,
24}
25
26/// The published description for exit 65. Three codes map to it, so the string
27/// is authored to name all three rather than left to whichever `ERROR_CONTRACT`
28/// entry `exit_code_map` happens to insert last (r48). Every 65 entry carries
29/// it, so the map cannot drift with the table's order.
30pub const EXIT_65_DESCRIPTION: &str =
31    "invalid input data, including an ambiguous ID or an unsupported log version";
32
33pub const ERROR_CONTRACT: &[ErrorContract] = &[
34    ErrorContract {
35        code: "invalid_argument",
36        exit_code: 2,
37        description: "invalid arguments",
38    },
39    ErrorContract {
40        code: "invalid_input",
41        exit_code: 65,
42        description: EXIT_65_DESCRIPTION,
43    },
44    ErrorContract {
45        code: "not_found",
46        exit_code: 66,
47        description: "missing explicit file or unknown ID",
48    },
49    ErrorContract {
50        code: "ambiguous_id",
51        exit_code: 65,
52        description: EXIT_65_DESCRIPTION,
53    },
54    ErrorContract {
55        code: "unsupported_log_version",
56        exit_code: 65,
57        description: EXIT_65_DESCRIPTION,
58    },
59    ErrorContract {
60        code: "io_error",
61        exit_code: 74,
62        description: "I/O error",
63    },
64    ErrorContract {
65        code: "permission_denied",
66        exit_code: 77,
67        description: "permission denied",
68    },
69    ErrorContract {
70        code: "lock_timeout",
71        exit_code: 75,
72        description: "lock timeout; retryable",
73    },
74    ErrorContract {
75        code: "config_error",
76        exit_code: 78,
77        description: "configuration error",
78    },
79    ErrorContract {
80        code: "internal",
81        exit_code: 70,
82        description: "internal error",
83    },
84];
85
86/// The refusal text, shared by the error envelope and `doctor`'s
87/// `unsupported_version` finding so the two cannot drift. It names the offending
88/// line and what was found there, never the path.
89pub fn unsupported_log_version_message(line: usize, found_version: Option<&Value>) -> String {
90    let found = match found_version {
91        Some(value) => format!("found v {value}"),
92        None => "record has no v field".to_owned(),
93    };
94    format!("unsupported log version on line {line}: {found}")
95}
96
97pub fn exit_code_for(code: &str) -> i32 {
98    ERROR_CONTRACT
99        .iter()
100        .find(|entry| entry.code == code)
101        .map_or(70, |entry| entry.exit_code)
102}
103
104pub fn error_codes() -> Vec<&'static str> {
105    ERROR_CONTRACT.iter().map(|entry| entry.code).collect()
106}
107
108pub fn exit_code_map() -> BTreeMap<i32, &'static str> {
109    let mut map = BTreeMap::new();
110    map.insert(0, "success or empty result");
111    for entry in ERROR_CONTRACT {
112        map.insert(entry.exit_code, entry.description);
113    }
114    map.insert(
115        1,
116        "command findings: doctor unhealthy, triage clusters, verify recurrences, or retrospect candidates",
117    );
118    map
119}
120
121impl AppError {
122    pub fn invalid_argument(message: impl Into<String>, fix: impl Into<String>) -> Self {
123        Self::new("invalid_argument", message, false, fix)
124    }
125
126    pub fn invalid_input(message: impl Into<String>, fix: impl Into<String>) -> Self {
127        Self::new("invalid_input", message, false, fix)
128    }
129
130    pub fn not_found(message: impl Into<String>, fix: impl Into<String>) -> Self {
131        Self::new("not_found", message, false, fix)
132    }
133
134    pub fn ambiguous_id(prefix: &str, candidates: Vec<String>) -> Self {
135        let mut error = Self::new(
136            "ambiguous_id",
137            format!("ID prefix '{prefix}' matches multiple records"),
138            false,
139            "Use one of the full IDs listed in error.details.candidates.",
140        );
141        error.details = json!({ "candidates": candidates });
142        error
143    }
144
145    /// The 0.15 → 1.0.0 upgrade refusal (r48, r49, r50). The message names the
146    /// offending line and what was found there and never the path: `sweep`
147    /// prefixes its warning with the path, and the resolved path is carried in
148    /// `details.file` and in `suggested_fix`. `found_version` is present
149    /// verbatim for any `v` other than the integer 2 — `null` included — and
150    /// omitted only when the key was absent, so absent and wrong are told apart
151    /// by key presence.
152    pub fn unsupported_log_version(
153        path: &std::path::Path,
154        line: usize,
155        found_version: Option<&Value>,
156    ) -> Self {
157        let mut error = Self::new(
158            "unsupported_log_version",
159            unsupported_log_version_message(line, found_version),
160            false,
161            format!(
162                "Rename {} to a path that does not yet exist, then run `blotter add` to create a fresh v2 log.",
163                path.display()
164            ),
165        );
166        error.details = json!({ "file": path.to_string_lossy(), "line": line });
167        if let Some(value) = found_version {
168            error.details["found_version"] = value.clone();
169        }
170        error
171    }
172
173    pub fn config(message: impl Into<String>, fix: impl Into<String>) -> Self {
174        Self::new("config_error", message, false, fix)
175    }
176
177    pub fn lock_timeout(path: &std::path::Path) -> Self {
178        Self::new(
179            "lock_timeout",
180            format!(
181                "timed out waiting for the blotter file lock: {}",
182                path.display()
183            ),
184            true,
185            "Retry the same command after the other blotter process finishes.",
186        )
187    }
188
189    pub fn internal(message: impl Into<String>) -> Self {
190        Self::new(
191            "internal",
192            message,
193            false,
194            "Run `blotter doctor`; if the problem persists, report the command and blotter version.",
195        )
196    }
197
198    pub fn from_io(error: std::io::Error, path: &std::path::Path) -> Self {
199        match error.kind() {
200            std::io::ErrorKind::PermissionDenied => Self::new(
201                "permission_denied",
202                format!("permission denied for {}: {error}", path.display()),
203                false,
204                "Choose a writable path with --file or correct the file permissions.",
205            ),
206            _ => Self::new(
207                "io_error",
208                format!("I/O error for {}: {error}", path.display()),
209                false,
210                "Check that the path exists and its filesystem is available, then retry.",
211            ),
212        }
213    }
214
215    /// Error mapping for opening an existing blotter log file. This is where
216    /// `NotFound` becomes `not_found` / 66, and where a directory rejected by
217    /// an append-mode open is normalized to the same `invalid_input` / 65 that
218    /// the opened-handle regular-file check returns for other object types.
219    pub fn from_log_open(error: std::io::Error, path: &std::path::Path) -> Self {
220        match error.kind() {
221            std::io::ErrorKind::NotFound => Self::new(
222                "not_found",
223                format!("blotter file not found: {}", path.display()),
224                false,
225                "Run `blotter add` to create the file or pass an existing --file PATH.",
226            ),
227            std::io::ErrorKind::IsADirectory => Self::invalid_input(
228                format!("blotter file is not a regular file: {}", path.display()),
229                "Point --file PATH or BLOTTER_FILE at a regular JSONL file; FIFOs and devices are not accepted.",
230            ),
231            _ => Self::from_io(error, path),
232        }
233    }
234
235    /// Error mapping for reading an explicit `sweep --registry` file. The flag
236    /// names a file as explicitly as `--file` does, so a missing path is
237    /// `not_found` / 66 rather than the `io_error` / 74 `from_io` gives. A path
238    /// that exists but can never be read as a registry — a directory, or bytes
239    /// that are not UTF-8 — is `invalid_input` / 65 for the same reason the log
240    /// path is: the input is wrong, not the filesystem.
241    pub fn from_registry_file(error: std::io::Error, path: &std::path::Path) -> Self {
242        match error.kind() {
243            std::io::ErrorKind::NotFound => Self::new(
244                "not_found",
245                format!("sweep registry file not found: {}", path.display()),
246                false,
247                "Pass an existing registry file to --registry PATH.",
248            ),
249            std::io::ErrorKind::InvalidData => Self::invalid_input(
250                format!("sweep registry file is not valid UTF-8: {}", path.display()),
251                "Save the registry as UTF-8 text with one path per line, then retry.",
252            ),
253            std::io::ErrorKind::IsADirectory => Self::invalid_input(
254                format!(
255                    "sweep registry file is not a regular file: {}",
256                    path.display()
257                ),
258                "Pass a UTF-8 text file with one repository path per line to --registry PATH.",
259            ),
260            _ => Self::from_io(error, path),
261        }
262    }
263
264    pub fn from_evidence_file(error: std::io::Error, path: &std::path::Path) -> Self {
265        match error.kind() {
266            std::io::ErrorKind::NotFound => Self::new(
267                "not_found",
268                format!("stderr evidence file not found: {}", path.display()),
269                false,
270                "Pass an existing regular UTF-8 file to --stderr-file PATH.",
271            ),
272            std::io::ErrorKind::PermissionDenied => Self::new(
273                "permission_denied",
274                format!(
275                    "permission denied reading stderr evidence file {}: {error}",
276                    path.display()
277                ),
278                false,
279                "Grant read permission to the stderr evidence file or pass a readable --stderr-file PATH.",
280            ),
281            _ => Self::new(
282                "io_error",
283                format!(
284                    "I/O error reading stderr evidence file {}: {error}",
285                    path.display()
286                ),
287                false,
288                "Check that --stderr-file PATH is a readable regular file, then retry.",
289            ),
290        }
291    }
292
293    /// A leftover repair backup blocks a copy-and-swap. Naming the file keeps
294    /// the retry from reporting the collision instead of the original failure.
295    pub fn stale_backup(path: &std::path::Path) -> Self {
296        Self::new(
297            "io_error",
298            format!("backup path already exists: {}", path.display()),
299            false,
300            format!(
301                "Remove or rename the leftover backup {}; it is from an aborted repair, not a completed one, then retry.",
302                path.display()
303            ),
304        )
305    }
306
307    fn new(
308        code: &'static str,
309        message: impl Into<String>,
310        retryable: bool,
311        suggested_fix: impl Into<String>,
312    ) -> Self {
313        Self {
314            code,
315            message: message.into(),
316            details: json!({}),
317            retryable,
318            suggested_fix: suggested_fix.into(),
319            exit_code: exit_code_for(code),
320        }
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use std::io::ErrorKind;
328
329    #[test]
330    fn io_not_found_maps_to_io_error_74() {
331        let error = std::io::Error::new(ErrorKind::NotFound, "missing");
332        let err = AppError::from_io(error, std::path::Path::new("/tmp/x"));
333        assert_eq!(err.code, "io_error");
334        assert_eq!(err.exit_code, 74);
335    }
336
337    #[test]
338    fn log_open_not_found_maps_to_not_found_66() {
339        let error = std::io::Error::new(ErrorKind::NotFound, "missing");
340        let err = AppError::from_log_open(error, std::path::Path::new("/tmp/x"));
341        assert_eq!(err.code, "not_found");
342        assert_eq!(err.exit_code, 66);
343    }
344
345    #[test]
346    fn log_open_directory_maps_to_invalid_input_65() {
347        let error = std::io::Error::new(ErrorKind::IsADirectory, "is a directory");
348        let err = AppError::from_log_open(error, std::path::Path::new("/tmp/log-dir"));
349        assert_eq!(err.code, "invalid_input");
350        assert_eq!(err.exit_code, 65);
351        assert!(err.message.contains("not a regular file"));
352    }
353
354    #[test]
355    fn registry_not_found_maps_to_not_found_66() {
356        let error = std::io::Error::new(ErrorKind::NotFound, "missing");
357        let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/x"));
358        assert_eq!(err.code, "not_found");
359        assert_eq!(err.exit_code, 66);
360    }
361
362    #[test]
363    fn registry_permission_denied_maps_to_permission_denied_77() {
364        let error = std::io::Error::new(ErrorKind::PermissionDenied, "denied");
365        let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/x"));
366        assert_eq!(err.code, "permission_denied");
367        assert_eq!(err.exit_code, 77);
368    }
369
370    #[test]
371    fn registry_invalid_data_maps_to_invalid_input_65() {
372        let error =
373            std::io::Error::new(ErrorKind::InvalidData, "stream did not contain valid UTF-8");
374        let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/repos.txt"));
375        assert_eq!(err.code, "invalid_input");
376        assert_eq!(err.exit_code, 65);
377        assert!(err.message.contains("not valid UTF-8"));
378    }
379
380    #[test]
381    fn registry_directory_maps_to_invalid_input_65() {
382        let error = std::io::Error::new(ErrorKind::IsADirectory, "is a directory");
383        let err = AppError::from_registry_file(error, std::path::Path::new("/tmp/repos"));
384        assert_eq!(err.code, "invalid_input");
385        assert_eq!(err.exit_code, 65);
386        assert!(err.message.contains("not a regular file"));
387    }
388}