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 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 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}