Skip to main content

sqlmodel_console/renderables/
error.rs

1//! Error panel renderable for beautiful error display.
2//!
3//! Provides a panel specifically designed for displaying errors with rich formatting
4//! in styled mode and structured plain text in plain mode.
5//!
6//! # Example
7//!
8//! ```rust
9//! use sqlmodel_console::renderables::{ErrorPanel, ErrorSeverity};
10//!
11//! let panel = ErrorPanel::new("SQL Syntax Error", "Unexpected token 'SELCT'")
12//!     .severity(ErrorSeverity::Error)
13//!     .with_sql("SELCT * FROM users WHERE id = $1")
14//!     .with_position(1)
15//!     .with_sqlstate("42601")
16//!     .with_hint("Did you mean 'SELECT'?");
17//!
18//! // Plain mode output
19//! println!("{}", panel.render_plain());
20//! ```
21
22use crate::theme::Theme;
23
24/// Error severity level for styling.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ErrorSeverity {
27    /// Critical error - red, urgent
28    Critical,
29    /// Standard error - red
30    Error,
31    /// Warning - yellow
32    Warning,
33    /// Notice - cyan (informational)
34    Notice,
35}
36
37impl ErrorSeverity {
38    /// Get a human-readable severity string.
39    #[must_use]
40    pub fn as_str(&self) -> &'static str {
41        match self {
42            Self::Critical => "CRITICAL",
43            Self::Error => "ERROR",
44            Self::Warning => "WARNING",
45            Self::Notice => "NOTICE",
46        }
47    }
48
49    /// Get the ANSI color code for this severity.
50    #[must_use]
51    pub fn color_code(&self) -> &'static str {
52        match self {
53            Self::Critical => "\x1b[91m", // Bright red
54            Self::Error => "\x1b[31m",    // Red
55            Self::Warning => "\x1b[33m",  // Yellow
56            Self::Notice => "\x1b[36m",   // Cyan
57        }
58    }
59
60    /// Get the ANSI reset code.
61    #[must_use]
62    pub fn reset_code() -> &'static str {
63        "\x1b[0m"
64    }
65}
66
67impl std::fmt::Display for ErrorSeverity {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        write!(f, "{}", self.as_str())
70    }
71}
72
73/// A panel specifically designed for error display.
74///
75/// Provides rich formatting for error messages including SQL context,
76/// position markers, hints, and SQLSTATE codes.
77#[derive(Debug, Clone)]
78pub struct ErrorPanel {
79    /// Error severity for styling
80    severity: ErrorSeverity,
81    /// Panel title (e.g., "SQL Syntax Error")
82    title: String,
83    /// Main error message
84    message: String,
85    /// Optional SQL query that caused the error
86    sql: Option<String>,
87    /// Position in SQL where error occurred (1-indexed)
88    sql_position: Option<usize>,
89    /// SQLSTATE code (PostgreSQL error code)
90    sqlstate: Option<String>,
91    /// Additional detail from database
92    detail: Option<String>,
93    /// Hint for fixing the error
94    hint: Option<String>,
95    /// Additional context lines
96    context: Vec<String>,
97    /// Theme for styled output
98    theme: Option<Theme>,
99    /// Panel width for styled output
100    width: Option<usize>,
101}
102
103impl ErrorPanel {
104    /// Create a new error panel with title and message.
105    ///
106    /// # Example
107    ///
108    /// ```rust
109    /// use sqlmodel_console::renderables::ErrorPanel;
110    ///
111    /// let panel = ErrorPanel::new("Connection Error", "Failed to connect to database");
112    /// ```
113    #[must_use]
114    pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
115        Self {
116            severity: ErrorSeverity::Error,
117            title: title.into(),
118            message: message.into(),
119            sql: None,
120            sql_position: None,
121            sqlstate: None,
122            detail: None,
123            hint: None,
124            context: Vec::new(),
125            theme: None,
126            width: None,
127        }
128    }
129
130    /// Set error severity.
131    #[must_use]
132    pub fn severity(mut self, severity: ErrorSeverity) -> Self {
133        self.severity = severity;
134        self
135    }
136
137    /// Add SQL query context.
138    #[must_use]
139    pub fn with_sql(mut self, sql: impl Into<String>) -> Self {
140        self.sql = Some(sql.into());
141        self
142    }
143
144    /// Add error position in SQL (1-indexed character position).
145    #[must_use]
146    pub fn with_position(mut self, position: usize) -> Self {
147        self.sql_position = Some(position);
148        self
149    }
150
151    /// Add SQLSTATE code.
152    #[must_use]
153    pub fn with_sqlstate(mut self, code: impl Into<String>) -> Self {
154        self.sqlstate = Some(code.into());
155        self
156    }
157
158    /// Add detail message.
159    #[must_use]
160    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
161        self.detail = Some(detail.into());
162        self
163    }
164
165    /// Add hint for fixing the error.
166    #[must_use]
167    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
168        self.hint = Some(hint.into());
169        self
170    }
171
172    /// Add context line.
173    #[must_use]
174    pub fn add_context(mut self, line: impl Into<String>) -> Self {
175        self.context.push(line.into());
176        self
177    }
178
179    /// Set the theme for styled output.
180    #[must_use]
181    pub fn theme(mut self, theme: Theme) -> Self {
182        self.theme = Some(theme);
183        self
184    }
185
186    /// Set the panel width for styled output.
187    #[must_use]
188    pub fn width(mut self, width: usize) -> Self {
189        self.width = Some(width);
190        self
191    }
192
193    /// Get the severity level.
194    #[must_use]
195    pub fn get_severity(&self) -> ErrorSeverity {
196        self.severity
197    }
198
199    /// Get the title.
200    #[must_use]
201    pub fn get_title(&self) -> &str {
202        &self.title
203    }
204
205    /// Get the message.
206    #[must_use]
207    pub fn get_message(&self) -> &str {
208        &self.message
209    }
210
211    /// Get the SQL query if set.
212    #[must_use]
213    pub fn get_sql(&self) -> Option<&str> {
214        self.sql.as_deref()
215    }
216
217    /// Get the SQL position if set.
218    #[must_use]
219    pub fn get_position(&self) -> Option<usize> {
220        self.sql_position
221    }
222
223    /// Get the SQLSTATE code if set.
224    #[must_use]
225    pub fn get_sqlstate(&self) -> Option<&str> {
226        self.sqlstate.as_deref()
227    }
228
229    /// Get the detail message if set.
230    #[must_use]
231    pub fn get_detail(&self) -> Option<&str> {
232        self.detail.as_deref()
233    }
234
235    /// Get the hint if set.
236    #[must_use]
237    pub fn get_hint(&self) -> Option<&str> {
238        self.hint.as_deref()
239    }
240
241    /// Get the context lines.
242    #[must_use]
243    pub fn get_context(&self) -> &[String] {
244        &self.context
245    }
246
247    /// Render as plain text.
248    ///
249    /// Returns a structured plain text representation suitable for
250    /// non-TTY environments or agent consumption.
251    #[must_use]
252    pub fn render_plain(&self) -> String {
253        let mut lines = Vec::new();
254
255        // Header with severity and title
256        lines.push(format!("=== {} [{}] ===", self.title, self.severity));
257        lines.push(String::new());
258        lines.push(self.message.clone());
259
260        // SQL context with position marker
261        if let Some(ref sql) = self.sql {
262            lines.push(String::new());
263            lines.push("Query:".to_string());
264            lines.push(format!("  {sql}"));
265
266            if let Some(pos) = self.sql_position {
267                let marker_pos = pos.saturating_sub(1);
268                lines.push(format!("  {}^", " ".repeat(marker_pos)));
269            }
270        }
271
272        // Detail
273        if let Some(ref detail) = self.detail {
274            lines.push(String::new());
275            lines.push(format!("Detail: {detail}"));
276        }
277
278        // Hint
279        if let Some(ref hint) = self.hint {
280            lines.push(String::new());
281            lines.push(format!("Hint: {hint}"));
282        }
283
284        // SQLSTATE
285        if let Some(ref code) = self.sqlstate {
286            lines.push(String::new());
287            lines.push(format!("SQLSTATE: {code}"));
288        }
289
290        // Context lines
291        for line in &self.context {
292            lines.push(line.clone());
293        }
294
295        lines.join("\n")
296    }
297
298    /// Render as styled text with ANSI colors and box drawing.
299    ///
300    /// Returns a rich panel representation with colored borders
301    /// and formatted content.
302    #[must_use]
303    pub fn render_styled(&self) -> String {
304        let theme = self.theme.clone().unwrap_or_default();
305        let width = self.width.unwrap_or(70);
306        let inner_width = width - 4; // Account for borders and padding
307
308        let color = self.severity.color_code();
309        let reset = ErrorSeverity::reset_code();
310        let dim = "\x1b[2m";
311
312        let mut lines = Vec::new();
313
314        // Top border with title
315        let title = format!(" {} ", self.title);
316        let title_len = title.chars().count();
317        let left_pad = (width - 2 - title_len) / 2;
318        let right_pad = width - 2 - title_len - left_pad;
319        let top_border = format!(
320            "{color}╭{}{}{}╮{reset}",
321            "─".repeat(left_pad),
322            title,
323            "─".repeat(right_pad)
324        );
325        lines.push(top_border);
326
327        // Empty line
328        lines.push(format!(
329            "{color}│{reset}{:width$}{color}│{reset}",
330            "",
331            width = width - 2
332        ));
333
334        // Severity badge
335        let severity_line = format!(
336            "  {}{}{} {}",
337            color,
338            self.severity.as_str(),
339            reset,
340            &self.message
341        );
342        lines.push(self.wrap_line(&severity_line, width, color, reset));
343
344        // Empty line after message
345        lines.push(format!(
346            "{color}│{reset}{:width$}{color}│{reset}",
347            "",
348            width = width - 2
349        ));
350
351        // SQL context with position marker
352        if let Some(ref sql) = self.sql {
353            // SQL box header
354            let sql_header = format!("{dim}┌─ Query ─{}┐{reset}", "─".repeat(inner_width - 12));
355            lines.push(format!("{color}│{reset} {sql_header} {color}│{reset}"));
356
357            // SQL content (may need truncation for very long queries)
358            let sql_display = if sql.len() > inner_width - 4 {
359                format!("{}...", &sql[..inner_width - 7])
360            } else {
361                sql.clone()
362            };
363            lines.push(format!(
364                "{color}│{reset} {dim}│{reset} {:<width$} {dim}│{reset} {color}│{reset}",
365                sql_display,
366                width = inner_width - 4
367            ));
368
369            // Position marker
370            if let Some(pos) = self.sql_position {
371                let marker_pos = pos.saturating_sub(1).min(inner_width - 5);
372                let marker_line = format!("{}^", " ".repeat(marker_pos));
373                lines.push(format!(
374                    "{color}│{reset} {dim}│{reset} {}{:<width$}{reset} {dim}│{reset} {color}│{reset}",
375                    theme.error.color_code(),
376                    marker_line,
377                    width = inner_width - 4
378                ));
379            }
380
381            // SQL box footer
382            let sql_footer = format!("{dim}└{}┘{reset}", "─".repeat(inner_width - 2));
383            lines.push(format!("{color}│{reset} {sql_footer} {color}│{reset}"));
384
385            // Empty line after SQL
386            lines.push(format!(
387                "{color}│{reset}{:width$}{color}│{reset}",
388                "",
389                width = width - 2
390            ));
391        }
392
393        // Detail
394        if let Some(ref detail) = self.detail {
395            let detail_line = format!("  Detail: {detail}");
396            lines.push(self.wrap_line(&detail_line, width, color, reset));
397        }
398
399        // Hint with lightbulb
400        if let Some(ref hint) = self.hint {
401            let hint_color = self.get_hint_color(&theme);
402            let hint_line = format!("  {hint_color}💡 Hint: {hint}{reset}");
403            lines.push(self.wrap_line(&hint_line, width, color, reset));
404        }
405
406        // SQLSTATE
407        if let Some(ref code) = self.sqlstate {
408            let sqlstate_line = format!("  {dim}SQLSTATE: {code}{reset}");
409            lines.push(self.wrap_line(&sqlstate_line, width, color, reset));
410        }
411
412        // Context lines
413        for line in &self.context {
414            let context_line = format!("  {line}");
415            lines.push(self.wrap_line(&context_line, width, color, reset));
416        }
417
418        // Empty line before bottom border
419        lines.push(format!(
420            "{color}│{reset}{:width$}{color}│{reset}",
421            "",
422            width = width - 2
423        ));
424
425        // Bottom border
426        let bottom_border = format!("{color}╰{}╯{reset}", "─".repeat(width - 2));
427        lines.push(bottom_border);
428
429        lines.join("\n")
430    }
431
432    /// Wrap a line to fit within the panel, accounting for ANSI codes.
433    fn wrap_line(&self, content: &str, width: usize, border_color: &str, reset: &str) -> String {
434        // For simplicity, we just truncate long lines
435        // A more sophisticated implementation would wrap text properly
436        let visible_len = self.visible_length(content);
437        let padding = (width - 2).saturating_sub(visible_len);
438
439        format!(
440            "{border_color}│{reset}{content}{:padding$}{border_color}│{reset}",
441            "",
442            padding = padding
443        )
444    }
445
446    /// Calculate visible length of a string (excluding ANSI codes).
447    fn visible_length(&self, s: &str) -> usize {
448        let mut len = 0;
449        let mut in_escape = false;
450
451        for c in s.chars() {
452            if c == '\x1b' {
453                in_escape = true;
454            } else if in_escape {
455                if c == 'm' {
456                    in_escape = false;
457                }
458            } else {
459                // Count emoji as 2 characters for terminal width
460                if c == '💡' {
461                    len += 2;
462                } else {
463                    len += 1;
464                }
465            }
466        }
467        len
468    }
469
470    /// Get the hint color code from theme.
471    fn get_hint_color(&self, theme: &Theme) -> String {
472        let (r, g, b) = theme.info.rgb();
473        format!("\x1b[38;2;{r};{g};{b}m")
474    }
475
476    /// Render as JSON-serializable structure.
477    ///
478    /// Returns a JSON value suitable for structured logging or API responses.
479    #[must_use]
480    pub fn to_json(&self) -> serde_json::Value {
481        serde_json::json!({
482            "severity": self.severity.as_str(),
483            "title": self.title,
484            "message": self.message,
485            "sql": self.sql,
486            "position": self.sql_position,
487            "sqlstate": self.sqlstate,
488            "detail": self.detail,
489            "hint": self.hint,
490            "context": self.context,
491        })
492    }
493}
494
495impl Default for ErrorPanel {
496    fn default() -> Self {
497        Self::new("Error", "An error occurred")
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn test_error_panel_basic() {
507        let panel = ErrorPanel::new("Test Error", "Something went wrong");
508        assert_eq!(panel.get_title(), "Test Error");
509        assert_eq!(panel.get_message(), "Something went wrong");
510        assert_eq!(panel.get_severity(), ErrorSeverity::Error);
511    }
512
513    #[test]
514    fn test_error_panel_with_sql() {
515        let panel = ErrorPanel::new("SQL Error", "Invalid query").with_sql("SELECT * FROM users");
516        assert_eq!(panel.get_sql(), Some("SELECT * FROM users"));
517    }
518
519    #[test]
520    fn test_error_panel_with_position() {
521        let panel = ErrorPanel::new("SQL Error", "Syntax error")
522            .with_sql("SELCT * FROM users")
523            .with_position(1);
524        assert_eq!(panel.get_position(), Some(1));
525    }
526
527    #[test]
528    fn test_error_panel_severity_styles() {
529        assert_eq!(
530            ErrorPanel::new("", "")
531                .severity(ErrorSeverity::Critical)
532                .get_severity(),
533            ErrorSeverity::Critical
534        );
535        assert_eq!(
536            ErrorPanel::new("", "")
537                .severity(ErrorSeverity::Warning)
538                .get_severity(),
539            ErrorSeverity::Warning
540        );
541        assert_eq!(
542            ErrorPanel::new("", "")
543                .severity(ErrorSeverity::Notice)
544                .get_severity(),
545            ErrorSeverity::Notice
546        );
547    }
548
549    #[test]
550    fn test_error_panel_with_hint() {
551        let panel = ErrorPanel::new("Error", "Problem").with_hint("Try this instead");
552        assert_eq!(panel.get_hint(), Some("Try this instead"));
553    }
554
555    #[test]
556    fn test_error_panel_with_detail() {
557        let panel = ErrorPanel::new("Error", "Problem").with_detail("More information here");
558        assert_eq!(panel.get_detail(), Some("More information here"));
559    }
560
561    #[test]
562    fn test_error_panel_with_sqlstate() {
563        let panel = ErrorPanel::new("Error", "Problem").with_sqlstate("42601");
564        assert_eq!(panel.get_sqlstate(), Some("42601"));
565    }
566
567    #[test]
568    fn test_error_panel_add_context() {
569        let panel = ErrorPanel::new("Error", "Problem")
570            .add_context("Line 1")
571            .add_context("Line 2");
572        assert_eq!(panel.get_context(), &["Line 1", "Line 2"]);
573    }
574
575    #[test]
576    fn test_error_panel_to_plain() {
577        let panel = ErrorPanel::new("SQL Syntax Error", "Unexpected token")
578            .with_sql("SELCT * FROM users")
579            .with_position(1)
580            .with_hint("Did you mean 'SELECT'?")
581            .with_sqlstate("42601");
582
583        let plain = panel.render_plain();
584
585        assert!(plain.contains("SQL Syntax Error"));
586        assert!(plain.contains("ERROR"));
587        assert!(plain.contains("Unexpected token"));
588        assert!(plain.contains("Query:"));
589        assert!(plain.contains("SELCT * FROM users"));
590        assert!(plain.contains('^')); // Position marker
591        assert!(plain.contains("Hint:"));
592        assert!(plain.contains("SQLSTATE: 42601"));
593    }
594
595    #[test]
596    fn test_error_panel_to_plain_minimal() {
597        let panel = ErrorPanel::new("Error", "Something failed");
598        let plain = panel.render_plain();
599
600        assert!(plain.contains("Error"));
601        assert!(plain.contains("Something failed"));
602        assert!(!plain.contains("Query:")); // No SQL
603        assert!(!plain.contains("Hint:")); // No hint
604        assert!(!plain.contains("SQLSTATE:")); // No SQLSTATE
605    }
606
607    #[test]
608    fn test_error_panel_to_json() {
609        let panel = ErrorPanel::new("Test", "Message")
610            .with_sql("SELECT 1")
611            .with_position(5)
612            .with_sqlstate("00000")
613            .with_hint("No hint needed");
614
615        let json = panel.to_json();
616
617        assert_eq!(json["severity"], "ERROR");
618        assert_eq!(json["title"], "Test");
619        assert_eq!(json["message"], "Message");
620        assert_eq!(json["sql"], "SELECT 1");
621        assert_eq!(json["position"], 5);
622        assert_eq!(json["sqlstate"], "00000");
623        assert_eq!(json["hint"], "No hint needed");
624    }
625
626    #[test]
627    fn test_error_panel_to_json_null_fields() {
628        let panel = ErrorPanel::new("Test", "Message");
629        let json = panel.to_json();
630
631        assert!(json["sql"].is_null());
632        assert!(json["position"].is_null());
633        assert!(json["sqlstate"].is_null());
634        assert!(json["hint"].is_null());
635        assert!(json["detail"].is_null());
636    }
637
638    #[test]
639    fn test_error_panel_multiple_context() {
640        let panel = ErrorPanel::new("Error", "Problem")
641            .add_context("Context 1")
642            .add_context("Context 2")
643            .add_context("Context 3");
644
645        let plain = panel.render_plain();
646        assert!(plain.contains("Context 1"));
647        assert!(plain.contains("Context 2"));
648        assert!(plain.contains("Context 3"));
649    }
650
651    #[test]
652    fn test_error_panel_empty_fields() {
653        let panel = ErrorPanel::new("", "");
654        assert_eq!(panel.get_title(), "");
655        assert_eq!(panel.get_message(), "");
656        assert!(panel.get_sql().is_none());
657    }
658
659    #[test]
660    fn test_error_severity_as_str() {
661        assert_eq!(ErrorSeverity::Critical.as_str(), "CRITICAL");
662        assert_eq!(ErrorSeverity::Error.as_str(), "ERROR");
663        assert_eq!(ErrorSeverity::Warning.as_str(), "WARNING");
664        assert_eq!(ErrorSeverity::Notice.as_str(), "NOTICE");
665    }
666
667    #[test]
668    fn test_error_severity_display() {
669        assert_eq!(format!("{}", ErrorSeverity::Critical), "CRITICAL");
670        assert_eq!(format!("{}", ErrorSeverity::Error), "ERROR");
671    }
672
673    #[test]
674    fn test_error_severity_color_codes() {
675        assert!(ErrorSeverity::Critical.color_code().contains("91")); // Bright red
676        assert!(ErrorSeverity::Error.color_code().contains("31")); // Red
677        assert!(ErrorSeverity::Warning.color_code().contains("33")); // Yellow
678        assert!(ErrorSeverity::Notice.color_code().contains("36")); // Cyan
679    }
680
681    #[test]
682    fn test_error_panel_render_styled_contains_box() {
683        let panel = ErrorPanel::new("Test", "Message").width(60);
684        let styled = panel.render_styled();
685
686        assert!(styled.contains("╭")); // Top left
687        assert!(styled.contains("╮")); // Top right
688        assert!(styled.contains("╰")); // Bottom left
689        assert!(styled.contains("╯")); // Bottom right
690        assert!(styled.contains("│")); // Sides
691    }
692
693    #[test]
694    fn test_error_panel_render_styled_contains_title() {
695        let panel = ErrorPanel::new("My Error Title", "Message").width(60);
696        let styled = panel.render_styled();
697
698        assert!(styled.contains("My Error Title"));
699    }
700
701    #[test]
702    fn test_error_panel_render_styled_with_sql() {
703        let panel = ErrorPanel::new("SQL Error", "Syntax error")
704            .with_sql("SELECT * FROM users")
705            .with_position(8)
706            .width(70);
707        let styled = panel.render_styled();
708
709        assert!(styled.contains("Query")); // SQL box header
710        assert!(styled.contains("SELECT * FROM users"));
711        assert!(styled.contains('^')); // Position marker
712    }
713
714    #[test]
715    fn test_error_panel_default() {
716        let panel = ErrorPanel::default();
717        assert_eq!(panel.get_title(), "Error");
718        assert_eq!(panel.get_message(), "An error occurred");
719    }
720
721    #[test]
722    fn test_error_panel_builder_chain() {
723        let panel = ErrorPanel::new("Chain Test", "Testing builder")
724            .severity(ErrorSeverity::Warning)
725            .with_sql("SELECT 1")
726            .with_position(7)
727            .with_sqlstate("00000")
728            .with_detail("Some detail")
729            .with_hint("A hint")
730            .add_context("Context line")
731            .theme(Theme::dark())
732            .width(80);
733
734        assert_eq!(panel.get_severity(), ErrorSeverity::Warning);
735        assert_eq!(panel.get_sql(), Some("SELECT 1"));
736        assert_eq!(panel.get_position(), Some(7));
737        assert_eq!(panel.get_sqlstate(), Some("00000"));
738        assert_eq!(panel.get_detail(), Some("Some detail"));
739        assert_eq!(panel.get_hint(), Some("A hint"));
740        assert_eq!(panel.get_context().len(), 1);
741    }
742
743    #[test]
744    fn test_render_plain_with_detail() {
745        let panel = ErrorPanel::new("Error", "Problem").with_detail("Additional details here");
746        let plain = panel.render_plain();
747
748        assert!(plain.contains("Detail: Additional details here"));
749    }
750
751    #[test]
752    fn test_position_marker_alignment() {
753        // Position 1 should have no leading spaces before ^
754        let panel = ErrorPanel::new("Error", "Msg")
755            .with_sql("SELCT")
756            .with_position(1);
757        let plain = panel.render_plain();
758        assert!(plain.contains("  ^")); // 2 spaces for indentation, then ^
759
760        // Position 5 should have 4 spaces before ^
761        let panel = ErrorPanel::new("Error", "Msg")
762            .with_sql("SELCT")
763            .with_position(5);
764        let plain = panel.render_plain();
765        assert!(plain.contains("      ^")); // 2 indent + 4 spaces + ^
766    }
767}