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        };
190
191        Self {
192            title,
193            details,
194            suggestions,
195            technical,
196        }
197    }
198
199    /// Create a simple error info
200    pub fn simple(title: impl Into<String>, details: impl Into<String>) -> Self {
201        Self {
202            title: title.into(),
203            details: details.into(),
204            suggestions: vec![],
205            technical: None,
206        }
207    }
208
209    /// Add a suggestion
210    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
211        self.suggestions.push(suggestion.into());
212        self
213    }
214}
215
216/// Widget for rendering an error dialog
217pub struct ErrorDialog<'a> {
218    error: &'a ErrorInfo,
219    show_technical: bool,
220}
221
222impl<'a> ErrorDialog<'a> {
223    /// Create a new error dialog widget
224    pub fn new(error: &'a ErrorInfo) -> Self {
225        Self {
226            error,
227            show_technical: false,
228        }
229    }
230
231    /// Show technical details
232    pub fn with_technical(mut self, show: bool) -> Self {
233        self.show_technical = show;
234        self
235    }
236}
237
238impl<'a> Widget for ErrorDialog<'a> {
239    fn render(self, area: Rect, buf: &mut Buffer) {
240        // Clear the area first
241        Clear.render(area, buf);
242
243        let block = Block::default()
244            .borders(Borders::ALL)
245            .border_style(Style::default().fg(Color::Red))
246            .title(format!(" Error: {} ", self.error.title))
247            .title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));
248
249        let inner = block.inner(area);
250        block.render(area, buf);
251
252        // Calculate layout
253        let chunks = Layout::default()
254            .direction(Direction::Vertical)
255            .margin(1)
256            .constraints([
257                Constraint::Length(3), // Details
258                Constraint::Min(1),    // Suggestions
259                Constraint::Length(1), // Close hint
260            ])
261            .split(inner);
262
263        // Render details
264        let details = Paragraph::new(self.error.details.as_str())
265            .style(Style::default().fg(Color::White))
266            .wrap(Wrap { trim: true });
267        details.render(chunks[0], buf);
268
269        // Render suggestions
270        if !self.error.suggestions.is_empty() {
271            let mut lines: Vec<Line> = vec![Line::from(Span::styled(
272                "Suggestions:",
273                Style::default()
274                    .fg(Color::Yellow)
275                    .add_modifier(Modifier::BOLD),
276            ))];
277
278            for suggestion in &self.error.suggestions {
279                lines.push(Line::from(vec![
280                    Span::raw("  - "),
281                    Span::raw(suggestion.as_str()),
282                ]));
283            }
284
285            let suggestions = Paragraph::new(lines)
286                .style(Style::default().fg(Color::Yellow))
287                .wrap(Wrap { trim: true });
288            suggestions.render(chunks[1], buf);
289        }
290
291        // Render close hint
292        let close_hint = Paragraph::new("Press Esc or Enter to close")
293            .style(Style::default().fg(Color::Yellow))
294            .alignment(Alignment::Center);
295        close_hint.render(chunks[2], buf);
296    }
297}
298
299/// Calculate the area for an error dialog (centered in parent)
300pub fn error_dialog_area(parent: Rect) -> Rect {
301    let width = (parent.width * 70 / 100).clamp(40, 80);
302    let height = (parent.height * 50 / 100).clamp(10, 20);
303
304    let x = parent.x + (parent.width - width) / 2;
305    let y = parent.y + (parent.height - height) / 2;
306
307    Rect::new(x, y, width, height)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_error_info_from_validation_error() {
316        let error = EnvelopeError::Validation("Name cannot be empty".to_string());
317        let info = ErrorInfo::from_error(&error);
318
319        assert_eq!(info.title, "Validation Error");
320        assert!(info.details.contains("Name cannot be empty"));
321    }
322
323    #[test]
324    fn test_error_info_from_not_found() {
325        let error = EnvelopeError::NotFound {
326            entity_type: "Account",
327            identifier: "Checking".to_string(),
328        };
329        let info = ErrorInfo::from_error(&error);
330
331        assert_eq!(info.title, "Account Not Found");
332        assert!(info.details.contains("Checking"));
333    }
334
335    #[test]
336    fn test_simple_error_info() {
337        let info =
338            ErrorInfo::simple("Test Error", "Something went wrong").with_suggestion("Try again");
339
340        assert_eq!(info.title, "Test Error");
341        assert_eq!(info.details, "Something went wrong");
342        assert_eq!(info.suggestions.len(), 1);
343    }
344}