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
18pub struct ErrorContract {
21 pub code: &'static str,
22 pub exit_code: i32,
23 pub description: &'static str,
24}
25
26pub 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
86pub 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 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 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 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 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}