Skip to main content

rch_common/ui/
error.rs

1//! ErrorPanel - Consistent, actionable error display for RCH.
2//!
3//! This module provides the base error display component with:
4//! - Red-bordered panel with error icon and title
5//! - Error code in header (RCH-Exxx format)
6//! - Context section with relevant details
7//! - Suggestion section with remediation steps
8//! - Optional stack trace (collapsed by default)
9//! - JSON serialization for machine output
10//!
11//! # Example
12//!
13//! ```ignore
14//! use rch_common::ui::error::{ErrorPanel, ErrorSeverity};
15//!
16//! let error = ErrorPanel::new("RCH-E042", "Worker Connection Failed")
17//!     .message("Could not establish SSH connection to worker 'build1'")
18//!     .context("Host", "build1.internal (192.168.1.50:22)")
19//!     .context("Timeout", "30s elapsed")
20//!     .context("Last successful", "2h 15m ago")
21//!     .suggestion("Check if worker is online: ssh build1.internal")
22//!     .suggestion("Verify SSH key: ssh-add -l")
23//!     .suggestion("Run: rch workers probe build1 --verbose");
24//!
25//! // Render to console
26//! error.render(&console);
27//!
28//! // Or serialize to JSON
29//! let json = serde_json::to_string(&error)?;
30//! ```
31
32use 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/// Error severity level.
43///
44/// Determines the color and icon used for display.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "lowercase")]
47pub enum ErrorSeverity {
48    /// Critical/error level - red border, cross icon.
49    #[default]
50    Error,
51    /// Warning level - amber border, warning icon.
52    Warning,
53    /// Informational level - blue border, info icon.
54    Info,
55}
56
57impl ErrorSeverity {
58    /// Get the color hex code for this severity level.
59    #[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    /// Get the icon function for this severity level.
69    #[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    /// Get the severity name for display.
79    #[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/// A key-value context item for error details.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ErrorContext {
98    /// The context key (e.g., "Host", "Timeout").
99    pub key: String,
100    /// The context value (e.g., "build1.internal", "30s").
101    pub value: String,
102}
103
104/// An error that caused this error (for error chaining).
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct CausedBy {
107    /// Brief description of the cause.
108    pub message: String,
109    /// Optional error code of the cause.
110    pub code: Option<String>,
111}
112
113/// ErrorPanel - The base error display component for RCH.
114///
115/// Provides consistent, actionable error messages with:
116/// - Error code and title
117/// - Main message
118/// - Context key-value pairs
119/// - Remediation suggestions
120/// - Error chaining support
121/// - Timestamp for debugging
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ErrorPanel {
124    /// Error code in RCH-Exxx format.
125    pub code: String,
126
127    /// Short error title (e.g., "Worker Connection Failed").
128    pub title: String,
129
130    /// Main error message with details.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub message: Option<String>,
133
134    /// Error severity level.
135    pub severity: ErrorSeverity,
136
137    /// Context key-value pairs (e.g., Host, Timeout).
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub context: Vec<ErrorContext>,
140
141    /// Remediation suggestions (numbered list).
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub suggestions: Vec<String>,
144
145    /// Error chain (caused by).
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub caused_by: Vec<CausedBy>,
148
149    /// Stack trace (usually collapsed/hidden).
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub stack_trace: Option<String>,
152
153    /// Timestamp when the error occurred.
154    pub timestamp: DateTime<Utc>,
155
156    /// Whether the message was truncated (show --verbose hint).
157    #[serde(default)]
158    pub truncated: bool,
159}
160
161impl ErrorPanel {
162    /// Create a new ErrorPanel with code and title.
163    ///
164    /// # Arguments
165    ///
166    /// * `code` - Error code (e.g., "RCH-E042")
167    /// * `title` - Short error title (e.g., "Worker Connection Failed")
168    #[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    /// Create an error-level ErrorPanel (red, cross icon).
185    #[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    /// Create a warning-level ErrorPanel (amber, warning icon).
191    #[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    /// Create an info-level ErrorPanel (blue, info icon).
197    #[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    /// Set the severity level.
203    #[must_use]
204    pub fn with_severity(mut self, severity: ErrorSeverity) -> Self {
205        self.severity = severity;
206        self
207    }
208
209    /// Set the main error message.
210    #[must_use]
211    pub fn message(mut self, message: impl Into<String>) -> Self {
212        self.message = Some(message.into());
213        self
214    }
215
216    /// Set the main error message, truncating if too long.
217    ///
218    /// If the message exceeds `max_len` characters, it will be truncated
219    /// and `truncated` will be set to true (shows --verbose hint).
220    #[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    /// Add a context key-value pair.
233    #[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    /// Add a remediation suggestion.
243    #[must_use]
244    pub fn suggestion(mut self, suggestion: impl Into<String>) -> Self {
245        self.suggestions.push(suggestion.into());
246        self
247    }
248
249    /// Add multiple suggestions at once.
250    #[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    /// Add a caused-by error (for error chaining).
262    #[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    /// Add a stack trace (shown in verbose mode or dim).
272    #[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    /// Set the timestamp explicitly.
279    #[must_use]
280    pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
281        self.timestamp = timestamp;
282        self
283    }
284
285    // ========================================================================
286    // RENDERING
287    // ========================================================================
288
289    /// Render the error panel to stderr.
290    ///
291    /// Automatically selects rich or plain output based on context.
292    pub fn render(&self, ctx: OutputContext) {
293        if ctx.is_machine() {
294            // Machine mode - render nothing, caller should use to_json()
295            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    /// Render using rich_rust Panel.
308    #[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        // Print to stderr via Console
323        let console = Console::builder().force_terminal(true).build();
324        console.print_renderable(&panel);
325    }
326
327    /// Render plain text to stderr.
328    fn render_plain(&self, ctx: OutputContext) {
329        let icon = self.severity.icon(ctx);
330        let severity = self.severity.name();
331
332        // Header line
333        eprintln!("{icon} [{severity}] {}: {}", self.code, self.title);
334
335        // Main message
336        if let Some(ref msg) = self.message {
337            eprintln!();
338            eprintln!("{msg}");
339        }
340
341        // Context section
342        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        // Error chain
351        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        // Suggestions
364        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        // Truncation hint
373        if self.truncated {
374            eprintln!();
375            eprintln!("(Use --verbose for full message)");
376        }
377
378        // Stack trace (dim)
379        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        // Timestamp
388        eprintln!();
389        eprintln!("[{}]", self.timestamp.format("%Y-%m-%d %H:%M:%S UTC"));
390    }
391
392    /// Build content string for panel body.
393    #[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        // Main message
398        if let Some(ref msg) = self.message {
399            lines.push(msg.clone());
400        }
401
402        // Context section
403        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        // Error chain
417        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        // Suggestions
430        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        // Truncation hint
444        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        // Stack trace (dim)
453        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        // Timestamp
462        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    /// Serialize to JSON string.
474    ///
475    /// Use this for --json mode output.
476    pub fn to_json(&self) -> serde_json::Result<String> {
477        serde_json::to_string_pretty(self)
478    }
479
480    /// Serialize to compact JSON string.
481    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
498// ============================================================================
499// CONVENIENCE FUNCTIONS
500// ============================================================================
501
502/// Create an error panel and render it immediately.
503///
504/// For quick error display without building the full panel manually.
505pub fn show_error(code: &str, title: &str, message: &str, ctx: OutputContext) {
506    ErrorPanel::error(code, title).message(message).render(ctx);
507}
508
509/// Create a warning panel and render it immediately.
510pub fn show_warning(code: &str, title: &str, message: &str, ctx: OutputContext) {
511    ErrorPanel::warning(code, title)
512        .message(message)
513        .render(ctx);
514}
515
516/// Create an info panel and render it immediately.
517pub 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        // Compact JSON should not have newlines
613        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        // Just verify they don't panic and return something
644        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        // Should not panic
657        error.render(OutputContext::Plain);
658    }
659
660    #[test]
661    fn test_render_machine_mode_silent() {
662        let error = ErrorPanel::error("RCH-E001", "Test");
663        // Should not output anything in machine mode
664        error.render(OutputContext::Machine);
665    }
666
667    #[test]
668    fn test_render_hook_mode_silent() {
669        let error = ErrorPanel::error("RCH-E001", "Test");
670        // Should not output anything in hook mode
671        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        // These should not panic even in Plain mode
703        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        // Should be usable as a std::error::Error
712        let _ = format!("{error}");
713    }
714}