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 automatically detects
5//! terminal capabilities and disables colors when appropriate.
6
7/// Color theme for console error output
8pub struct ConsoleTheme {
9    error_color: String,
10    warning_color: String,
11    info_color: String,
12    success_color: String,
13    caption_color: String,
14    reset: String,
15    bold: String,
16    dim: String,
17}
18
19/// Detect if the current terminal supports ANSI colors
20fn terminal_supports_ansi() -> bool {
21    #[cfg(windows)]
22    {
23        // On Windows, need special detection logic
24        // Windows 10 build 10586+ supports ANSI, but cmd.exe may have it disabled
25        static WINDOWS_ANSI_SUPPORT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
26        
27        *WINDOWS_ANSI_SUPPORT.get_or_init(|| {
28            // Check if stderr is a TTY
29            if !atty::is(atty::Stream::Stderr) {
30                return false;
31            }
32            
33            // Check for TERM environment variable
34            if let Ok(term) = std::env::var("TERM") {
35                if term == "dumb" {
36                    return false;
37                }
38            }
39            
40            // Check if NO_COLOR is set (https://no-color.org/)
41            if std::env::var_os("NO_COLOR").is_some() {
42                return false;
43            }
44            
45            // Check if we're in Windows Terminal, which supports ANSI
46            if std::env::var_os("WT_SESSION").is_some() {
47                return true;
48            }
49            
50            // Default to enabled for modern Windows
51            true
52        })
53    }
54    
55    #[cfg(not(windows))]
56    {
57        // Unix-like systems generally support ANSI if it's a TTY
58        if !atty::is(atty::Stream::Stderr) {
59            return false;
60        }
61        
62        // Check for TERM=dumb
63        if let Ok(term) = std::env::var("TERM") {
64            if term == "dumb" {
65                return false;
66            }
67        }
68        
69        // Check if NO_COLOR is set (https://no-color.org/)
70        if std::env::var_os("NO_COLOR").is_some() {
71            return false;
72        }
73        
74        true
75    }
76}
77
78impl Default for ConsoleTheme {
79    fn default() -> Self {
80        if terminal_supports_ansi() {
81            Self {
82                error_color: "\x1b[31m".to_string(),   // Red
83                warning_color: "\x1b[33m".to_string(), // Yellow
84                info_color: "\x1b[34m".to_string(),    // Blue
85                success_color: "\x1b[32m".to_string(), // Green
86                caption_color: "\x1b[36m".to_string(), // Cyan
87                reset: "\x1b[0m".to_string(),
88                bold: "\x1b[1m".to_string(),
89                dim: "\x1b[2m".to_string(),
90            }
91        } else {
92            // No color support detected
93            Self::plain()
94        }
95    }
96}
97
98impl ConsoleTheme {
99    /// Create a new theme with default colors
100    /// Auto-detects terminal color support
101    pub fn new() -> Self {
102        Self::default()
103    }
104    
105    /// Create a new theme with forced colors
106    pub fn with_colors() -> Self {
107        Self {
108            error_color: "\x1b[31m".to_string(),   // Red
109            warning_color: "\x1b[33m".to_string(), // Yellow
110            info_color: "\x1b[34m".to_string(),    // Blue
111            success_color: "\x1b[32m".to_string(), // Green
112            caption_color: "\x1b[36m".to_string(), // Cyan
113            reset: "\x1b[0m".to_string(),
114            bold: "\x1b[1m".to_string(),
115            dim: "\x1b[2m".to_string(),
116        }
117    }
118    
119    /// Create a new theme with no colors (plain text)
120    pub fn plain() -> Self {
121        Self {
122            error_color: "".to_string(),
123            warning_color: "".to_string(),
124            info_color: "".to_string(),
125            success_color: "".to_string(),
126            caption_color: "".to_string(),
127            reset: "".to_string(),
128            bold: "".to_string(),
129            dim: "".to_string(),
130        }
131    }
132    
133    /// Format an error message with the error color
134    pub fn error(&self, text: &str) -> String {
135        format!("{}{}{}", self.error_color, text, self.reset)
136    }
137    
138    /// Format a warning message with the warning color
139    pub fn warning(&self, text: &str) -> String {
140        format!("{}{}{}", self.warning_color, text, self.reset)
141    }
142    
143    /// Format an info message with the info color
144    pub fn info(&self, text: &str) -> String {
145        format!("{}{}{}", self.info_color, text, self.reset)
146    }
147    
148    /// Format a success message with the success color
149    pub fn success(&self, text: &str) -> String {
150        format!("{}{}{}", self.success_color, text, self.reset)
151    }
152    
153    /// Format a caption with the caption color
154    pub fn caption(&self, text: &str) -> String {
155        format!("{}{}{}", self.caption_color, text, self.reset)
156    }
157    
158    /// Format text as bold
159    pub fn bold(&self, text: &str) -> String {
160        format!("{}{}{}", self.bold, text, self.reset)
161    }
162    
163    /// Format text as dim
164    pub fn dim(&self, text: &str) -> String {
165        format!("{}{}{}", self.dim, text, self.reset)
166    }
167    
168    /// Format an error display in a structured way
169    pub fn format_error<E: crate::error::ForgeError>(&self, err: &E) -> String {
170        let mut result = String::new();
171        
172        // Add the error caption
173        result.push_str(&format!("{}\n", self.caption(&format!("⚠️  {}", err.caption()))));
174        
175        // Add the error message
176        result.push_str(&format!("{}\n", self.error(&err.to_string())));
177        
178        // Add retryable status if applicable
179        if err.is_retryable() {
180            result.push_str(&format!("{}Retryable: {}{}\n", 
181                self.dim, 
182                self.success("Yes"), 
183                self.reset
184            ));
185        } else {
186            result.push_str(&format!("{}Retryable: {}{}\n", 
187                self.dim, 
188                self.error("No"), 
189                self.reset
190            ));
191        }
192        
193        // Add source error if available
194        if let Some(source) = err.source() {
195            result.push_str(&format!("{}Caused by: {}{}\n", 
196                self.dim, 
197                self.error(&source.to_string()), 
198                self.reset
199            ));
200        }
201        
202        result
203    }
204}
205
206/// Pretty-print an error to stderr with the default theme
207pub fn print_error<E: crate::error::ForgeError>(err: &E) {
208    let theme = ConsoleTheme::default();
209    eprintln!("{}", theme.format_error(err));
210}
211
212/// Install a panic hook that formats panics using the ConsoleTheme
213pub fn install_panic_hook() {
214    let theme = ConsoleTheme::default();
215    std::panic::set_hook(Box::new(move |panic_info| {
216        let message = match panic_info.payload().downcast_ref::<&str>() {
217            Some(s) => *s,
218            None => match panic_info.payload().downcast_ref::<String>() {
219                Some(s) => s.as_str(),
220                None => "Unknown panic",
221            },
222        };
223        
224        let location = if let Some(location) = panic_info.location() {
225            format!("at {}:{}", location.file(), location.line())
226        } else {
227            "at unknown location".to_string()
228        };
229        
230        eprintln!("{}", theme.caption("💥 PANIC"));
231        eprintln!("{}", theme.error(&format!("{} {}", message, theme.dim(&location))));
232    }));
233}