1use ratatui::{
6 buffer::Buffer,
7 layout::{Alignment, Constraint, Direction, Layout, Rect},
8 style::{Color, Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap},
11};
12
13use crate::error::EnvelopeError;
14
15#[derive(Debug, Clone)]
17pub struct ErrorInfo {
18 pub title: String,
20 pub details: String,
22 pub suggestions: Vec<String>,
24 pub technical: Option<String>,
26}
27
28impl ErrorInfo {
29 pub fn from_error(error: &EnvelopeError) -> Self {
31 let (title, details, suggestions, technical) = match error {
32 EnvelopeError::Config(msg) => (
33 "Configuration Error".to_string(),
34 msg.clone(),
35 vec![
36 "Check your settings file for syntax errors".to_string(),
37 "Try running 'envelope init' to reset configuration".to_string(),
38 ],
39 None,
40 ),
41 EnvelopeError::Io(msg) => (
42 "I/O Error".to_string(),
43 msg.clone(),
44 vec![
45 "Check that you have write permissions to the data directory".to_string(),
46 "Ensure there is enough disk space".to_string(),
47 "Check if the file is locked by another process".to_string(),
48 ],
49 None,
50 ),
51 EnvelopeError::Json(msg) => (
52 "Data File Error".to_string(),
53 format!("Failed to read or write data: {}", msg),
54 vec![
55 "The data file may be corrupted".to_string(),
56 "Try restoring from a backup with 'envelope backup restore'".to_string(),
57 ],
58 Some(msg.clone()),
59 ),
60 EnvelopeError::Validation(msg) => (
61 "Validation Error".to_string(),
62 msg.clone(),
63 vec!["Review the input values and try again".to_string()],
64 None,
65 ),
66 EnvelopeError::NotFound {
67 entity_type,
68 identifier,
69 } => (
70 format!("{} Not Found", entity_type),
71 format!(
72 "Could not find {} with identifier '{}'",
73 entity_type.to_lowercase(),
74 identifier
75 ),
76 vec![
77 format!("Check that the {} exists", entity_type.to_lowercase()),
78 format!(
79 "Use 'envelope {} list' to see available {}s",
80 entity_type.to_lowercase(),
81 entity_type.to_lowercase()
82 ),
83 ],
84 None,
85 ),
86 EnvelopeError::Duplicate {
87 entity_type,
88 identifier,
89 } => (
90 format!("Duplicate {}", entity_type),
91 format!("{} '{}' already exists", entity_type, identifier),
92 vec![
93 "Use a different name".to_string(),
94 format!("Edit the existing {} instead", entity_type.to_lowercase()),
95 ],
96 None,
97 ),
98 EnvelopeError::Budget(msg) => (
99 "Budget Error".to_string(),
100 msg.clone(),
101 vec![
102 "Review your budget allocations".to_string(),
103 "Check the 'Available to Budget' amount".to_string(),
104 ],
105 None,
106 ),
107 EnvelopeError::Reconciliation(msg) => (
108 "Reconciliation Error".to_string(),
109 msg.clone(),
110 vec![
111 "Review the reconciliation difference".to_string(),
112 "Check for missing or duplicate transactions".to_string(),
113 ],
114 None,
115 ),
116 EnvelopeError::Import(msg) => (
117 "Import Error".to_string(),
118 msg.clone(),
119 vec![
120 "Check the CSV file format".to_string(),
121 "Ensure the column mapping is correct".to_string(),
122 "Try importing with a different preset".to_string(),
123 ],
124 None,
125 ),
126 EnvelopeError::Export(msg) => (
127 "Export Error".to_string(),
128 msg.clone(),
129 vec![
130 "Check that you have write permissions to the output path".to_string(),
131 "Ensure there is enough disk space".to_string(),
132 ],
133 None,
134 ),
135 EnvelopeError::Encryption(msg) => (
136 "Encryption Error".to_string(),
137 msg.clone(),
138 vec![
139 "Check that you entered the correct passphrase".to_string(),
140 "If you forgot your passphrase, data cannot be recovered".to_string(),
141 ],
142 None,
143 ),
144 EnvelopeError::Locked(msg) => (
145 "Transaction Locked".to_string(),
146 msg.clone(),
147 vec![
148 "Reconciled transactions cannot be edited".to_string(),
149 "Unlock the transaction first with 'envelope transaction unlock'".to_string(),
150 ],
151 None,
152 ),
153 EnvelopeError::InsufficientFunds {
154 category,
155 needed,
156 available,
157 } => (
158 "Insufficient Funds".to_string(),
159 format!(
160 "Category '{}' has insufficient funds: need ${:.2}, have ${:.2}",
161 category,
162 *needed as f64 / 100.0,
163 *available as f64 / 100.0
164 ),
165 vec![
166 "Move funds from another category".to_string(),
167 "Assign more funds to this category".to_string(),
168 ],
169 None,
170 ),
171 EnvelopeError::Storage(msg) => (
172 "Storage Error".to_string(),
173 msg.clone(),
174 vec![
175 "Check that the data directory is accessible".to_string(),
176 "Try running with elevated permissions".to_string(),
177 ],
178 Some(msg.clone()),
179 ),
180 EnvelopeError::Tui(msg) => (
181 "Interface Error".to_string(),
182 msg.clone(),
183 vec![
184 "Try resizing your terminal window".to_string(),
185 "Use the CLI commands instead".to_string(),
186 ],
187 None,
188 ),
189 EnvelopeError::Income(msg) => (
190 "Income Error".to_string(),
191 msg.clone(),
192 vec![
193 "Check the expected income amount is positive".to_string(),
194 "Run 'envelope income show' to see current income expectations".to_string(),
195 ],
196 None,
197 ),
198 };
199
200 Self {
201 title,
202 details,
203 suggestions,
204 technical,
205 }
206 }
207
208 pub fn simple(title: impl Into<String>, details: impl Into<String>) -> Self {
210 Self {
211 title: title.into(),
212 details: details.into(),
213 suggestions: vec![],
214 technical: None,
215 }
216 }
217
218 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
220 self.suggestions.push(suggestion.into());
221 self
222 }
223}
224
225pub struct ErrorDialog<'a> {
227 error: &'a ErrorInfo,
228 show_technical: bool,
229}
230
231impl<'a> ErrorDialog<'a> {
232 pub fn new(error: &'a ErrorInfo) -> Self {
234 Self {
235 error,
236 show_technical: false,
237 }
238 }
239
240 pub fn with_technical(mut self, show: bool) -> Self {
242 self.show_technical = show;
243 self
244 }
245}
246
247impl<'a> Widget for ErrorDialog<'a> {
248 fn render(self, area: Rect, buf: &mut Buffer) {
249 Clear.render(area, buf);
251
252 let block = Block::default()
253 .borders(Borders::ALL)
254 .border_style(Style::default().fg(Color::Red))
255 .title(format!(" Error: {} ", self.error.title))
256 .title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));
257
258 let inner = block.inner(area);
259 block.render(area, buf);
260
261 let chunks = Layout::default()
263 .direction(Direction::Vertical)
264 .margin(1)
265 .constraints([
266 Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
270 .split(inner);
271
272 let details = Paragraph::new(self.error.details.as_str())
274 .style(Style::default().fg(Color::White))
275 .wrap(Wrap { trim: true });
276 details.render(chunks[0], buf);
277
278 if !self.error.suggestions.is_empty() {
280 let mut lines: Vec<Line> = vec![Line::from(Span::styled(
281 "Suggestions:",
282 Style::default()
283 .fg(Color::Yellow)
284 .add_modifier(Modifier::BOLD),
285 ))];
286
287 for suggestion in &self.error.suggestions {
288 lines.push(Line::from(vec![
289 Span::raw(" - "),
290 Span::raw(suggestion.as_str()),
291 ]));
292 }
293
294 let suggestions = Paragraph::new(lines)
295 .style(Style::default().fg(Color::Yellow))
296 .wrap(Wrap { trim: true });
297 suggestions.render(chunks[1], buf);
298 }
299
300 let close_hint = Paragraph::new("Press Esc or Enter to close")
302 .style(Style::default().fg(Color::Yellow))
303 .alignment(Alignment::Center);
304 close_hint.render(chunks[2], buf);
305 }
306}
307
308pub fn error_dialog_area(parent: Rect) -> Rect {
310 let width = (parent.width * 70 / 100).clamp(40, 80);
311 let height = (parent.height * 50 / 100).clamp(10, 20);
312
313 let x = parent.x + (parent.width - width) / 2;
314 let y = parent.y + (parent.height - height) / 2;
315
316 Rect::new(x, y, width, height)
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn test_error_info_from_validation_error() {
325 let error = EnvelopeError::Validation("Name cannot be empty".to_string());
326 let info = ErrorInfo::from_error(&error);
327
328 assert_eq!(info.title, "Validation Error");
329 assert!(info.details.contains("Name cannot be empty"));
330 }
331
332 #[test]
333 fn test_error_info_from_not_found() {
334 let error = EnvelopeError::NotFound {
335 entity_type: "Account",
336 identifier: "Checking".to_string(),
337 };
338 let info = ErrorInfo::from_error(&error);
339
340 assert_eq!(info.title, "Account Not Found");
341 assert!(info.details.contains("Checking"));
342 }
343
344 #[test]
345 fn test_simple_error_info() {
346 let info =
347 ErrorInfo::simple("Test Error", "Something went wrong").with_suggestion("Try again");
348
349 assert_eq!(info.title, "Test Error");
350 assert_eq!(info.details, "Something went wrong");
351 assert_eq!(info.suggestions.len(), 1);
352 }
353}