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
82impl EnvelopeError {
83 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 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 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 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 pub fn is_not_found(&self) -> bool {
117 matches!(self, Self::NotFound { .. })
118 }
119
120 pub fn is_validation(&self) -> bool {
122 matches!(self, Self::Validation(_))
123 }
124
125 pub fn is_recoverable(&self) -> bool {
127 matches!(
128 self,
129 Self::Io(_) | Self::Storage(_) | Self::Validation(_) | Self::Encryption(_)
130 )
131 }
132
133 pub fn is_fatal(&self) -> bool {
135 matches!(self, Self::Config(_))
136 }
137
138 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 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 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
264pub 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
279impl 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
293pub 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}