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