1use chrono::{DateTime, Utc};
33use serde::{Deserialize, Serialize};
34
35#[cfg(all(feature = "rich-ui", unix))]
36use rich_rust::r#box::HEAVY;
37#[cfg(all(feature = "rich-ui", unix))]
38use rich_rust::prelude::*;
39
40use super::{Icons, OutputContext, RchTheme};
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "lowercase")]
47pub enum ErrorSeverity {
48 #[default]
50 Error,
51 Warning,
53 Info,
55}
56
57impl ErrorSeverity {
58 #[must_use]
60 pub const fn color(&self) -> &'static str {
61 match self {
62 Self::Error => RchTheme::ERROR,
63 Self::Warning => RchTheme::WARNING,
64 Self::Info => RchTheme::INFO,
65 }
66 }
67
68 #[must_use]
70 pub fn icon(&self, ctx: OutputContext) -> &'static str {
71 match self {
72 Self::Error => Icons::cross(ctx),
73 Self::Warning => Icons::warning(ctx),
74 Self::Info => Icons::info(ctx),
75 }
76 }
77
78 #[must_use]
80 pub const fn name(&self) -> &'static str {
81 match self {
82 Self::Error => "ERROR",
83 Self::Warning => "WARN",
84 Self::Info => "INFO",
85 }
86 }
87}
88
89impl std::fmt::Display for ErrorSeverity {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 write!(f, "{}", self.name())
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ErrorContext {
98 pub key: String,
100 pub value: String,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct CausedBy {
107 pub message: String,
109 pub code: Option<String>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ErrorPanel {
124 pub code: String,
126
127 pub title: String,
129
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub message: Option<String>,
133
134 pub severity: ErrorSeverity,
136
137 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 pub context: Vec<ErrorContext>,
140
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub suggestions: Vec<String>,
144
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub caused_by: Vec<CausedBy>,
148
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub stack_trace: Option<String>,
152
153 pub timestamp: DateTime<Utc>,
155
156 #[serde(default)]
158 pub truncated: bool,
159}
160
161impl ErrorPanel {
162 #[must_use]
169 pub fn new(code: impl Into<String>, title: impl Into<String>) -> Self {
170 Self {
171 code: code.into(),
172 title: title.into(),
173 message: None,
174 severity: ErrorSeverity::Error,
175 context: Vec::new(),
176 suggestions: Vec::new(),
177 caused_by: Vec::new(),
178 stack_trace: None,
179 timestamp: Utc::now(),
180 truncated: false,
181 }
182 }
183
184 #[must_use]
186 pub fn error(code: impl Into<String>, title: impl Into<String>) -> Self {
187 Self::new(code, title).with_severity(ErrorSeverity::Error)
188 }
189
190 #[must_use]
192 pub fn warning(code: impl Into<String>, title: impl Into<String>) -> Self {
193 Self::new(code, title).with_severity(ErrorSeverity::Warning)
194 }
195
196 #[must_use]
198 pub fn info(code: impl Into<String>, title: impl Into<String>) -> Self {
199 Self::new(code, title).with_severity(ErrorSeverity::Info)
200 }
201
202 #[must_use]
204 pub fn with_severity(mut self, severity: ErrorSeverity) -> Self {
205 self.severity = severity;
206 self
207 }
208
209 #[must_use]
211 pub fn message(mut self, message: impl Into<String>) -> Self {
212 self.message = Some(message.into());
213 self
214 }
215
216 #[must_use]
221 pub fn message_truncated(mut self, message: impl Into<String>, max_len: usize) -> Self {
222 let msg = message.into();
223 if msg.len() > max_len {
224 self.message = Some(format!("{}...", &msg[..max_len.saturating_sub(3)]));
225 self.truncated = true;
226 } else {
227 self.message = Some(msg);
228 }
229 self
230 }
231
232 #[must_use]
234 pub fn context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
235 self.context.push(ErrorContext {
236 key: key.into(),
237 value: value.into(),
238 });
239 self
240 }
241
242 #[must_use]
244 pub fn suggestion(mut self, suggestion: impl Into<String>) -> Self {
245 self.suggestions.push(suggestion.into());
246 self
247 }
248
249 #[must_use]
251 pub fn suggestions<I, S>(mut self, suggestions: I) -> Self
252 where
253 I: IntoIterator<Item = S>,
254 S: Into<String>,
255 {
256 self.suggestions
257 .extend(suggestions.into_iter().map(Into::into));
258 self
259 }
260
261 #[must_use]
263 pub fn caused_by(mut self, message: impl Into<String>, code: Option<String>) -> Self {
264 self.caused_by.push(CausedBy {
265 message: message.into(),
266 code,
267 });
268 self
269 }
270
271 #[must_use]
273 pub fn stack_trace(mut self, trace: impl Into<String>) -> Self {
274 self.stack_trace = Some(trace.into());
275 self
276 }
277
278 #[must_use]
280 pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
281 self.timestamp = timestamp;
282 self
283 }
284
285 pub fn render(&self, ctx: OutputContext) {
293 if ctx.is_machine() {
294 return;
296 }
297
298 #[cfg(all(feature = "rich-ui", unix))]
299 if ctx.supports_rich() {
300 self.render_rich(ctx);
301 return;
302 }
303
304 self.render_plain(ctx);
305 }
306
307 #[cfg(all(feature = "rich-ui", unix))]
309 fn render_rich(&self, ctx: OutputContext) {
310 let content = self.build_content(ctx, true);
311 let icon = self.severity.icon(ctx);
312 let title_text = format!("{icon} {}: {}", self.code, self.title);
313
314 let border_color = Color::parse(self.severity.color()).unwrap_or_else(|_| Color::default());
315 let border_style = Style::new().bold().color(border_color);
316
317 let panel = Panel::from_text(&content)
318 .title(title_text.as_str())
319 .border_style(border_style)
320 .box_style(&HEAVY);
321
322 let console = Console::builder().force_terminal(true).build();
324 console.print_renderable(&panel);
325 }
326
327 fn render_plain(&self, ctx: OutputContext) {
329 let icon = self.severity.icon(ctx);
330 let severity = self.severity.name();
331
332 eprintln!("{icon} [{severity}] {}: {}", self.code, self.title);
334
335 if let Some(ref msg) = self.message {
337 eprintln!();
338 eprintln!("{msg}");
339 }
340
341 if !self.context.is_empty() {
343 eprintln!();
344 eprintln!("Context:");
345 for item in &self.context {
346 eprintln!(" {}: {}", item.key, item.value);
347 }
348 }
349
350 if !self.caused_by.is_empty() {
352 eprintln!();
353 eprintln!("Caused by:");
354 for cause in &self.caused_by {
355 if let Some(ref code) = cause.code {
356 eprintln!(" [{code}] {}", cause.message);
357 } else {
358 eprintln!(" {}", cause.message);
359 }
360 }
361 }
362
363 if !self.suggestions.is_empty() {
365 eprintln!();
366 eprintln!("Suggestions:");
367 for (i, suggestion) in self.suggestions.iter().enumerate() {
368 eprintln!(" {}. {suggestion}", i + 1);
369 }
370 }
371
372 if self.truncated {
374 eprintln!();
375 eprintln!("(Use --verbose for full message)");
376 }
377
378 if let Some(ref trace) = self.stack_trace {
380 eprintln!();
381 eprintln!("Stack trace:");
382 for line in trace.lines() {
383 eprintln!(" {line}");
384 }
385 }
386
387 eprintln!();
389 eprintln!("[{}]", self.timestamp.format("%Y-%m-%d %H:%M:%S UTC"));
390 }
391
392 #[cfg(all(feature = "rich-ui", unix))]
394 fn build_content(&self, ctx: OutputContext, _use_markup: bool) -> String {
395 let mut lines = Vec::new();
396
397 if let Some(ref msg) = self.message {
399 lines.push(msg.clone());
400 }
401
402 if !self.context.is_empty() {
404 lines.push(String::new());
405 lines.push(format!("[{}]Context:[/]", RchTheme::DIM));
406 for item in &self.context {
407 lines.push(format!(
408 " [{}]{}:[/] {}",
409 RchTheme::DIM,
410 item.key,
411 item.value
412 ));
413 }
414 }
415
416 if !self.caused_by.is_empty() {
418 lines.push(String::new());
419 lines.push(format!("[{}]Caused by:[/]", RchTheme::DIM));
420 for cause in &self.caused_by {
421 if let Some(ref code) = cause.code {
422 lines.push(format!(" [{code}] {}", cause.message));
423 } else {
424 lines.push(format!(" {}", cause.message));
425 }
426 }
427 }
428
429 if !self.suggestions.is_empty() {
431 lines.push(String::new());
432 lines.push(format!("[{}]Suggestions:[/]", RchTheme::SECONDARY));
433 for (i, suggestion) in self.suggestions.iter().enumerate() {
434 lines.push(format!(
435 " [{}]{}.[/] {}",
436 RchTheme::SECONDARY,
437 i + 1,
438 suggestion
439 ));
440 }
441 }
442
443 if self.truncated {
445 lines.push(String::new());
446 lines.push(format!(
447 "[{}](Use --verbose for full message)[/]",
448 RchTheme::DIM
449 ));
450 }
451
452 if let Some(ref trace) = self.stack_trace {
454 lines.push(String::new());
455 lines.push(format!("[{}]Stack trace:[/]", RchTheme::DIM));
456 for line in trace.lines() {
457 lines.push(format!("[{}] {line}[/]", RchTheme::DIM));
458 }
459 }
460
461 lines.push(String::new());
463 let timestamp_icon = Icons::clock(ctx);
464 lines.push(format!(
465 "[{}]{timestamp_icon} {}[/]",
466 RchTheme::DIM,
467 self.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
468 ));
469
470 lines.join("\n")
471 }
472
473 pub fn to_json(&self) -> serde_json::Result<String> {
477 serde_json::to_string_pretty(self)
478 }
479
480 pub fn to_json_compact(&self) -> serde_json::Result<String> {
482 serde_json::to_string(self)
483 }
484}
485
486impl std::fmt::Display for ErrorPanel {
487 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
488 write!(f, "[{}] {}: {}", self.severity, self.code, self.title)?;
489 if let Some(ref msg) = self.message {
490 write!(f, " - {msg}")?;
491 }
492 Ok(())
493 }
494}
495
496impl std::error::Error for ErrorPanel {}
497
498pub fn show_error(code: &str, title: &str, message: &str, ctx: OutputContext) {
506 ErrorPanel::error(code, title).message(message).render(ctx);
507}
508
509pub fn show_warning(code: &str, title: &str, message: &str, ctx: OutputContext) {
511 ErrorPanel::warning(code, title)
512 .message(message)
513 .render(ctx);
514}
515
516pub fn show_info(code: &str, title: &str, message: &str, ctx: OutputContext) {
518 ErrorPanel::info(code, title).message(message).render(ctx);
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn test_error_panel_creation() {
527 let error = ErrorPanel::new("RCH-E042", "Test Error");
528 assert_eq!(error.code, "RCH-E042");
529 assert_eq!(error.title, "Test Error");
530 assert_eq!(error.severity, ErrorSeverity::Error);
531 assert!(error.message.is_none());
532 assert!(error.context.is_empty());
533 assert!(error.suggestions.is_empty());
534 }
535
536 #[test]
537 fn test_error_panel_builder() {
538 let error = ErrorPanel::error("RCH-E001", "Connection Failed")
539 .message("Could not connect to worker")
540 .context("Host", "192.168.1.10")
541 .context("Port", "22")
542 .suggestion("Check if worker is online")
543 .suggestion("Verify SSH key");
544
545 assert_eq!(error.severity, ErrorSeverity::Error);
546 assert_eq!(
547 error.message.as_deref(),
548 Some("Could not connect to worker")
549 );
550 assert_eq!(error.context.len(), 2);
551 assert_eq!(error.suggestions.len(), 2);
552 }
553
554 #[test]
555 fn test_warning_panel() {
556 let warning = ErrorPanel::warning("RCH-W001", "Slow Response");
557 assert_eq!(warning.severity, ErrorSeverity::Warning);
558 }
559
560 #[test]
561 fn test_info_panel() {
562 let info = ErrorPanel::info("RCH-I001", "Build Complete");
563 assert_eq!(info.severity, ErrorSeverity::Info);
564 }
565
566 #[test]
567 fn test_message_truncation() {
568 let long_message = "a".repeat(1000);
569 let error = ErrorPanel::new("RCH-E001", "Test").message_truncated(long_message, 100);
570
571 assert!(error.truncated);
572 assert!(error.message.as_ref().unwrap().len() <= 100);
573 assert!(error.message.as_ref().unwrap().ends_with("..."));
574 }
575
576 #[test]
577 fn test_message_no_truncation_needed() {
578 let short_message = "Short message";
579 let error = ErrorPanel::new("RCH-E001", "Test").message_truncated(short_message, 100);
580
581 assert!(!error.truncated);
582 assert_eq!(error.message.as_deref(), Some("Short message"));
583 }
584
585 #[test]
586 fn test_error_chaining() {
587 let error = ErrorPanel::error("RCH-E042", "Connection Failed")
588 .caused_by("SSH handshake failed", Some("SSH-001".to_string()))
589 .caused_by("Network unreachable", None);
590
591 assert_eq!(error.caused_by.len(), 2);
592 assert_eq!(error.caused_by[0].code, Some("SSH-001".to_string()));
593 assert!(error.caused_by[1].code.is_none());
594 }
595
596 #[test]
597 fn test_json_serialization() {
598 let error = ErrorPanel::error("RCH-E001", "Test Error")
599 .message("Test message")
600 .context("Key", "Value");
601
602 let json = error.to_json().expect("JSON serialization failed");
603 assert!(json.contains("RCH-E001"));
604 assert!(json.contains("Test Error"));
605 assert!(json.contains("Test message"));
606 }
607
608 #[test]
609 fn test_json_compact_serialization() {
610 let error = ErrorPanel::error("RCH-E001", "Test");
611 let json = error.to_json_compact().expect("JSON serialization failed");
612 assert!(!json.contains('\n'));
614 }
615
616 #[test]
617 fn test_display_implementation() {
618 let error = ErrorPanel::error("RCH-E001", "Test Error").message("Details here");
619 let display = format!("{error}");
620 assert!(display.contains("ERROR"));
621 assert!(display.contains("RCH-E001"));
622 assert!(display.contains("Test Error"));
623 assert!(display.contains("Details here"));
624 }
625
626 #[test]
627 fn test_severity_colors() {
628 assert_eq!(ErrorSeverity::Error.color(), RchTheme::ERROR);
629 assert_eq!(ErrorSeverity::Warning.color(), RchTheme::WARNING);
630 assert_eq!(ErrorSeverity::Info.color(), RchTheme::INFO);
631 }
632
633 #[test]
634 fn test_severity_names() {
635 assert_eq!(ErrorSeverity::Error.name(), "ERROR");
636 assert_eq!(ErrorSeverity::Warning.name(), "WARN");
637 assert_eq!(ErrorSeverity::Info.name(), "INFO");
638 }
639
640 #[test]
641 fn test_severity_icons_plain() {
642 let ctx = OutputContext::Plain;
643 assert!(!ErrorSeverity::Error.icon(ctx).is_empty());
645 assert!(!ErrorSeverity::Warning.icon(ctx).is_empty());
646 assert!(!ErrorSeverity::Info.icon(ctx).is_empty());
647 }
648
649 #[test]
650 fn test_render_plain_mode() {
651 let error = ErrorPanel::error("RCH-E001", "Test")
652 .message("Message")
653 .context("Key", "Value")
654 .suggestion("Do something");
655
656 error.render(OutputContext::Plain);
658 }
659
660 #[test]
661 fn test_render_machine_mode_silent() {
662 let error = ErrorPanel::error("RCH-E001", "Test");
663 error.render(OutputContext::Machine);
665 }
666
667 #[test]
668 fn test_render_hook_mode_silent() {
669 let error = ErrorPanel::error("RCH-E001", "Test");
670 error.render(OutputContext::Hook);
672 }
673
674 #[test]
675 fn test_stack_trace() {
676 let error =
677 ErrorPanel::error("RCH-E001", "Test").stack_trace("at main.rs:42\nat lib.rs:100");
678
679 assert!(error.stack_trace.is_some());
680 assert!(error.stack_trace.as_ref().unwrap().contains("main.rs:42"));
681 }
682
683 #[test]
684 fn test_multiple_suggestions() {
685 let error = ErrorPanel::error("RCH-E001", "Test").suggestions([
686 "First suggestion",
687 "Second suggestion",
688 "Third suggestion",
689 ]);
690
691 assert_eq!(error.suggestions.len(), 3);
692 }
693
694 #[test]
695 fn test_default_severity() {
696 let severity = ErrorSeverity::default();
697 assert_eq!(severity, ErrorSeverity::Error);
698 }
699
700 #[test]
701 fn test_convenience_functions_dont_panic() {
702 show_error("E001", "Test", "Message", OutputContext::Plain);
704 show_warning("W001", "Test", "Message", OutputContext::Plain);
705 show_info("I001", "Test", "Message", OutputContext::Plain);
706 }
707
708 #[test]
709 fn test_error_panel_is_error_trait() {
710 let error: Box<dyn std::error::Error> = Box::new(ErrorPanel::error("RCH-E001", "Test"));
711 let _ = format!("{error}");
713 }
714}