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);
306 let inner_width = width - 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 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 lines.push(format!(
329 "{color}│{reset}{:width$}{color}│{reset}",
330 "",
331 width = width - 2
332 ));
333
334 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 lines.push(format!(
346 "{color}│{reset}{:width$}{color}│{reset}",
347 "",
348 width = width - 2
349 ));
350
351 if let Some(ref sql) = self.sql {
353 let sql_header = format!("{dim}┌─ Query ─{}┐{reset}", "─".repeat(inner_width - 12));
355 lines.push(format!("{color}│{reset} {sql_header} {color}│{reset}"));
356
357 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 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 let sql_footer = format!("{dim}└{}┘{reset}", "─".repeat(inner_width - 2));
383 lines.push(format!("{color}│{reset} {sql_footer} {color}│{reset}"));
384
385 lines.push(format!(
387 "{color}│{reset}{:width$}{color}│{reset}",
388 "",
389 width = width - 2
390 ));
391 }
392
393 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 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 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 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 lines.push(format!(
420 "{color}│{reset}{:width$}{color}│{reset}",
421 "",
422 width = width - 2
423 ));
424
425 let bottom_border = format!("{color}╰{}╯{reset}", "─".repeat(width - 2));
427 lines.push(bottom_border);
428
429 lines.join("\n")
430 }
431
432 fn wrap_line(&self, content: &str, width: usize, border_color: &str, reset: &str) -> String {
434 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 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 if c == '💡' {
461 len += 2;
462 } else {
463 len += 1;
464 }
465 }
466 }
467 len
468 }
469
470 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 #[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('^')); 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:")); assert!(!plain.contains("Hint:")); assert!(!plain.contains("SQLSTATE:")); }
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")); assert!(ErrorSeverity::Error.color_code().contains("31")); assert!(ErrorSeverity::Warning.color_code().contains("33")); assert!(ErrorSeverity::Notice.color_code().contains("36")); }
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("╭")); assert!(styled.contains("╮")); assert!(styled.contains("╰")); assert!(styled.contains("╯")); assert!(styled.contains("│")); }
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")); assert!(styled.contains("SELECT * FROM users"));
711 assert!(styled.contains('^')); }
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 let panel = ErrorPanel::new("Error", "Msg")
755 .with_sql("SELCT")
756 .with_position(1);
757 let plain = panel.render_plain();
758 assert!(plain.contains(" ^")); let panel = ErrorPanel::new("Error", "Msg")
762 .with_sql("SELCT")
763 .with_position(5);
764 let plain = panel.render_plain();
765 assert!(plain.contains(" ^")); }
767}