envelope_cli/tui/widgets/
error_dialog.rs

1//! Error dialog widget
2//!
3//! Displays detailed error information with recovery suggestions.
4
5use 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/// An error dialog with details and suggestions
16#[derive(Debug, Clone)]
17pub struct ErrorInfo {
18    /// The error title/summary
19    pub title: String,
20    /// Detailed error message
21    pub details: String,
22    /// Suggested recovery actions
23    pub suggestions: Vec<String>,
24    /// Technical details (for advanced users)
25    pub technical: Option<String>,
26}
27
28impl ErrorInfo {
29    /// Create error info from an EnvelopeError
30    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    /// Create a simple error info
209    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    /// Add a suggestion
219    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
220        self.suggestions.push(suggestion.into());
221        self
222    }
223}
224
225/// Widget for rendering an error dialog
226pub struct ErrorDialog<'a> {
227    error: &'a ErrorInfo,
228    show_technical: bool,
229}
230
231impl<'a> ErrorDialog<'a> {
232    /// Create a new error dialog widget
233    pub fn new(error: &'a ErrorInfo) -> Self {
234        Self {
235            error,
236            show_technical: false,
237        }
238    }
239
240    /// Show technical details
241    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 the area first
250        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        // Calculate layout
262        let chunks = Layout::default()
263            .direction(Direction::Vertical)
264            .margin(1)
265            .constraints([
266                Constraint::Length(3), // Details
267                Constraint::Min(1),    // Suggestions
268                Constraint::Length(1), // Close hint
269            ])
270            .split(inner);
271
272        // Render details
273        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        // Render suggestions
279        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        // Render close hint
301        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
308/// Calculate the area for an error dialog (centered in parent)
309pub 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}