envelope_cli/
error.rs

1//! Custom error types for EnvelopeCLI
2//!
3//! This module defines the error hierarchy for the application using thiserror
4//! for ergonomic error definitions.
5
6use thiserror::Error;
7
8/// The main error type for EnvelopeCLI operations
9#[derive(Error, Debug)]
10pub enum EnvelopeError {
11    /// Configuration-related errors
12    #[error("Configuration error: {0}")]
13    Config(String),
14
15    /// File I/O errors
16    #[error("I/O error: {0}")]
17    Io(String),
18
19    /// JSON serialization/deserialization errors
20    #[error("JSON error: {0}")]
21    Json(String),
22
23    /// Validation errors for data models
24    #[error("Validation error: {0}")]
25    Validation(String),
26
27    /// Entity not found errors
28    #[error("{entity_type} not found: {identifier}")]
29    NotFound {
30        entity_type: &'static str,
31        identifier: String,
32    },
33
34    /// Duplicate entity errors
35    #[error("{entity_type} already exists: {identifier}")]
36    Duplicate {
37        entity_type: &'static str,
38        identifier: String,
39    },
40
41    /// Budget-related errors
42    #[error("Budget error: {0}")]
43    Budget(String),
44
45    /// Reconciliation errors
46    #[error("Reconciliation error: {0}")]
47    Reconciliation(String),
48
49    /// Import errors
50    #[error("Import error: {0}")]
51    Import(String),
52
53    /// Export errors
54    #[error("Export error: {0}")]
55    Export(String),
56
57    /// Encryption errors
58    #[error("Encryption error: {0}")]
59    Encryption(String),
60
61    /// Transaction is locked (reconciled)
62    #[error("Transaction is locked: {0}")]
63    Locked(String),
64
65    /// Insufficient funds
66    #[error("Insufficient funds in category '{category}': need {needed}, have {available}")]
67    InsufficientFunds {
68        category: String,
69        needed: i64,
70        available: i64,
71    },
72
73    /// Storage errors
74    #[error("Storage error: {0}")]
75    Storage(String),
76
77    /// TUI errors
78    #[error("TUI error: {0}")]
79    Tui(String),
80
81    /// Income expectation errors
82    #[error("Income error: {0}")]
83    Income(String),
84}
85
86impl EnvelopeError {
87    /// Create a "not found" error for accounts
88    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    /// Create a "not found" error for categories
96    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    /// Create a "not found" error for transactions
104    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    /// Create a "not found" error for payees
112    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    /// Check if this is a "not found" error
120    pub fn is_not_found(&self) -> bool {
121        matches!(self, Self::NotFound { .. })
122    }
123
124    /// Check if this is a validation error
125    pub fn is_validation(&self) -> bool {
126        matches!(self, Self::Validation(_))
127    }
128
129    /// Check if this is a recoverable error (can retry)
130    pub fn is_recoverable(&self) -> bool {
131        matches!(
132            self,
133            Self::Io(_) | Self::Storage(_) | Self::Validation(_) | Self::Encryption(_)
134        )
135    }
136
137    /// Check if this is a fatal error (should exit)
138    pub fn is_fatal(&self) -> bool {
139        matches!(self, Self::Config(_))
140    }
141
142    /// Get a user-friendly message for this error
143    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    /// Get recovery suggestions for this error
186    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    /// Get the exit code for this error
252    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
274/// Format an error for CLI output with suggestions
275pub 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
289// Implement From traits for common error types
290
291impl 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
303/// Result type alias for EnvelopeCLI operations
304pub 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}