1use thiserror::Error;
7
8#[derive(Error, Debug)]
10pub enum EnvelopeError {
11 #[error("Configuration error: {0}")]
13 Config(String),
14
15 #[error("I/O error: {0}")]
17 Io(String),
18
19 #[error("JSON error: {0}")]
21 Json(String),
22
23 #[error("Validation error: {0}")]
25 Validation(String),
26
27 #[error("{entity_type} not found: {identifier}")]
29 NotFound {
30 entity_type: &'static str,
31 identifier: String,
32 },
33
34 #[error("{entity_type} already exists: {identifier}")]
36 Duplicate {
37 entity_type: &'static str,
38 identifier: String,
39 },
40
41 #[error("Budget error: {0}")]
43 Budget(String),
44
45 #[error("Reconciliation error: {0}")]
47 Reconciliation(String),
48
49 #[error("Import error: {0}")]
51 Import(String),
52
53 #[error("Export error: {0}")]
55 Export(String),
56
57 #[error("Encryption error: {0}")]
59 Encryption(String),
60
61 #[error("Transaction is locked: {0}")]
63 Locked(String),
64
65 #[error("Insufficient funds in category '{category}': need {needed}, have {available}")]
67 InsufficientFunds {
68 category: String,
69 needed: i64,
70 available: i64,
71 },
72
73 #[error("Storage error: {0}")]
75 Storage(String),
76
77 #[error("TUI error: {0}")]
79 Tui(String),
80
81 #[error("Income error: {0}")]
83 Income(String),
84}
85
86impl EnvelopeError {
87 pub fn account_not_found(identifier: impl Into<String>) -> Self {
89 Self::NotFound {
90 entity_type: "Account",
91 identifier: identifier.into(),
92 }
93 }
94
95 pub fn category_not_found(identifier: impl Into<String>) -> Self {
97 Self::NotFound {
98 entity_type: "Category",
99 identifier: identifier.into(),
100 }
101 }
102
103 pub fn transaction_not_found(identifier: impl Into<String>) -> Self {
105 Self::NotFound {
106 entity_type: "Transaction",
107 identifier: identifier.into(),
108 }
109 }
110
111 pub fn payee_not_found(identifier: impl Into<String>) -> Self {
113 Self::NotFound {
114 entity_type: "Payee",
115 identifier: identifier.into(),
116 }
117 }
118
119 pub fn is_not_found(&self) -> bool {
121 matches!(self, Self::NotFound { .. })
122 }
123
124 pub fn is_validation(&self) -> bool {
126 matches!(self, Self::Validation(_))
127 }
128
129 pub fn is_recoverable(&self) -> bool {
131 matches!(
132 self,
133 Self::Io(_) | Self::Storage(_) | Self::Validation(_) | Self::Encryption(_)
134 )
135 }
136
137 pub fn is_fatal(&self) -> bool {
139 matches!(self, Self::Config(_))
140 }
141
142 pub fn user_message(&self) -> String {
144 match self {
145 Self::Config(msg) => format!("Configuration problem: {}", msg),
146 Self::Io(msg) => format!("Could not access file: {}", msg),
147 Self::Json(msg) => format!("Data file is corrupted: {}", msg),
148 Self::Validation(msg) => msg.clone(),
149 Self::NotFound {
150 entity_type,
151 identifier,
152 } => {
153 format!("{} '{}' was not found", entity_type, identifier)
154 }
155 Self::Duplicate {
156 entity_type,
157 identifier,
158 } => {
159 format!("{} '{}' already exists", entity_type, identifier)
160 }
161 Self::Budget(msg) => msg.clone(),
162 Self::Reconciliation(msg) => msg.clone(),
163 Self::Import(msg) => format!("Import failed: {}", msg),
164 Self::Export(msg) => format!("Export failed: {}", msg),
165 Self::Encryption(msg) => format!("Encryption error: {}", msg),
166 Self::Locked(msg) => format!("Cannot modify locked transaction: {}", msg),
167 Self::InsufficientFunds {
168 category,
169 needed,
170 available,
171 } => {
172 format!(
173 "'{}' doesn't have enough funds (need ${:.2}, have ${:.2})",
174 category,
175 *needed as f64 / 100.0,
176 *available as f64 / 100.0
177 )
178 }
179 Self::Storage(msg) => format!("Storage error: {}", msg),
180 Self::Tui(msg) => format!("Display error: {}", msg),
181 Self::Income(msg) => msg.clone(),
182 }
183 }
184
185 pub fn recovery_suggestions(&self) -> Vec<&'static str> {
187 match self {
188 Self::Config(_) => vec![
189 "Check ~/.config/envelope-cli/config.json for syntax errors",
190 "Run 'envelope init' to reset configuration",
191 ],
192 Self::Io(_) => vec![
193 "Check file permissions",
194 "Ensure the disk has free space",
195 "Try closing other programs that might be using the files",
196 ],
197 Self::Json(_) => vec![
198 "The data file may be corrupted",
199 "Restore from backup: 'envelope backup restore'",
200 ],
201 Self::Validation(_) => vec!["Check your input and try again"],
202 Self::NotFound { entity_type, .. } => match *entity_type {
203 "Account" => vec!["Run 'envelope account list' to see available accounts"],
204 "Category" => vec!["Run 'envelope category list' to see available categories"],
205 "Transaction" => vec!["Check the transaction ID and try again"],
206 _ => vec!["Check that the item exists"],
207 },
208 Self::Duplicate { .. } => {
209 vec!["Use a different name", "Edit the existing item instead"]
210 }
211 Self::Budget(_) => vec![
212 "Check your budget allocations",
213 "Review 'Available to Budget'",
214 ],
215 Self::Reconciliation(_) => vec![
216 "Review the reconciliation difference",
217 "Check for missing transactions",
218 ],
219 Self::Import(_) => vec![
220 "Check the CSV file format",
221 "Ensure column mapping is correct",
222 ],
223 Self::Export(_) => vec![
224 "Check write permissions to the output path",
225 "Ensure there is enough disk space",
226 ],
227 Self::Encryption(_) => vec![
228 "Verify your passphrase",
229 "Note: There is no password recovery",
230 ],
231 Self::Locked(_) => vec![
232 "Use 'envelope transaction unlock' to edit",
233 "This will require confirmation",
234 ],
235 Self::InsufficientFunds { .. } => vec![
236 "Move funds from another category",
237 "Assign more funds to this category",
238 ],
239 Self::Storage(_) => vec![
240 "Check the data directory is accessible",
241 "Try with elevated permissions",
242 ],
243 Self::Tui(_) => vec!["Try resizing your terminal", "Use CLI commands instead"],
244 Self::Income(_) => vec![
245 "Check the expected income amount is positive",
246 "Run 'envelope income show' to see current income expectations",
247 ],
248 }
249 }
250
251 pub fn exit_code(&self) -> i32 {
253 match self {
254 Self::Config(_) => 1,
255 Self::Io(_) => 2,
256 Self::Json(_) => 3,
257 Self::Validation(_) => 4,
258 Self::NotFound { .. } => 5,
259 Self::Duplicate { .. } => 6,
260 Self::Budget(_) => 7,
261 Self::Reconciliation(_) => 8,
262 Self::Import(_) => 9,
263 Self::Export(_) => 10,
264 Self::Encryption(_) => 11,
265 Self::Locked(_) => 12,
266 Self::InsufficientFunds { .. } => 13,
267 Self::Storage(_) => 14,
268 Self::Tui(_) => 15,
269 Self::Income(_) => 16,
270 }
271 }
272}
273
274pub fn format_cli_error(error: &EnvelopeError) -> String {
276 let mut output = format!("Error: {}\n", error.user_message());
277
278 let suggestions = error.recovery_suggestions();
279 if !suggestions.is_empty() {
280 output.push_str("\nSuggestions:\n");
281 for suggestion in suggestions {
282 output.push_str(&format!(" - {}\n", suggestion));
283 }
284 }
285
286 output
287}
288
289impl From<std::io::Error> for EnvelopeError {
292 fn from(err: std::io::Error) -> Self {
293 Self::Io(err.to_string())
294 }
295}
296
297impl From<serde_json::Error> for EnvelopeError {
298 fn from(err: serde_json::Error) -> Self {
299 Self::Json(err.to_string())
300 }
301}
302
303pub type EnvelopeResult<T> = Result<T, EnvelopeError>;
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn test_error_display() {
312 let err = EnvelopeError::Config("test error".into());
313 assert_eq!(err.to_string(), "Configuration error: test error");
314 }
315
316 #[test]
317 fn test_not_found_error() {
318 let err = EnvelopeError::account_not_found("Checking");
319 assert_eq!(err.to_string(), "Account not found: Checking");
320 assert!(err.is_not_found());
321 }
322
323 #[test]
324 fn test_insufficient_funds_error() {
325 let err = EnvelopeError::InsufficientFunds {
326 category: "Groceries".into(),
327 needed: 5000,
328 available: 3000,
329 };
330 assert_eq!(
331 err.to_string(),
332 "Insufficient funds in category 'Groceries': need 5000, have 3000"
333 );
334 }
335
336 #[test]
337 fn test_from_io_error() {
338 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
339 let envelope_err: EnvelopeError = io_err.into();
340 assert!(matches!(envelope_err, EnvelopeError::Io(_)));
341 }
342}