1use crate::theme::Theme;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ErrorSeverity {
27 Critical,
29 Error,
31 Warning,
33 Notice,
35}
36
37impl ErrorSeverity {
38 #[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 #[must_use]
51 pub fn color_code(&self) -> &'static str {
52 match self {
53 Self::Critical => "\x1b[91m", Self::Error => "\x1b[31m", Self::Warning => "\x1b[33m", Self::Notice => "\x1b[36m", }
58 }
59
60 #[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#[derive(Debug, Clone)]
78pub struct ErrorPanel {
79 severity: ErrorSeverity,
81 title: String,
83 message: String,
85 sql: Option<String>,
87 sql_position: Option<usize>,
89 sqlstate: Option<String>,
91 detail: Option<String>,
93 hint: Option<String>,
95 context: Vec<String>,
97 theme: Option<Theme>,
99 width: Option<usize>,
101}
102
103impl ErrorPanel {
104 #[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 #[must_use]
132 pub fn severity(mut self, severity: ErrorSeverity) -> Self {
133 self.severity = severity;
134 self
135 }
136
137 #[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 #[must_use]
146 pub fn with_position(mut self, position: usize) -> Self {
147 self.sql_position = Some(position);
148 self
149 }
150
151 #[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 #[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 #[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 #[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 #[must_use]
181 pub fn theme(mut self, theme: Theme) -> Self {
182 self.theme = Some(theme);
183 self
184 }
185
186 #[must_use]
188 pub fn width(mut self, width: usize) -> Self {
189 self.width = Some(width);
190 self
191 }
192
193 #[must_use]
195 pub fn get_severity(&self) -> ErrorSeverity {
196 self.severity
197 }
198
199 #[must_use]
201 pub fn get_title(&self) -> &str {
202 &self.title
203 }
204
205 #[must_use]
207 pub fn get_message(&self) -> &str {
208 &self.message
209 }
210
211 #[must_use]
213 pub fn get_sql(&self) -> Option<&str> {
214 self.sql.as_deref()
215 }
216
217 #[must_use]
219 pub fn get_position(&self) -> Option<usize> {
220 self.sql_position
221 }
222
223 #[must_use]
225 pub fn get_sqlstate(&self) -> Option<&str> {
226 self.sqlstate.as_deref()
227 }
228
229 #[must_use]
231 pub fn get_detail(&self) -> Option<&str> {
232 self.detail.as_deref()
233 }
234
235 #[must_use]
237 pub fn get_hint(&self) -> Option<&str> {
238 self.hint.as_deref()
239 }
240
241 #[must_use]
243 pub fn get_context(&self) -> &[String] {
244 &self.context
245 }
246
247 #[must_use]
252 pub fn render_plain(&self) -> String {
253 let mut lines = Vec::new();
254
255 lines.push(format!("=== {} [{}] ===", self.title, self.severity));
257 lines.push(String::new());
258 lines.push(self.message.clone());
259
260 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 if let Some(ref detail) = self.detail {
274 lines.push(String::new());
275 lines.push(format!("Detail: {detail}"));
276 }
277
278 if let Some(ref hint) = self.hint {
280 lines.push(String::new());
281 lines.push(format!("Hint: {hint}"));
282 }
283
284 if let Some(ref code) = self.sqlstate {
286 lines.push(String::new());
287 lines.push(format!("SQLSTATE: {code}"));
288 }
289
290 for line in &self.context {
292 lines.push(line.clone());
293 }
294
295 lines.join("\n")
296 }
297
298 #[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); 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 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 lines.push(format!(
333 "{color}│{reset}{:width$}{color}│{reset}",
334 "",
335 width = width - 2
336 ));
337
338 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 lines.push(format!(
350 "{color}│{reset}{:width$}{color}│{reset}",
351 "",
352 width = width - 2
353 ));
354
355 if let Some(ref sql) = self.sql {
357 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 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 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 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 lines.push(format!(
394 "{color}│{reset}{:width$}{color}│{reset}",
395 "",
396 width = width - 2
397 ));
398 }
399
400 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 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 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 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 lines.push(format!(
427 "{color}│{reset}{:width$}{color}│{reset}",
428 "",
429 width = width - 2
430 ));
431
432 let bottom_border = format!("{color}╰{}╯{reset}", "─".repeat(width - 2));
434 lines.push(bottom_border);
435
436 lines.join("\n")
437 }
438
439 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 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 if c == '💡' {
527 len += 2;
528 } else {
529 len += 1;
530 }
531 }
532 }
533 len
534 }
535
536 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 #[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('^')); 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:")); assert!(!plain.contains("Hint:")); assert!(!plain.contains("SQLSTATE:")); }
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")); assert!(ErrorSeverity::Error.color_code().contains("31")); assert!(ErrorSeverity::Warning.color_code().contains("33")); assert!(ErrorSeverity::Notice.color_code().contains("36")); }
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("╭")); assert!(styled.contains("╮")); assert!(styled.contains("╰")); assert!(styled.contains("╯")); assert!(styled.contains("│")); }
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")); assert!(styled.contains("SELECT * FROM users"));
777 assert!(styled.contains('^')); }
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 let panel = ErrorPanel::new("Error", "Msg")
845 .with_sql("SELCT")
846 .with_position(1);
847 let plain = panel.render_plain();
848 assert!(plain.contains(" ^")); let panel = ErrorPanel::new("Error", "Msg")
852 .with_sql("SELCT")
853 .with_position(5);
854 let plain = panel.render_plain();
855 assert!(plain.contains(" ^")); }
857}