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
26pub const ERROR_CONTRACT: &[ErrorContract] = &[
27    ErrorContract {
28        code: "invalid_argument",
29        exit_code: 2,
30        description: "invalid arguments",
31    },
32    ErrorContract {
33        code: "invalid_input",
34        exit_code: 65,
35        description: "invalid input data",
36    },
37    ErrorContract {
38        code: "not_found",
39        exit_code: 66,
40        description: "missing explicit file or unknown ID",
41    },
42    ErrorContract {
43        code: "ambiguous_id",
44        exit_code: 65,
45        description: "invalid input data including ambiguous ID",
46    },
47    ErrorContract {
48        code: "io_error",
49        exit_code: 74,
50        description: "I/O error",
51    },
52    ErrorContract {
53        code: "permission_denied",
54        exit_code: 77,
55        description: "permission denied",
56    },
57    ErrorContract {
58        code: "lock_timeout",
59        exit_code: 75,
60        description: "lock timeout; retryable",
61    },
62    ErrorContract {
63        code: "config_error",
64        exit_code: 78,
65        description: "configuration error",
66    },
67    ErrorContract {
68        code: "internal",
69        exit_code: 70,
70        description: "internal error",
71    },
72];
73
74pub fn exit_code_for(code: &str) -> i32 {
75    ERROR_CONTRACT
76        .iter()
77        .find(|entry| entry.code == code)
78        .map_or(70, |entry| entry.exit_code)
79}
80
81pub fn error_codes() -> Vec<&'static str> {
82    ERROR_CONTRACT.iter().map(|entry| entry.code).collect()
83}
84
85pub fn exit_code_map() -> BTreeMap<i32, &'static str> {
86    let mut map = BTreeMap::new();
87    map.insert(0, "success or empty result");
88    for entry in ERROR_CONTRACT {
89        map.insert(entry.exit_code, entry.description);
90    }
91    map.insert(
92        1,
93        "command findings: doctor unhealthy, triage clusters, verify recurrences, or retrospect candidates",
94    );
95    map
96}
97
98impl AppError {
99    pub fn invalid_argument(message: impl Into<String>, fix: impl Into<String>) -> Self {
100        Self::new("invalid_argument", message, false, fix)
101    }
102
103    pub fn invalid_input(message: impl Into<String>, fix: impl Into<String>) -> Self {
104        Self::new("invalid_input", message, false, fix)
105    }
106
107    pub fn not_found(message: impl Into<String>, fix: impl Into<String>) -> Self {
108        Self::new("not_found", message, false, fix)
109    }
110
111    pub fn ambiguous_id(prefix: &str, candidates: Vec<String>) -> Self {
112        let mut error = Self::new(
113            "ambiguous_id",
114            format!("ID prefix '{prefix}' matches multiple cuts"),
115            false,
116            "Use one of the full IDs listed in error.details.candidates.",
117        );
118        error.details = json!({ "candidates": candidates });
119        error
120    }
121
122    pub fn config(message: impl Into<String>, fix: impl Into<String>) -> Self {
123        Self::new("config_error", message, false, fix)
124    }
125
126    pub fn lock_timeout(path: &std::path::Path) -> Self {
127        Self::new(
128            "lock_timeout",
129            format!(
130                "timed out waiting for the blotter file lock: {}",
131                path.display()
132            ),
133            true,
134            "Retry the same command after the other blotter process finishes.",
135        )
136    }
137
138    pub fn internal(message: impl Into<String>) -> Self {
139        Self::new(
140            "internal",
141            message,
142            false,
143            "Run `blotter doctor`; if the problem persists, report the command and blotter version.",
144        )
145    }
146
147    pub fn from_io(error: std::io::Error, path: &std::path::Path) -> Self {
148        match error.kind() {
149            std::io::ErrorKind::PermissionDenied => Self::new(
150                "permission_denied",
151                format!("permission denied for {}: {error}", path.display()),
152                false,
153                "Choose a writable path with --file or correct the file permissions.",
154            ),
155            _ => Self::new(
156                "io_error",
157                format!("I/O error for {}: {error}", path.display()),
158                false,
159                "Check that the path exists and its filesystem is available, then retry.",
160            ),
161        }
162    }
163
164    /// Error mapping for opening an existing blotter log file. This is the
165    /// only place where `NotFound` is mapped to `not_found` / 66.
166    pub fn from_log_open(error: std::io::Error, path: &std::path::Path) -> Self {
167        if error.kind() == std::io::ErrorKind::NotFound {
168            Self::new(
169                "not_found",
170                format!("blotter file not found: {}", path.display()),
171                false,
172                "Run `blotter add` to create the file or pass an existing --file PATH.",
173            )
174        } else {
175            Self::from_io(error, path)
176        }
177    }
178
179    pub fn from_evidence_file(error: std::io::Error, path: &std::path::Path) -> Self {
180        match error.kind() {
181            std::io::ErrorKind::NotFound => Self::new(
182                "not_found",
183                format!("stderr evidence file not found: {}", path.display()),
184                false,
185                "Pass an existing regular UTF-8 file to --stderr-file PATH.",
186            ),
187            std::io::ErrorKind::PermissionDenied => Self::new(
188                "permission_denied",
189                format!(
190                    "permission denied reading stderr evidence file {}: {error}",
191                    path.display()
192                ),
193                false,
194                "Grant read permission to the stderr evidence file or pass a readable --stderr-file PATH.",
195            ),
196            _ => Self::new(
197                "io_error",
198                format!(
199                    "I/O error reading stderr evidence file {}: {error}",
200                    path.display()
201                ),
202                false,
203                "Check that --stderr-file PATH is a readable regular file, then retry.",
204            ),
205        }
206    }
207
208    fn new(
209        code: &'static str,
210        message: impl Into<String>,
211        retryable: bool,
212        suggested_fix: impl Into<String>,
213    ) -> Self {
214        Self {
215            code,
216            message: message.into(),
217            details: json!({}),
218            retryable,
219            suggested_fix: suggested_fix.into(),
220            exit_code: exit_code_for(code),
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::io::ErrorKind;
229
230    #[test]
231    fn io_not_found_maps_to_io_error_74() {
232        let error = std::io::Error::new(ErrorKind::NotFound, "missing");
233        let err = AppError::from_io(error, std::path::Path::new("/tmp/x"));
234        assert_eq!(err.code, "io_error");
235        assert_eq!(err.exit_code, 74);
236    }
237
238    #[test]
239    fn log_open_not_found_maps_to_not_found_66() {
240        let error = std::io::Error::new(ErrorKind::NotFound, "missing");
241        let err = AppError::from_log_open(error, std::path::Path::new("/tmp/x"));
242        assert_eq!(err.code, "not_found");
243        assert_eq!(err.exit_code, 66);
244    }
245}