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).max(6);
306        let inner_width = width.saturating_sub(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 max_title_chars = width.saturating_sub(4);
316        let title_text = self.truncate_plain_to_width(&self.title, max_title_chars);
317        let title = format!(" {title_text} ");
318        let title_len = title.chars().count();
319        let border_space = width.saturating_sub(2);
320        let total_pad = border_space.saturating_sub(title_len);
321        let left_pad = total_pad / 2;
322        let right_pad = total_pad.saturating_sub(left_pad);
323        let top_border = format!(
324            "{color}╭{}{}{}╮{reset}",
325            "─".repeat(left_pad),
326            title,
327            "─".repeat(right_pad)
328        );
329        lines.push(top_border);
330
331        // Empty line
332        lines.push(format!(
333            "{color}│{reset}{:width$}{color}│{reset}",
334            "",
335            width = width - 2
336        ));
337
338        // Severity badge
339        let severity_line = format!(
340            "  {}{}{} {}",
341            color,
342            self.severity.as_str(),
343            reset,
344            &self.message
345        );
346        lines.push(self.wrap_line(&severity_line, width, color, reset));
347
348        // Empty line after message
349        lines.push(format!(
350            "{color}│{reset}{:width$}{color}│{reset}",
351            "",
352            width = width - 2
353        ));
354
355        // SQL context with position marker
356        if let Some(ref sql) = self.sql {
357            // SQL box header
358            let sql_header = format!(
359                "{dim}┌─ Query ─{}┐{reset}",
360                "─".repeat(inner_width.saturating_sub(12))
361            );
362            lines.push(format!("{color}│{reset} {sql_header} {color}│{reset}"));
363
364            // SQL content (may need truncation for very long queries)
365            let sql_content_width = inner_width.saturating_sub(4);
366            let sql_display = self.truncate_plain_to_width(sql, sql_content_width);
367            lines.push(format!(
368                "{color}│{reset} {dim}│{reset} {:<width$} {dim}│{reset} {color}│{reset}",
369                sql_display,
370                width = sql_content_width
371            ));
372
373            // Position marker
374            if let Some(pos) = self.sql_position {
375                let marker_pos = pos.saturating_sub(1).min(inner_width.saturating_sub(5));
376                let marker_line = format!("{}^", " ".repeat(marker_pos));
377                lines.push(format!(
378                    "{color}│{reset} {dim}│{reset} {}{:<width$}{reset} {dim}│{reset} {color}│{reset}",
379                    theme.error.color_code(),
380                    marker_line,
381                    width = sql_content_width
382                ));
383            }
384
385            // SQL box footer
386            let sql_footer = format!(
387                "{dim}└{}┘{reset}",
388                "─".repeat(inner_width.saturating_sub(2))
389            );
390            lines.push(format!("{color}│{reset} {sql_footer} {color}│{reset}"));
391
392            // Empty line after SQL
393            lines.push(format!(
394                "{color}│{reset}{:width$}{color}│{reset}",
395                "",
396                width = width - 2
397            ));
398        }
399
400        // Detail
401        if let Some(ref detail) = self.detail {
402            let detail_line = format!("  Detail: {detail}");
403            lines.push(self.wrap_line(&detail_line, width, color, reset));
404        }
405
406        // Hint with lightbulb
407        if let Some(ref hint) = self.hint {
408            let hint_color = self.get_hint_color(&theme);
409            let hint_line = format!("  {hint_color}💡 Hint: {hint}{reset}");
410            lines.push(self.wrap_line(&hint_line, width, color, reset));
411        }
412
413        // SQLSTATE
414        if let Some(ref code) = self.sqlstate {
415            let sqlstate_line = format!("  {dim}SQLSTATE: {code}{reset}");
416            lines.push(self.wrap_line(&sqlstate_line, width, color, reset));
417        }
418
419        // Context lines
420        for line in &self.context {
421            let context_line = format!("  {line}");
422            lines.push(self.wrap_line(&context_line, width, color, reset));
423        }
424
425        // Empty line before bottom border
426        lines.push(format!(
427            "{color}│{reset}{:width$}{color}│{reset}",
428            "",
429            width = width - 2
430        ));
431
432        // Bottom border
433        let bottom_border = format!("{color}╰{}╯{reset}", "─".repeat(width - 2));
434        lines.push(bottom_border);
435
436        lines.join("\n")
437    }
438
439    /// Wrap a line to fit within the panel, accounting for ANSI codes.
440    fn wrap_line(&self, content: &str, width: usize, border_color: &str, reset: &str) -> String {
441        let inner_width = width.saturating_sub(2);
442        let mut rendered = content.to_string();
443        if self.visible_length(&rendered) > inner_width {
444            rendered = self.truncate_ansi_to_width(&rendered, inner_width, reset);
445        }
446        let visible_len = self.visible_length(&rendered);
447        let padding = inner_width.saturating_sub(visible_len);
448
449        format!(
450            "{border_color}│{reset}{rendered}{:padding$}{border_color}│{reset}",
451            "",
452            padding = padding
453        )
454    }
455
456    fn truncate_ansi_to_width(&self, s: &str, max_visible: usize, reset: &str) -> String {
457        let mut out = String::new();
458        let mut visible = 0usize;
459        let mut in_escape = false;
460
461        for c in s.chars() {
462            if c == '\x1b' {
463                in_escape = true;
464                out.push(c);
465                continue;
466            }
467            if in_escape {
468                out.push(c);
469                if c == 'm' {
470                    in_escape = false;
471                }
472                continue;
473            }
474
475            if visible >= max_visible {
476                break;
477            }
478
479            out.push(c);
480            if c == '💡' {
481                visible = visible.saturating_add(2);
482            } else {
483                visible = visible.saturating_add(1);
484            }
485        }
486
487        if s.contains('\x1b') && !out.ends_with(reset) {
488            out.push_str(reset);
489        }
490
491        out
492    }
493
494    fn truncate_plain_to_width(&self, s: &str, max_visible: usize) -> String {
495        if max_visible == 0 {
496            return String::new();
497        }
498
499        let char_count = s.chars().count();
500        if char_count <= max_visible {
501            return s.to_string();
502        }
503
504        if max_visible <= 3 {
505            return ".".repeat(max_visible);
506        }
507
508        let truncated: String = s.chars().take(max_visible - 3).collect();
509        format!("{truncated}...")
510    }
511
512    /// Calculate visible length of a string (excluding ANSI codes).
513    fn visible_length(&self, s: &str) -> usize {
514        let mut len = 0;
515        let mut in_escape = false;
516
517        for c in s.chars() {
518            if c == '\x1b' {
519                in_escape = true;
520            } else if in_escape {
521                if c == 'm' {
522                    in_escape = false;
523                }
524            } else {
525                // Count emoji as 2 characters for terminal width
526                if c == '💡' {
527                    len += 2;
528                } else {
529                    len += 1;
530                }
531            }
532        }
533        len
534    }
535
536    /// Get the hint color code from theme.
537    fn get_hint_color(&self, theme: &Theme) -> String {
538        let (r, g, b) = theme.info.rgb();
539        format!("\x1b[38;2;{r};{g};{b}m")
540    }
541
542    /// Render as JSON-serializable structure.
543    ///
544    /// Returns a JSON value suitable for structured logging or API responses.
545    #[must_use]
546    pub fn to_json(&self) -> serde_json::Value {
547        serde_json::json!({
548            "severity": self.severity.as_str(),
549            "title": self.title,
550            "message": self.message,
551            "sql": self.sql,
552            "position": self.sql_position,
553            "sqlstate": self.sqlstate,
554            "detail": self.detail,
555            "hint": self.hint,
556            "context": self.context,
557        })
558    }
559}
560
561impl Default for ErrorPanel {
562    fn default() -> Self {
563        Self::new("Error", "An error occurred")
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn test_error_panel_basic() {
573        let panel = ErrorPanel::new("Test Error", "Something went wrong");
574        assert_eq!(panel.get_title(), "Test Error");
575        assert_eq!(panel.get_message(), "Something went wrong");
576        assert_eq!(panel.get_severity(), ErrorSeverity::Error);
577    }
578
579    #[test]
580    fn test_error_panel_with_sql() {
581        let panel = ErrorPanel::new("SQL Error", "Invalid query").with_sql("SELECT * FROM users");
582        assert_eq!(panel.get_sql(), Some("SELECT * FROM users"));
583    }
584
585    #[test]
586    fn test_error_panel_with_position() {
587        let panel = ErrorPanel::new("SQL Error", "Syntax error")
588            .with_sql("SELCT * FROM users")
589            .with_position(1);
590        assert_eq!(panel.get_position(), Some(1));
591    }
592
593    #[test]
594    fn test_error_panel_severity_styles() {
595        assert_eq!(
596            ErrorPanel::new("", "")
597                .severity(ErrorSeverity::Critical)
598                .get_severity(),
599            ErrorSeverity::Critical
600        );
601        assert_eq!(
602            ErrorPanel::new("", "")
603                .severity(ErrorSeverity::Warning)
604                .get_severity(),
605            ErrorSeverity::Warning
606        );
607        assert_eq!(
608            ErrorPanel::new("", "")
609                .severity(ErrorSeverity::Notice)
610                .get_severity(),
611            ErrorSeverity::Notice
612        );
613    }
614
615    #[test]
616    fn test_error_panel_with_hint() {
617        let panel = ErrorPanel::new("Error", "Problem").with_hint("Try this instead");
618        assert_eq!(panel.get_hint(), Some("Try this instead"));
619    }
620
621    #[test]
622    fn test_error_panel_with_detail() {
623        let panel = ErrorPanel::new("Error", "Problem").with_detail("More information here");
624        assert_eq!(panel.get_detail(), Some("More information here"));
625    }
626
627    #[test]
628    fn test_error_panel_with_sqlstate() {
629        let panel = ErrorPanel::new("Error", "Problem").with_sqlstate("42601");
630        assert_eq!(panel.get_sqlstate(), Some("42601"));
631    }
632
633    #[test]
634    fn test_error_panel_add_context() {
635        let panel = ErrorPanel::new("Error", "Problem")
636            .add_context("Line 1")
637            .add_context("Line 2");
638        assert_eq!(panel.get_context(), &["Line 1", "Line 2"]);
639    }
640
641    #[test]
642    fn test_error_panel_to_plain() {
643        let panel = ErrorPanel::new("SQL Syntax Error", "Unexpected token")
644            .with_sql("SELCT * FROM users")
645            .with_position(1)
646            .with_hint("Did you mean 'SELECT'?")
647            .with_sqlstate("42601");
648
649        let plain = panel.render_plain();
650
651        assert!(plain.contains("SQL Syntax Error"));
652        assert!(plain.contains("ERROR"));
653        assert!(plain.contains("Unexpected token"));
654        assert!(plain.contains("Query:"));
655        assert!(plain.contains("SELCT * FROM users"));
656        assert!(plain.contains('^')); // Position marker
657        assert!(plain.contains("Hint:"));
658        assert!(plain.contains("SQLSTATE: 42601"));
659    }
660
661    #[test]
662    fn test_error_panel_to_plain_minimal() {
663        let panel = ErrorPanel::new("Error", "Something failed");
664        let plain = panel.render_plain();
665
666        assert!(plain.contains("Error"));
667        assert!(plain.contains("Something failed"));
668        assert!(!plain.contains("Query:")); // No SQL
669        assert!(!plain.contains("Hint:")); // No hint
670        assert!(!plain.contains("SQLSTATE:")); // No SQLSTATE
671    }
672
673    #[test]
674    fn test_error_panel_to_json() {
675        let panel = ErrorPanel::new("Test", "Message")
676            .with_sql("SELECT 1")
677            .with_position(5)
678            .with_sqlstate("00000")
679            .with_hint("No hint needed");
680
681        let json = panel.to_json();
682
683        assert_eq!(json["severity"], "ERROR");
684        assert_eq!(json["title"], "Test");
685        assert_eq!(json["message"], "Message");
686        assert_eq!(json["sql"], "SELECT 1");
687        assert_eq!(json["position"], 5);
688        assert_eq!(json["sqlstate"], "00000");
689        assert_eq!(json["hint"], "No hint needed");
690    }
691
692    #[test]
693    fn test_error_panel_to_json_null_fields() {
694        let panel = ErrorPanel::new("Test", "Message");
695        let json = panel.to_json();
696
697        assert!(json["sql"].is_null());
698        assert!(json["position"].is_null());
699        assert!(json["sqlstate"].is_null());
700        assert!(json["hint"].is_null());
701        assert!(json["detail"].is_null());
702    }
703
704    #[test]
705    fn test_error_panel_multiple_context() {
706        let panel = ErrorPanel::new("Error", "Problem")
707            .add_context("Context 1")
708            .add_context("Context 2")
709            .add_context("Context 3");
710
711        let plain = panel.render_plain();
712        assert!(plain.contains("Context 1"));
713        assert!(plain.contains("Context 2"));
714        assert!(plain.contains("Context 3"));
715    }
716
717    #[test]
718    fn test_error_panel_empty_fields() {
719        let panel = ErrorPanel::new("", "");
720        assert_eq!(panel.get_title(), "");
721        assert_eq!(panel.get_message(), "");
722        assert!(panel.get_sql().is_none());
723    }
724
725    #[test]
726    fn test_error_severity_as_str() {
727        assert_eq!(ErrorSeverity::Critical.as_str(), "CRITICAL");
728        assert_eq!(ErrorSeverity::Error.as_str(), "ERROR");
729        assert_eq!(ErrorSeverity::Warning.as_str(), "WARNING");
730        assert_eq!(ErrorSeverity::Notice.as_str(), "NOTICE");
731    }
732
733    #[test]
734    fn test_error_severity_display() {
735        assert_eq!(format!("{}", ErrorSeverity::Critical), "CRITICAL");
736        assert_eq!(format!("{}", ErrorSeverity::Error), "ERROR");
737    }
738
739    #[test]
740    fn test_error_severity_color_codes() {
741        assert!(ErrorSeverity::Critical.color_code().contains("91")); // Bright red
742        assert!(ErrorSeverity::Error.color_code().contains("31")); // Red
743        assert!(ErrorSeverity::Warning.color_code().contains("33")); // Yellow
744        assert!(ErrorSeverity::Notice.color_code().contains("36")); // Cyan
745    }
746
747    #[test]
748    fn test_error_panel_render_styled_contains_box() {
749        let panel = ErrorPanel::new("Test", "Message").width(60);
750        let styled = panel.render_styled();
751
752        assert!(styled.contains("╭")); // Top left
753        assert!(styled.contains("╮")); // Top right
754        assert!(styled.contains("╰")); // Bottom left
755        assert!(styled.contains("╯")); // Bottom right
756        assert!(styled.contains("│")); // Sides
757    }
758
759    #[test]
760    fn test_error_panel_render_styled_contains_title() {
761        let panel = ErrorPanel::new("My Error Title", "Message").width(60);
762        let styled = panel.render_styled();
763
764        assert!(styled.contains("My Error Title"));
765    }
766
767    #[test]
768    fn test_error_panel_render_styled_with_sql() {
769        let panel = ErrorPanel::new("SQL Error", "Syntax error")
770            .with_sql("SELECT * FROM users")
771            .with_position(8)
772            .width(70);
773        let styled = panel.render_styled();
774
775        assert!(styled.contains("Query")); // SQL box header
776        assert!(styled.contains("SELECT * FROM users"));
777        assert!(styled.contains('^')); // Position marker
778    }
779
780    #[test]
781    fn test_error_panel_render_styled_tiny_width_does_not_panic() {
782        let panel = ErrorPanel::new("Tiny", "Narrow")
783            .with_sql("SELECT * FROM t")
784            .with_position(3)
785            .width(1);
786        let styled = panel.render_styled();
787
788        assert!(!styled.is_empty());
789        assert!(styled.contains('╭'));
790        assert!(styled.contains('╯'));
791    }
792
793    #[test]
794    fn test_error_panel_render_styled_unicode_sql_truncation() {
795        let panel = ErrorPanel::new("Unicode", "Syntax error")
796            .with_sql("SELECT '🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥'")
797            .width(26);
798        let styled = panel.render_styled();
799
800        assert!(styled.contains("Query"));
801        assert!(styled.contains("..."));
802    }
803
804    #[test]
805    fn test_error_panel_default() {
806        let panel = ErrorPanel::default();
807        assert_eq!(panel.get_title(), "Error");
808        assert_eq!(panel.get_message(), "An error occurred");
809    }
810
811    #[test]
812    fn test_error_panel_builder_chain() {
813        let panel = ErrorPanel::new("Chain Test", "Testing builder")
814            .severity(ErrorSeverity::Warning)
815            .with_sql("SELECT 1")
816            .with_position(7)
817            .with_sqlstate("00000")
818            .with_detail("Some detail")
819            .with_hint("A hint")
820            .add_context("Context line")
821            .theme(Theme::dark())
822            .width(80);
823
824        assert_eq!(panel.get_severity(), ErrorSeverity::Warning);
825        assert_eq!(panel.get_sql(), Some("SELECT 1"));
826        assert_eq!(panel.get_position(), Some(7));
827        assert_eq!(panel.get_sqlstate(), Some("00000"));
828        assert_eq!(panel.get_detail(), Some("Some detail"));
829        assert_eq!(panel.get_hint(), Some("A hint"));
830        assert_eq!(panel.get_context().len(), 1);
831    }
832
833    #[test]
834    fn test_render_plain_with_detail() {
835        let panel = ErrorPanel::new("Error", "Problem").with_detail("Additional details here");
836        let plain = panel.render_plain();
837
838        assert!(plain.contains("Detail: Additional details here"));
839    }
840
841    #[test]
842    fn test_position_marker_alignment() {
843        // Position 1 should have no leading spaces before ^
844        let panel = ErrorPanel::new("Error", "Msg")
845            .with_sql("SELCT")
846            .with_position(1);
847        let plain = panel.render_plain();
848        assert!(plain.contains("  ^")); // 2 spaces for indentation, then ^
849
850        // Position 5 should have 4 spaces before ^
851        let panel = ErrorPanel::new("Error", "Msg")
852            .with_sql("SELCT")
853            .with_position(5);
854        let plain = panel.render_plain();
855        assert!(plain.contains("      ^")); // 2 indent + 4 spaces + ^
856    }
857}