Skip to main content

rec/cli/
output.rs

1use std::io::IsTerminal;
2
3use crate::config::load_config;
4use crate::models::{ColorMode, SymbolMode, Verbosity};
5
6/// Output handler with styled terminal output.
7///
8/// Respects configuration for colors, symbols, and verbosity levels.
9/// Supports JSON output mode for scripting and machine consumption.
10#[derive(Clone, Copy)]
11pub struct Output {
12    /// Whether to use ANSI colors
13    pub colors: bool,
14    /// Symbol mode (unicode or ascii)
15    pub symbols: SymbolMode,
16    /// Verbosity level
17    pub verbosity: Verbosity,
18    /// Whether to output JSON instead of human-readable text
19    pub json: bool,
20}
21
22impl Output {
23    /// Create output handler from CLI flags.
24    ///
25    /// Loads config to get style settings, then applies CLI flag overrides.
26    /// `NO_COLOR` environment variable takes precedence over config.
27    #[must_use]
28    pub fn new(verbose: bool, quiet: bool, json: bool) -> Self {
29        let config = load_config().unwrap_or_default();
30
31        // Determine colors: NO_COLOR env var takes precedence
32        let colors = match config.style.colors {
33            ColorMode::Always => std::env::var("NO_COLOR").is_err(),
34            ColorMode::Never => false,
35            ColorMode::Auto => {
36                std::env::var("NO_COLOR").is_err() && std::io::stdout().is_terminal()
37            }
38        };
39
40        // CLI flags override config verbosity
41        // Priority: quiet > verbose > config
42        let verbosity = if quiet {
43            Verbosity::Quiet
44        } else if verbose {
45            Verbosity::Verbose
46        } else {
47            config.style.verbosity
48        };
49
50        Self {
51            colors,
52            symbols: config.style.symbols,
53            verbosity,
54            json,
55        }
56    }
57
58    /// Whether ANSI color codes are enabled.
59    ///
60    /// Used by modules like `doctor::format_report()` to decide whether
61    /// to include color escapes in their output.
62    #[must_use]
63    pub fn use_color(&self) -> bool {
64        self.colors
65    }
66
67    /// Get success symbol based on symbol mode.
68    #[must_use]
69    pub fn success_symbol(&self) -> &'static str {
70        match self.symbols {
71            SymbolMode::Unicode => "\u{2713}", // checkmark
72            SymbolMode::Ascii => "[OK]",
73        }
74    }
75
76    /// Get error symbol based on symbol mode.
77    #[must_use]
78    pub fn error_symbol(&self) -> &'static str {
79        match self.symbols {
80            SymbolMode::Unicode => "\u{2717}", // X mark
81            SymbolMode::Ascii => "[ERR]",
82        }
83    }
84
85    /// Get info symbol based on symbol mode.
86    #[must_use]
87    pub fn info_symbol(&self) -> &'static str {
88        match self.symbols {
89            SymbolMode::Unicode => "\u{2192}", // right arrow
90            SymbolMode::Ascii => "->",
91        }
92    }
93
94    /// Get warning symbol based on symbol mode.
95    #[must_use]
96    pub fn warning_symbol(&self) -> &'static str {
97        match self.symbols {
98            SymbolMode::Unicode => "\u{26a0}", // warning sign
99            SymbolMode::Ascii => "[WARN]",
100        }
101    }
102
103    /// Print a success message.
104    ///
105    /// Suppressed in quiet mode. Uses green color when colors are enabled.
106    /// Writes to stderr to avoid corrupting stdout data output.
107    pub fn success(&self, message: &str) {
108        if matches!(self.verbosity, Verbosity::Quiet) {
109            return;
110        }
111
112        if self.json {
113            eprintln!(
114                r#"{{"status": "success", "message": "{}"}}"#,
115                escape_json_string(message)
116            );
117        } else if self.colors {
118            eprintln!("\x1b[32m{}\x1b[0m {}", self.success_symbol(), message);
119        } else {
120            eprintln!("{} {}", self.success_symbol(), message);
121        }
122    }
123
124    /// Print an error message with optional cause and help text.
125    ///
126    /// Always printed (even in quiet mode). Uses red color when colors are enabled.
127    pub fn error(&self, error_type: &str, message: &str, cause: Option<&str>, help: Option<&str>) {
128        if self.json {
129            let mut obj = format!(
130                r#"{{"status": "error", "type": "{}", "message": "{}""#,
131                escape_json_string(error_type),
132                escape_json_string(message)
133            );
134            if let Some(c) = cause {
135                use std::fmt::Write;
136                let _ = write!(obj, r#", "cause": "{}""#, escape_json_string(c));
137            }
138            if let Some(h) = help {
139                use std::fmt::Write;
140                let _ = write!(obj, r#", "help": "{}""#, escape_json_string(h));
141            }
142            obj.push('}');
143            eprintln!("{obj}");
144        } else {
145            let symbol = self.error_symbol();
146            if self.colors {
147                eprintln!("\x1b[31m{symbol} {error_type}\x1b[0m: {message}");
148            } else {
149                eprintln!("{symbol} {error_type}: {message}");
150            }
151
152            if let Some(c) = cause {
153                eprintln!("  cause: {c}");
154            }
155            if let Some(h) = help {
156                eprintln!("  help: {h}");
157            }
158        }
159    }
160
161    /// Print a warning message.
162    ///
163    /// Suppressed in quiet mode. Uses yellow color when colors are enabled.
164    /// Writes to stderr to avoid corrupting stdout data output.
165    pub fn warning(&self, message: &str) {
166        if matches!(self.verbosity, Verbosity::Quiet) {
167            return;
168        }
169
170        if self.json {
171            eprintln!(
172                r#"{{"level": "warning", "message": "{}"}}"#,
173                escape_json_string(message)
174            );
175        } else if self.colors {
176            eprintln!("\x1b[33m{}\x1b[0m {}", self.warning_symbol(), message);
177        } else {
178            eprintln!("{} {}", self.warning_symbol(), message);
179        }
180    }
181
182    /// Print a debug message.
183    ///
184    /// Only printed in verbose mode. Uses gray color when colors are enabled.
185    /// Writes to stderr to avoid corrupting stdout data output.
186    pub fn debug(&self, message: &str) {
187        if !matches!(self.verbosity, Verbosity::Verbose) {
188            return;
189        }
190
191        if self.json {
192            eprintln!(
193                r#"{{"level": "debug", "message": "{}"}}"#,
194                escape_json_string(message)
195            );
196        } else if self.colors {
197            eprintln!("\x1b[90m[DEBUG] {message}\x1b[0m");
198        } else {
199            eprintln!("[DEBUG] {message}");
200        }
201    }
202
203    /// Print an info message.
204    ///
205    /// Suppressed in quiet mode.
206    /// Writes to stderr to avoid corrupting stdout data output.
207    pub fn info(&self, message: &str) {
208        if matches!(self.verbosity, Verbosity::Quiet) {
209            return;
210        }
211
212        if self.json {
213            eprintln!(
214                r#"{{"level": "info", "message": "{}"}}"#,
215                escape_json_string(message)
216            );
217        } else {
218            eprintln!("{} {}", self.info_symbol(), message);
219        }
220    }
221    /// Style text as an inline command (cyan with color, backtick-wrapped without).
222    ///
223    /// Used for displaying command suggestions like `rec start` or `rec stop`.
224    #[must_use]
225    pub fn style_command(&self, text: &str) -> String {
226        if self.colors {
227            format!("\x1b[36m{text}\x1b[0m")
228        } else {
229            format!("`{text}`")
230        }
231    }
232
233    /// Style text as success (green with color, plain without).
234    #[must_use]
235    pub fn style_success(&self, text: &str) -> String {
236        if self.colors {
237            format!("\x1b[32m{text}\x1b[0m")
238        } else {
239            text.to_string()
240        }
241    }
242
243    /// Style text as error (red with color, plain without).
244    #[must_use]
245    pub fn style_error(&self, text: &str) -> String {
246        if self.colors {
247            format!("\x1b[31m{text}\x1b[0m")
248        } else {
249            text.to_string()
250        }
251    }
252
253    /// Check if verbose mode is enabled.
254    #[must_use]
255    pub fn is_verbose(&self) -> bool {
256        matches!(self.verbosity, Verbosity::Verbose)
257    }
258
259    /// Check if quiet mode is enabled.
260    #[must_use]
261    pub fn is_quiet(&self) -> bool {
262        matches!(self.verbosity, Verbosity::Quiet)
263    }
264}
265
266impl Default for Output {
267    fn default() -> Self {
268        Self::new(false, false, false)
269    }
270}
271
272/// Convenience function to print a success message with default settings.
273pub fn print_success(message: &str) {
274    Output::default().success(message);
275}
276
277/// Convenience function to print an error message with default settings.
278pub fn print_error(error_type: &str, message: &str, cause: Option<&str>, help: Option<&str>) {
279    Output::default().error(error_type, message, cause, help);
280}
281
282/// Escape special characters in a string for JSON output.
283fn escape_json_string(s: &str) -> String {
284    s.replace('\\', "\\\\")
285        .replace('"', "\\\"")
286        .replace('\n', "\\n")
287        .replace('\r', "\\r")
288        .replace('\t', "\\t")
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_output_default() {
297        // Use explicit construction to avoid environment-dependent config loading.
298        // Output::default() delegates to Output::new() which reads REC_VERBOSE etc.
299        let output = Output {
300            colors: false,
301            symbols: SymbolMode::Unicode,
302            verbosity: Verbosity::Normal,
303            json: false,
304        };
305        assert!(!output.json);
306        assert_eq!(output.verbosity, Verbosity::Normal);
307    }
308
309    #[test]
310    fn test_output_symbols_unicode() {
311        let output = Output {
312            colors: false,
313            symbols: SymbolMode::Unicode,
314            verbosity: Verbosity::Normal,
315            json: false,
316        };
317
318        assert_eq!(output.success_symbol(), "\u{2713}");
319        assert_eq!(output.error_symbol(), "\u{2717}");
320        assert_eq!(output.info_symbol(), "\u{2192}");
321    }
322
323    #[test]
324    fn test_output_symbols_ascii() {
325        let output = Output {
326            colors: false,
327            symbols: SymbolMode::Ascii,
328            verbosity: Verbosity::Normal,
329            json: false,
330        };
331
332        assert_eq!(output.success_symbol(), "[OK]");
333        assert_eq!(output.error_symbol(), "[ERR]");
334        assert_eq!(output.info_symbol(), "->");
335    }
336
337    #[test]
338    fn test_escape_json_string() {
339        assert_eq!(escape_json_string("hello"), "hello");
340        assert_eq!(escape_json_string("hello\"world"), "hello\\\"world");
341        assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
342        assert_eq!(escape_json_string("back\\slash"), "back\\\\slash");
343    }
344
345    #[test]
346    fn test_verbosity_quiet() {
347        let output = Output::new(false, true, false);
348        assert_eq!(output.verbosity, Verbosity::Quiet);
349    }
350
351    #[test]
352    fn test_verbosity_verbose() {
353        let output = Output::new(true, false, false);
354        assert_eq!(output.verbosity, Verbosity::Verbose);
355    }
356
357    #[test]
358    fn test_quiet_overrides_verbose() {
359        // When both flags are set, quiet takes precedence
360        let output = Output::new(true, true, false);
361        assert_eq!(output.verbosity, Verbosity::Quiet);
362    }
363}