use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[cfg(all(feature = "rich-ui", unix))]
use rich_rust::r#box::HEAVY;
#[cfg(all(feature = "rich-ui", unix))]
use rich_rust::prelude::*;
use super::{Icons, OutputContext, RchTheme};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ErrorSeverity {
#[default]
Error,
Warning,
Info,
}
impl ErrorSeverity {
#[must_use]
pub const fn color(&self) -> &'static str {
match self {
Self::Error => RchTheme::ERROR,
Self::Warning => RchTheme::WARNING,
Self::Info => RchTheme::INFO,
}
}
#[must_use]
pub fn icon(&self, ctx: OutputContext) -> &'static str {
match self {
Self::Error => Icons::cross(ctx),
Self::Warning => Icons::warning(ctx),
Self::Info => Icons::info(ctx),
}
}
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::Error => "ERROR",
Self::Warning => "WARN",
Self::Info => "INFO",
}
}
}
impl std::fmt::Display for ErrorSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorContext {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CausedBy {
pub message: String,
pub code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorPanel {
pub code: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
pub severity: ErrorSeverity,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context: Vec<ErrorContext>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggestions: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub caused_by: Vec<CausedBy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stack_trace: Option<String>,
pub timestamp: DateTime<Utc>,
#[serde(default)]
pub truncated: bool,
}
impl ErrorPanel {
#[must_use]
pub fn new(code: impl Into<String>, title: impl Into<String>) -> Self {
Self {
code: code.into(),
title: title.into(),
message: None,
severity: ErrorSeverity::Error,
context: Vec::new(),
suggestions: Vec::new(),
caused_by: Vec::new(),
stack_trace: None,
timestamp: Utc::now(),
truncated: false,
}
}
#[must_use]
pub fn error(code: impl Into<String>, title: impl Into<String>) -> Self {
Self::new(code, title).with_severity(ErrorSeverity::Error)
}
#[must_use]
pub fn warning(code: impl Into<String>, title: impl Into<String>) -> Self {
Self::new(code, title).with_severity(ErrorSeverity::Warning)
}
#[must_use]
pub fn info(code: impl Into<String>, title: impl Into<String>) -> Self {
Self::new(code, title).with_severity(ErrorSeverity::Info)
}
#[must_use]
pub fn with_severity(mut self, severity: ErrorSeverity) -> Self {
self.severity = severity;
self
}
#[must_use]
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
#[must_use]
pub fn message_truncated(mut self, message: impl Into<String>, max_len: usize) -> Self {
let msg = message.into();
if msg.len() > max_len {
self.message = Some(format!("{}...", &msg[..max_len.saturating_sub(3)]));
self.truncated = true;
} else {
self.message = Some(msg);
}
self
}
#[must_use]
pub fn context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.context.push(ErrorContext {
key: key.into(),
value: value.into(),
});
self
}
#[must_use]
pub fn suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestions.push(suggestion.into());
self
}
#[must_use]
pub fn suggestions<I, S>(mut self, suggestions: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.suggestions
.extend(suggestions.into_iter().map(Into::into));
self
}
#[must_use]
pub fn caused_by(mut self, message: impl Into<String>, code: Option<String>) -> Self {
self.caused_by.push(CausedBy {
message: message.into(),
code,
});
self
}
#[must_use]
pub fn stack_trace(mut self, trace: impl Into<String>) -> Self {
self.stack_trace = Some(trace.into());
self
}
#[must_use]
pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
self.timestamp = timestamp;
self
}
pub fn render(&self, ctx: OutputContext) {
if ctx.is_machine() {
return;
}
#[cfg(all(feature = "rich-ui", unix))]
if ctx.supports_rich() {
self.render_rich(ctx);
return;
}
self.render_plain(ctx);
}
#[cfg(all(feature = "rich-ui", unix))]
fn render_rich(&self, ctx: OutputContext) {
let content = self.build_content(ctx, true);
let icon = self.severity.icon(ctx);
let title_text = format!("{icon} {}: {}", self.code, self.title);
let border_color = Color::parse(self.severity.color()).unwrap_or_else(|_| Color::default());
let border_style = Style::new().bold().color(border_color);
let panel = Panel::from_text(&content)
.title(title_text.as_str())
.border_style(border_style)
.box_style(&HEAVY);
let console = Console::builder().force_terminal(true).build();
console.print_renderable(&panel);
}
fn render_plain(&self, ctx: OutputContext) {
let icon = self.severity.icon(ctx);
let severity = self.severity.name();
eprintln!("{icon} [{severity}] {}: {}", self.code, self.title);
if let Some(ref msg) = self.message {
eprintln!();
eprintln!("{msg}");
}
if !self.context.is_empty() {
eprintln!();
eprintln!("Context:");
for item in &self.context {
eprintln!(" {}: {}", item.key, item.value);
}
}
if !self.caused_by.is_empty() {
eprintln!();
eprintln!("Caused by:");
for cause in &self.caused_by {
if let Some(ref code) = cause.code {
eprintln!(" [{code}] {}", cause.message);
} else {
eprintln!(" {}", cause.message);
}
}
}
if !self.suggestions.is_empty() {
eprintln!();
eprintln!("Suggestions:");
for (i, suggestion) in self.suggestions.iter().enumerate() {
eprintln!(" {}. {suggestion}", i + 1);
}
}
if self.truncated {
eprintln!();
eprintln!("(Use --verbose for full message)");
}
if let Some(ref trace) = self.stack_trace {
eprintln!();
eprintln!("Stack trace:");
for line in trace.lines() {
eprintln!(" {line}");
}
}
eprintln!();
eprintln!("[{}]", self.timestamp.format("%Y-%m-%d %H:%M:%S UTC"));
}
#[cfg(all(feature = "rich-ui", unix))]
fn build_content(&self, ctx: OutputContext, _use_markup: bool) -> String {
let mut lines = Vec::new();
if let Some(ref msg) = self.message {
lines.push(msg.clone());
}
if !self.context.is_empty() {
lines.push(String::new());
lines.push(format!("[{}]Context:[/]", RchTheme::DIM));
for item in &self.context {
lines.push(format!(
" [{}]{}:[/] {}",
RchTheme::DIM,
item.key,
item.value
));
}
}
if !self.caused_by.is_empty() {
lines.push(String::new());
lines.push(format!("[{}]Caused by:[/]", RchTheme::DIM));
for cause in &self.caused_by {
if let Some(ref code) = cause.code {
lines.push(format!(" [{code}] {}", cause.message));
} else {
lines.push(format!(" {}", cause.message));
}
}
}
if !self.suggestions.is_empty() {
lines.push(String::new());
lines.push(format!("[{}]Suggestions:[/]", RchTheme::SECONDARY));
for (i, suggestion) in self.suggestions.iter().enumerate() {
lines.push(format!(
" [{}]{}.[/] {}",
RchTheme::SECONDARY,
i + 1,
suggestion
));
}
}
if self.truncated {
lines.push(String::new());
lines.push(format!(
"[{}](Use --verbose for full message)[/]",
RchTheme::DIM
));
}
if let Some(ref trace) = self.stack_trace {
lines.push(String::new());
lines.push(format!("[{}]Stack trace:[/]", RchTheme::DIM));
for line in trace.lines() {
lines.push(format!("[{}] {line}[/]", RchTheme::DIM));
}
}
lines.push(String::new());
let timestamp_icon = Icons::clock(ctx);
lines.push(format!(
"[{}]{timestamp_icon} {}[/]",
RchTheme::DIM,
self.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
));
lines.join("\n")
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
pub fn to_json_compact(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
}
impl std::fmt::Display for ErrorPanel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}: {}", self.severity, self.code, self.title)?;
if let Some(ref msg) = self.message {
write!(f, " - {msg}")?;
}
Ok(())
}
}
impl std::error::Error for ErrorPanel {}
pub fn show_error(code: &str, title: &str, message: &str, ctx: OutputContext) {
ErrorPanel::error(code, title).message(message).render(ctx);
}
pub fn show_warning(code: &str, title: &str, message: &str, ctx: OutputContext) {
ErrorPanel::warning(code, title)
.message(message)
.render(ctx);
}
pub fn show_info(code: &str, title: &str, message: &str, ctx: OutputContext) {
ErrorPanel::info(code, title).message(message).render(ctx);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_panel_creation() {
let error = ErrorPanel::new("RCH-E042", "Test Error");
assert_eq!(error.code, "RCH-E042");
assert_eq!(error.title, "Test Error");
assert_eq!(error.severity, ErrorSeverity::Error);
assert!(error.message.is_none());
assert!(error.context.is_empty());
assert!(error.suggestions.is_empty());
}
#[test]
fn test_error_panel_builder() {
let error = ErrorPanel::error("RCH-E001", "Connection Failed")
.message("Could not connect to worker")
.context("Host", "192.168.1.10")
.context("Port", "22")
.suggestion("Check if worker is online")
.suggestion("Verify SSH key");
assert_eq!(error.severity, ErrorSeverity::Error);
assert_eq!(
error.message.as_deref(),
Some("Could not connect to worker")
);
assert_eq!(error.context.len(), 2);
assert_eq!(error.suggestions.len(), 2);
}
#[test]
fn test_warning_panel() {
let warning = ErrorPanel::warning("RCH-W001", "Slow Response");
assert_eq!(warning.severity, ErrorSeverity::Warning);
}
#[test]
fn test_info_panel() {
let info = ErrorPanel::info("RCH-I001", "Build Complete");
assert_eq!(info.severity, ErrorSeverity::Info);
}
#[test]
fn test_message_truncation() {
let long_message = "a".repeat(1000);
let error = ErrorPanel::new("RCH-E001", "Test").message_truncated(long_message, 100);
assert!(error.truncated);
assert!(error.message.as_ref().unwrap().len() <= 100);
assert!(error.message.as_ref().unwrap().ends_with("..."));
}
#[test]
fn test_message_no_truncation_needed() {
let short_message = "Short message";
let error = ErrorPanel::new("RCH-E001", "Test").message_truncated(short_message, 100);
assert!(!error.truncated);
assert_eq!(error.message.as_deref(), Some("Short message"));
}
#[test]
fn test_error_chaining() {
let error = ErrorPanel::error("RCH-E042", "Connection Failed")
.caused_by("SSH handshake failed", Some("SSH-001".to_string()))
.caused_by("Network unreachable", None);
assert_eq!(error.caused_by.len(), 2);
assert_eq!(error.caused_by[0].code, Some("SSH-001".to_string()));
assert!(error.caused_by[1].code.is_none());
}
#[test]
fn test_json_serialization() {
let error = ErrorPanel::error("RCH-E001", "Test Error")
.message("Test message")
.context("Key", "Value");
let json = error.to_json().expect("JSON serialization failed");
assert!(json.contains("RCH-E001"));
assert!(json.contains("Test Error"));
assert!(json.contains("Test message"));
}
#[test]
fn test_json_compact_serialization() {
let error = ErrorPanel::error("RCH-E001", "Test");
let json = error.to_json_compact().expect("JSON serialization failed");
assert!(!json.contains('\n'));
}
#[test]
fn test_display_implementation() {
let error = ErrorPanel::error("RCH-E001", "Test Error").message("Details here");
let display = format!("{error}");
assert!(display.contains("ERROR"));
assert!(display.contains("RCH-E001"));
assert!(display.contains("Test Error"));
assert!(display.contains("Details here"));
}
#[test]
fn test_severity_colors() {
assert_eq!(ErrorSeverity::Error.color(), RchTheme::ERROR);
assert_eq!(ErrorSeverity::Warning.color(), RchTheme::WARNING);
assert_eq!(ErrorSeverity::Info.color(), RchTheme::INFO);
}
#[test]
fn test_severity_names() {
assert_eq!(ErrorSeverity::Error.name(), "ERROR");
assert_eq!(ErrorSeverity::Warning.name(), "WARN");
assert_eq!(ErrorSeverity::Info.name(), "INFO");
}
#[test]
fn test_severity_icons_plain() {
let ctx = OutputContext::Plain;
assert!(!ErrorSeverity::Error.icon(ctx).is_empty());
assert!(!ErrorSeverity::Warning.icon(ctx).is_empty());
assert!(!ErrorSeverity::Info.icon(ctx).is_empty());
}
#[test]
fn test_render_plain_mode() {
let error = ErrorPanel::error("RCH-E001", "Test")
.message("Message")
.context("Key", "Value")
.suggestion("Do something");
error.render(OutputContext::Plain);
}
#[test]
fn test_render_machine_mode_silent() {
let error = ErrorPanel::error("RCH-E001", "Test");
error.render(OutputContext::Machine);
}
#[test]
fn test_render_hook_mode_silent() {
let error = ErrorPanel::error("RCH-E001", "Test");
error.render(OutputContext::Hook);
}
#[test]
fn test_stack_trace() {
let error =
ErrorPanel::error("RCH-E001", "Test").stack_trace("at main.rs:42\nat lib.rs:100");
assert!(error.stack_trace.is_some());
assert!(error.stack_trace.as_ref().unwrap().contains("main.rs:42"));
}
#[test]
fn test_multiple_suggestions() {
let error = ErrorPanel::error("RCH-E001", "Test").suggestions([
"First suggestion",
"Second suggestion",
"Third suggestion",
]);
assert_eq!(error.suggestions.len(), 3);
}
#[test]
fn test_default_severity() {
let severity = ErrorSeverity::default();
assert_eq!(severity, ErrorSeverity::Error);
}
#[test]
fn test_convenience_functions_dont_panic() {
show_error("E001", "Test", "Message", OutputContext::Plain);
show_warning("W001", "Test", "Message", OutputContext::Plain);
show_info("I001", "Test", "Message", OutputContext::Plain);
}
#[test]
fn test_error_panel_is_error_trait() {
let error: Box<dyn std::error::Error> = Box::new(ErrorPanel::error("RCH-E001", "Test"));
let _ = format!("{error}");
}
}