Skip to main content

error_forge/
console_theme.rs

1//! Console theming for error display in CLI applications.
2//!
3//! This module provides ANSI color formatting for error messages
4//! displayed in terminal environments. It auto-detects terminal
5//! capabilities via [`std::io::IsTerminal`] and disables colors when
6//! stderr is not a TTY, when `TERM=dumb`, or when `NO_COLOR` is set
7//! (<https://no-color.org/>).
8
9use std::io::IsTerminal;
10
11/// Color theme for console error output.
12///
13/// The fields are `&'static str` ANSI escapes — no allocation per
14/// construction, and `const`-constructible for the three preset
15/// constructors ([`ConsoleTheme::with_colors`], [`ConsoleTheme::plain`]).
16pub struct ConsoleTheme {
17    error_color: &'static str,
18    warning_color: &'static str,
19    info_color: &'static str,
20    success_color: &'static str,
21    caption_color: &'static str,
22    reset: &'static str,
23    bold: &'static str,
24    dim: &'static str,
25}
26
27/// Detect if the current terminal supports ANSI colors.
28fn terminal_supports_ansi() -> bool {
29    // Cache the answer for the process. The decision is based on
30    // env vars + the `stderr` handle, both of which are effectively
31    // process-static, so caching is correct (and removes per-call
32    // env-var reads from the hot path).
33    static SUPPORTS_ANSI: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
34
35    *SUPPORTS_ANSI.get_or_init(|| {
36        // Stderr must be a terminal — applies to every platform.
37        if !std::io::stderr().is_terminal() {
38            return false;
39        }
40
41        // Explicit terminal-dumb signal.
42        if let Ok(term) = std::env::var("TERM") {
43            if term == "dumb" {
44                return false;
45            }
46        }
47
48        // <https://no-color.org/>: any non-empty `NO_COLOR` disables.
49        if std::env::var_os("NO_COLOR").is_some() {
50            return false;
51        }
52
53        // Windows Terminal explicitly signals ANSI support.
54        #[cfg(windows)]
55        {
56            if std::env::var_os("WT_SESSION").is_some() {
57                return true;
58            }
59        }
60
61        // Default to enabled on every supported platform — modern
62        // Windows builds (10.0.10586+) honour ANSI escapes in stderr.
63        true
64    })
65}
66
67impl Default for ConsoleTheme {
68    fn default() -> Self {
69        if terminal_supports_ansi() {
70            Self::with_colors()
71        } else {
72            Self::plain()
73        }
74    }
75}
76
77impl ConsoleTheme {
78    /// Create a new theme with default colors. Auto-detects terminal
79    /// color support; falls back to [`Self::plain`] if stderr is not
80    /// a TTY, `TERM=dumb`, or `NO_COLOR` is set.
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Create a new theme with colors forced on, regardless of
86    /// terminal detection.
87    pub const fn with_colors() -> Self {
88        Self {
89            error_color: "\x1b[31m",   // Red
90            warning_color: "\x1b[33m", // Yellow
91            info_color: "\x1b[34m",    // Blue
92            success_color: "\x1b[32m", // Green
93            caption_color: "\x1b[36m", // Cyan
94            reset: "\x1b[0m",
95            bold: "\x1b[1m",
96            dim: "\x1b[2m",
97        }
98    }
99
100    /// Create a new theme with no colors (plain text). Useful for
101    /// piping output to a file or non-TTY consumer.
102    pub const fn plain() -> Self {
103        Self {
104            error_color: "",
105            warning_color: "",
106            info_color: "",
107            success_color: "",
108            caption_color: "",
109            reset: "",
110            bold: "",
111            dim: "",
112        }
113    }
114
115    /// Format an error message with the error color.
116    pub fn error(&self, text: &str) -> String {
117        format!("{}{}{}", self.error_color, text, self.reset)
118    }
119
120    /// Format a warning message with the warning color.
121    pub fn warning(&self, text: &str) -> String {
122        format!("{}{}{}", self.warning_color, text, self.reset)
123    }
124
125    /// Format an info message with the info color.
126    pub fn info(&self, text: &str) -> String {
127        format!("{}{}{}", self.info_color, text, self.reset)
128    }
129
130    /// Format a success message with the success color.
131    pub fn success(&self, text: &str) -> String {
132        format!("{}{}{}", self.success_color, text, self.reset)
133    }
134
135    /// Format a caption with the caption color.
136    pub fn caption(&self, text: &str) -> String {
137        format!("{}{}{}", self.caption_color, text, self.reset)
138    }
139
140    /// Format text as bold.
141    pub fn bold(&self, text: &str) -> String {
142        format!("{}{}{}", self.bold, text, self.reset)
143    }
144
145    /// Format text as dim.
146    pub fn dim(&self, text: &str) -> String {
147        format!("{}{}{}", self.dim, text, self.reset)
148    }
149
150    /// Format an error display in a structured way.
151    ///
152    /// Writes the caption, the error's `Display` output, the
153    /// retryability marker, and the optional source chain into a
154    /// single `String` buffer. Allocates exactly once.
155    pub fn format_error<E: crate::error::ForgeError>(&self, err: &E) -> String {
156        use std::fmt::Write as _;
157        let mut buf = String::with_capacity(160);
158
159        // Caption — written via the helper formatters so the colour
160        // escapes match the rest of the output.
161        let _ = writeln!(buf, "{}", self.caption(&format!("⚠️  {}", err.caption())));
162
163        // Error message.
164        let _ = writeln!(buf, "{}", self.error(&err.to_string()));
165
166        // Retryable status.
167        let marker = if err.is_retryable() {
168            self.success("Yes")
169        } else {
170            self.error("No")
171        };
172        let _ = writeln!(buf, "{}Retryable: {}{}", self.dim, marker, self.reset);
173
174        // Source error if available.
175        if let Some(source) = err.source() {
176            let _ = writeln!(
177                buf,
178                "{}Caused by: {}{}",
179                self.dim,
180                self.error(&source.to_string()),
181                self.reset
182            );
183        }
184
185        buf
186    }
187}
188
189/// Pretty-print an error to stderr with the default theme.
190///
191/// The default theme is cached process-wide via `OnceLock` — the
192/// terminal-capability check runs at most once regardless of how
193/// many errors are printed.
194pub fn print_error<E: crate::error::ForgeError>(err: &E) {
195    static DEFAULT_THEME: std::sync::OnceLock<ConsoleTheme> = std::sync::OnceLock::new();
196    let theme = DEFAULT_THEME.get_or_init(ConsoleTheme::default);
197    eprintln!("{}", theme.format_error(err));
198}
199
200/// Install a panic hook that formats panics using the ConsoleTheme
201pub fn install_panic_hook() {
202    let theme = ConsoleTheme::default();
203    std::panic::set_hook(Box::new(move |panic_info| {
204        let message = match panic_info.payload().downcast_ref::<&str>() {
205            Some(s) => *s,
206            None => match panic_info.payload().downcast_ref::<String>() {
207                Some(s) => s.as_str(),
208                None => "Unknown panic",
209            },
210        };
211
212        let location = if let Some(location) = panic_info.location() {
213            format!("at {}:{}", location.file(), location.line())
214        } else {
215            "at unknown location".to_string()
216        };
217
218        eprintln!("{}", theme.caption("💥 PANIC"));
219        eprintln!(
220            "{}",
221            theme.error(&format!("{} {}", message, theme.dim(&location)))
222        );
223    }));
224}