Skip to main content

rch_common/ui/
display.rs

1//! Error display utilities for unified error handling.
2//!
3//! This module provides the `display_error()` helper function that:
4//! - Renders errors using ErrorPanel for rich/plain terminal output
5//! - Falls back to simple text for non-TTY output
6//! - Respects NO_COLOR and FORCE_COLOR environment variables
7//! - Supports JSON serialization for machine output
8//!
9//! # Example
10//!
11//! ```ignore
12//! use rch_common::ui::{display_error, OutputContext};
13//! use anyhow::anyhow;
14//!
15//! let err = anyhow!("Something went wrong");
16//! let ctx = OutputContext::detect();
17//!
18//! display_error(&err, &ctx);
19//! ```
20
21use crate::errors::catalog::ErrorCode;
22
23use super::{ErrorPanel, OutputContext};
24
25/// Display an error using the appropriate format for the output context.
26///
27/// This function:
28/// - Detects if stdout/stderr is a TTY
29/// - Respects NO_COLOR and FORCE_COLOR environment variables
30/// - Uses ErrorPanel for rich output when available
31/// - Falls back to simple text for non-TTY/machine output
32/// - Logs the full error chain to debug log regardless of display mode
33pub fn display_error<E: std::error::Error>(error: &E, ctx: &OutputContext) {
34    // Log full error chain to debug log
35    tracing::debug!("Error occurred: {}", error);
36    let mut source = error.source();
37    while let Some(err) = source {
38        tracing::debug!("  Caused by: {}", err);
39        source = err.source();
40    }
41
42    // For machine output, do nothing - caller should use to_json()
43    if ctx.is_machine() {
44        return;
45    }
46
47    // Create ErrorPanel from the error
48    let panel = error_to_panel(error);
49
50    // Render the panel
51    panel.render(*ctx);
52}
53
54/// Display an error with a specific error code.
55///
56/// Uses the error catalog to provide consistent error codes and remediation.
57pub fn display_error_with_code<E: std::error::Error>(
58    error: &E,
59    code: ErrorCode,
60    ctx: &OutputContext,
61) {
62    // Log full error chain to debug log
63    tracing::debug!("Error occurred [{}]: {}", code.code_string(), error);
64    let mut source = error.source();
65    while let Some(err) = source {
66        tracing::debug!("  Caused by: {}", err);
67        source = err.source();
68    }
69
70    // For machine output, do nothing - caller should use to_json()
71    if ctx.is_machine() {
72        return;
73    }
74
75    // Create ErrorPanel from error code with additional context
76    let entry = code.entry();
77    let mut panel = ErrorPanel::error(&entry.code, &entry.message).message(error.to_string());
78
79    // Add error chain
80    let mut source = error.source();
81    while let Some(err) = source {
82        panel = panel.caused_by(err.to_string(), None);
83        source = err.source();
84    }
85
86    // Add remediation from catalog
87    for step in entry.remediation {
88        panel = panel.suggestion(step);
89    }
90
91    // Render the panel
92    panel.render(*ctx);
93}
94
95/// Convert any error to an ErrorPanel.
96///
97/// This function attempts to extract structured information from the error,
98/// falling back to generic error display if specific handling isn't available.
99pub fn error_to_panel<E: std::error::Error>(error: &E) -> ErrorPanel {
100    let error_string = error.to_string();
101
102    // Try to extract error code from the error message (RCH-Exxx pattern)
103    let (code, title) = extract_error_info(&error_string);
104
105    let mut panel = ErrorPanel::error(&code, &title);
106
107    // Set the full error message
108    if title != error_string {
109        panel = panel.message(error_string);
110    }
111
112    // Add error chain as caused_by
113    let mut source = error.source();
114    while let Some(err) = source {
115        panel = panel.caused_by(err.to_string(), None);
116        source = err.source();
117    }
118
119    panel
120}
121
122/// Extract error code and title from an error message.
123///
124/// Looks for patterns like "RCH-E042: Something failed" and extracts
125/// the code and message separately.
126fn extract_error_info(message: &str) -> (String, String) {
127    // Try to match RCH-Exxx pattern
128    if let Some(caps) = extract_rch_code(message) {
129        return caps;
130    }
131
132    // Default to generic error
133    ("RCH-E500".to_string(), message.to_string())
134}
135
136/// Extract RCH error code from message if present.
137fn extract_rch_code(message: &str) -> Option<(String, String)> {
138    // Look for "RCH-Exxx" pattern
139    let prefix = "RCH-E";
140    if let Some(start) = message.find(prefix) {
141        let code_start = start;
142        let after_prefix = start + prefix.len();
143
144        // Find the end of the error code (digits)
145        let code_end = message[after_prefix..]
146            .chars()
147            .take_while(|c| c.is_ascii_digit())
148            .count()
149            + after_prefix;
150
151        if code_end > after_prefix {
152            let code = message[code_start..code_end].to_string();
153
154            // Extract the message after the code
155            let rest = &message[code_end..];
156            let title = if let Some(stripped) = rest.strip_prefix(": ") {
157                stripped.to_string()
158            } else if let Some(stripped) = rest.strip_prefix("] ") {
159                stripped.to_string()
160            } else {
161                rest.trim_start_matches(&[' ', ':', ']'][..]).to_string()
162            };
163
164            return Some((
165                code,
166                if title.is_empty() {
167                    message.to_string()
168                } else {
169                    title
170                },
171            ));
172        }
173    }
174
175    None
176}
177
178/// Convert an anyhow::Error to an ErrorPanel.
179pub fn anyhow_to_panel(error: &anyhow::Error) -> ErrorPanel {
180    let error_string = error.to_string();
181    let (code, title) = extract_error_info(&error_string);
182
183    let mut panel = ErrorPanel::error(&code, &title);
184
185    // Set the full error message
186    if title != error_string {
187        panel = panel.message(error_string);
188    }
189
190    // Add error chain
191    for cause in error.chain().skip(1) {
192        panel = panel.caused_by(cause.to_string(), None);
193    }
194
195    panel
196}
197
198/// Display an anyhow::Error using ErrorPanel.
199pub fn display_anyhow_error(error: &anyhow::Error, ctx: &OutputContext) {
200    // Log full error chain to debug log
201    tracing::debug!("Error occurred: {}", error);
202    for cause in error.chain().skip(1) {
203        tracing::debug!("  Caused by: {}", cause);
204    }
205
206    // For machine output, do nothing - caller should use to_json()
207    if ctx.is_machine() {
208        return;
209    }
210
211    let panel = anyhow_to_panel(error);
212    panel.render(*ctx);
213}
214
215/// Get JSON representation of an error for machine output.
216pub fn error_to_json<E: std::error::Error>(error: &E) -> serde_json::Result<String> {
217    let panel = error_to_panel(error);
218    panel.to_json()
219}
220
221/// Get JSON representation of an anyhow::Error for machine output.
222pub fn anyhow_to_json(error: &anyhow::Error) -> serde_json::Result<String> {
223    let panel = anyhow_to_panel(error);
224    panel.to_json()
225}
226
227/// Trait for errors that can be converted to ErrorPanel.
228pub trait IntoErrorPanel {
229    /// Convert this error into an ErrorPanel.
230    fn into_panel(self) -> ErrorPanel;
231}
232
233impl<E: std::error::Error> IntoErrorPanel for E {
234    fn into_panel(self) -> ErrorPanel {
235        error_to_panel(&self)
236    }
237}
238
239/// Extension trait for Result types to display errors.
240#[allow(clippy::result_unit_err)]
241pub trait ResultExt<T> {
242    /// Display the error if present using the given output context.
243    fn display_err(self, ctx: &OutputContext) -> Result<T, ()>;
244
245    /// Display the error if present and exit with the given code.
246    fn display_and_exit(self, ctx: &OutputContext, exit_code: i32) -> T;
247}
248
249impl<T, E: std::error::Error> ResultExt<T> for Result<T, E> {
250    fn display_err(self, ctx: &OutputContext) -> Result<T, ()> {
251        match self {
252            Ok(v) => Ok(v),
253            Err(e) => {
254                display_error(&e, ctx);
255                Err(())
256            }
257        }
258    }
259
260    fn display_and_exit(self, ctx: &OutputContext, exit_code: i32) -> T {
261        match self {
262            Ok(v) => v,
263            Err(e) => {
264                display_error(&e, ctx);
265                std::process::exit(exit_code);
266            }
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use std::io;
275
276    #[test]
277    fn test_extract_rch_code_with_colon() {
278        let result = extract_rch_code("RCH-E042: Worker Connection Failed");
279        assert_eq!(
280            result,
281            Some((
282                "RCH-E042".to_string(),
283                "Worker Connection Failed".to_string()
284            ))
285        );
286    }
287
288    #[test]
289    fn test_extract_rch_code_with_bracket() {
290        let result = extract_rch_code("[RCH-E100] SSH failed");
291        assert_eq!(
292            result,
293            Some(("RCH-E100".to_string(), "SSH failed".to_string()))
294        );
295    }
296
297    #[test]
298    fn test_extract_rch_code_no_code() {
299        let result = extract_rch_code("Some random error message");
300        assert_eq!(result, None);
301    }
302
303    #[test]
304    fn test_extract_error_info_with_code() {
305        let (code, title) = extract_error_info("RCH-E502: Daemon not running");
306        assert_eq!(code, "RCH-E502");
307        assert_eq!(title, "Daemon not running");
308    }
309
310    #[test]
311    fn test_extract_error_info_without_code() {
312        let (code, title) = extract_error_info("Something went wrong");
313        assert_eq!(code, "RCH-E500");
314        assert_eq!(title, "Something went wrong");
315    }
316
317    #[test]
318    fn test_error_to_panel_simple() {
319        let err = io::Error::new(io::ErrorKind::NotFound, "file not found");
320        let panel = error_to_panel(&err);
321
322        assert_eq!(panel.code, "RCH-E500");
323        assert!(panel.title.contains("not found"));
324    }
325
326    #[test]
327    fn test_error_to_panel_with_rch_code() {
328        // Create a custom error type that includes RCH code
329        #[derive(Debug)]
330        struct RchTestError;
331        impl std::fmt::Display for RchTestError {
332            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333                write!(f, "RCH-E042: Test error message")
334            }
335        }
336        impl std::error::Error for RchTestError {}
337
338        let err = RchTestError;
339        let panel = error_to_panel(&err);
340
341        assert_eq!(panel.code, "RCH-E042");
342        assert_eq!(panel.title, "Test error message");
343    }
344
345    #[test]
346    fn test_error_to_panel_with_source() {
347        let inner = io::Error::new(io::ErrorKind::PermissionDenied, "access denied");
348        let outer = io::Error::other(inner.to_string());
349
350        let panel = error_to_panel(&outer);
351
352        // Should capture the error message
353        assert!(panel.title.contains("access denied"));
354    }
355
356    #[test]
357    fn test_display_error_machine_mode_silent() {
358        let err = io::Error::new(io::ErrorKind::NotFound, "test error");
359        let ctx = OutputContext::Machine;
360
361        // Should not panic and not produce output
362        display_error(&err, &ctx);
363    }
364
365    #[test]
366    fn test_display_error_plain_mode() {
367        let err = io::Error::new(io::ErrorKind::NotFound, "test error");
368        let ctx = OutputContext::Plain;
369
370        // Should not panic
371        display_error(&err, &ctx);
372    }
373
374    #[test]
375    fn test_anyhow_to_panel() {
376        let err = anyhow::anyhow!("RCH-E100: SSH connection failed");
377        let panel = anyhow_to_panel(&err);
378
379        assert_eq!(panel.code, "RCH-E100");
380        assert_eq!(panel.title, "SSH connection failed");
381    }
382
383    #[test]
384    fn test_anyhow_to_panel_with_context() {
385        let err = anyhow::anyhow!("inner error").context("outer context");
386        let panel = anyhow_to_panel(&err);
387
388        // Should capture the outer context
389        assert!(panel.title.contains("outer context") || panel.message.is_some());
390    }
391
392    #[test]
393    fn test_error_to_json() {
394        let err = io::Error::new(io::ErrorKind::NotFound, "file not found");
395        let json = error_to_json(&err).expect("JSON serialization failed");
396
397        assert!(json.contains("RCH-E500"));
398        assert!(json.contains("not found"));
399    }
400
401    #[test]
402    fn test_anyhow_to_json() {
403        let err = anyhow::anyhow!("test error");
404        let json = anyhow_to_json(&err).expect("JSON serialization failed");
405
406        assert!(json.contains("RCH-E500"));
407        assert!(json.contains("test error"));
408    }
409
410    #[test]
411    fn test_result_ext_display_err_ok() {
412        let result: Result<i32, io::Error> = Ok(42);
413        let ctx = OutputContext::Plain;
414
415        assert_eq!(result.display_err(&ctx), Ok(42));
416    }
417
418    #[test]
419    fn test_result_ext_display_err_error() {
420        let result: Result<i32, io::Error> =
421            Err(io::Error::new(io::ErrorKind::NotFound, "not found"));
422        let ctx = OutputContext::Plain;
423
424        assert_eq!(result.display_err(&ctx), Err(()));
425    }
426}