Skip to main content

auths_cli/ux/
format.rs

1//! Terminal output utilities with color support.
2//!
3//! This module provides colored terminal output that respects:
4//! - `NO_COLOR` environment variable (https://no-color.org/)
5//! - TTY detection (colors disabled when not a terminal)
6//! - `--json` mode (colors disabled for machine-readable output)
7
8#![allow(dead_code)] // Some functions are for future use
9
10use console::{Style, Term};
11use serde::Serialize;
12use std::io::IsTerminal;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15static JSON_MODE: AtomicBool = AtomicBool::new(false);
16
17/// Standard JSON response structure for all commands.
18///
19/// This provides consistent machine-readable output for scripting.
20#[derive(Debug, Clone, Serialize)]
21pub struct JsonResponse<T: Serialize> {
22    /// Whether the command succeeded.
23    pub success: bool,
24    /// The command that was executed.
25    pub command: String,
26    /// The response data (when successful).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub data: Option<T>,
29    /// Error message (when failed).
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub error: Option<String>,
32}
33
34impl<T: Serialize> JsonResponse<T> {
35    /// Create a success response with data.
36    pub fn success(command: impl Into<String>, data: T) -> Self {
37        Self {
38            success: true,
39            command: command.into(),
40            data: Some(data),
41            error: None,
42        }
43    }
44
45    /// Create an error response.
46    pub fn error(command: impl Into<String>, error: impl Into<String>) -> JsonResponse<()> {
47        JsonResponse {
48            success: false,
49            command: command.into(),
50            data: None,
51            error: Some(error.into()),
52        }
53    }
54
55    /// Print the response as JSON to stdout.
56    pub fn print(&self) -> Result<(), serde_json::Error> {
57        println!("{}", serde_json::to_string_pretty(self)?);
58        Ok(())
59    }
60}
61
62/// Check if JSON mode is enabled.
63pub fn is_json_mode() -> bool {
64    JSON_MODE.load(Ordering::Relaxed)
65}
66
67/// Terminal output helper with color support.
68pub struct Output {
69    term: Term,
70    colors_enabled: bool,
71    // Pre-built styles
72    success_style: Style,
73    error_style: Style,
74    warn_style: Style,
75    info_style: Style,
76    bold_style: Style,
77    dim_style: Style,
78}
79
80impl Default for Output {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl Output {
87    /// Create a new Output instance.
88    pub fn new() -> Self {
89        let term = Term::stderr();
90        let colors_enabled = Self::should_use_colors(&term);
91
92        Self {
93            term,
94            colors_enabled,
95            success_style: Style::new().green(),
96            error_style: Style::new().red(),
97            warn_style: Style::new().yellow(),
98            info_style: Style::new().cyan(),
99            bold_style: Style::new().bold(),
100            dim_style: Style::new().dim(),
101        }
102    }
103
104    /// Create an Output for stdout (for actual data output).
105    pub fn stdout() -> Self {
106        let term = Term::stdout();
107        let colors_enabled = Self::should_use_colors(&term);
108
109        Self {
110            term,
111            colors_enabled,
112            success_style: Style::new().green(),
113            error_style: Style::new().red(),
114            warn_style: Style::new().yellow(),
115            info_style: Style::new().cyan(),
116            bold_style: Style::new().bold(),
117            dim_style: Style::new().dim(),
118        }
119    }
120
121    /// Determine if colors should be used.
122    fn should_use_colors(term: &Term) -> bool {
123        if JSON_MODE.load(Ordering::Relaxed) {
124            return false;
125        }
126
127        // Respect NO_COLOR env var
128        if std::env::var("NO_COLOR").is_ok() {
129            return false;
130        }
131
132        // Check if terminal supports colors
133        if !term.is_term() {
134            return false;
135        }
136
137        // Check if stdout is a TTY
138        if !std::io::stderr().is_terminal() {
139            return false;
140        }
141
142        true
143    }
144
145    /// Apply success style (green).
146    pub fn success(&self, text: &str) -> String {
147        if self.colors_enabled {
148            self.success_style.apply_to(text).to_string()
149        } else {
150            text.to_string()
151        }
152    }
153
154    /// Apply error style (red).
155    pub fn error(&self, text: &str) -> String {
156        if self.colors_enabled {
157            self.error_style.apply_to(text).to_string()
158        } else {
159            text.to_string()
160        }
161    }
162
163    /// Apply warning style (yellow).
164    pub fn warn(&self, text: &str) -> String {
165        if self.colors_enabled {
166            self.warn_style.apply_to(text).to_string()
167        } else {
168            text.to_string()
169        }
170    }
171
172    /// Apply info style (cyan).
173    pub fn info(&self, text: &str) -> String {
174        if self.colors_enabled {
175            self.info_style.apply_to(text).to_string()
176        } else {
177            text.to_string()
178        }
179    }
180
181    /// Apply bold style.
182    pub fn bold(&self, text: &str) -> String {
183        if self.colors_enabled {
184            self.bold_style.apply_to(text).to_string()
185        } else {
186            text.to_string()
187        }
188    }
189
190    /// Apply dim style.
191    pub fn dim(&self, text: &str) -> String {
192        if self.colors_enabled {
193            self.dim_style.apply_to(text).to_string()
194        } else {
195            text.to_string()
196        }
197    }
198
199    /// Print a success message.
200    pub fn print_success(&self, message: &str) {
201        let icon = if self.colors_enabled {
202            self.success_style.apply_to("\u{2713}").to_string()
203        } else {
204            "[OK]".to_string()
205        };
206        eprintln!("{} {}", icon, message);
207    }
208
209    /// Print an error message.
210    pub fn print_error(&self, message: &str) {
211        let icon = if self.colors_enabled {
212            self.error_style.apply_to("\u{2717}").to_string()
213        } else {
214            "[ERROR]".to_string()
215        };
216        eprintln!("{} {}", icon, message);
217    }
218
219    /// Print a warning message.
220    pub fn print_warn(&self, message: &str) {
221        let icon = if self.colors_enabled {
222            self.warn_style.apply_to("!").to_string()
223        } else {
224            "[WARN]".to_string()
225        };
226        eprintln!("{} {}", icon, message);
227    }
228
229    /// Print an info message.
230    pub fn print_info(&self, message: &str) {
231        let icon = if self.colors_enabled {
232            self.info_style.apply_to("i").to_string()
233        } else {
234            "[INFO]".to_string()
235        };
236        eprintln!("{} {}", icon, message);
237    }
238
239    /// Print a heading.
240    pub fn print_heading(&self, text: &str) {
241        let styled = if self.colors_enabled {
242            self.bold_style.apply_to(text).to_string()
243        } else {
244            text.to_string()
245        };
246        eprintln!("{}", styled);
247    }
248
249    /// Print a line.
250    pub fn println(&self, text: &str) {
251        eprintln!("{}", text);
252    }
253
254    /// Print an empty line.
255    pub fn newline(&self) {
256        eprintln!();
257    }
258
259    /// Format a key-value pair.
260    pub fn key_value(&self, key: &str, value: &str) -> String {
261        if self.colors_enabled {
262            format!(
263                "{}: {}",
264                self.dim_style.apply_to(key),
265                self.info_style.apply_to(value)
266            )
267        } else {
268            format!("{}: {}", key, value)
269        }
270    }
271
272    /// Format a status indicator.
273    pub fn status(&self, passed: bool) -> &'static str {
274        if passed {
275            if self.colors_enabled {
276                "\u{2713}"
277            } else {
278                "[PASS]"
279            }
280        } else if self.colors_enabled {
281            "\u{2717}"
282        } else {
283            "[FAIL]"
284        }
285    }
286}
287
288/// Set JSON mode for the current process.
289///
290/// Call this at the start of command handling if `--json` flag is set.
291pub fn set_json_mode(enabled: bool) {
292    JSON_MODE.store(enabled, Ordering::Relaxed);
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    impl Output {
300        /// Create an Output with colors explicitly disabled (for deterministic tests).
301        fn new_without_colors() -> Self {
302            let term = Term::stderr();
303            Self {
304                term,
305                colors_enabled: false,
306                success_style: Style::new().green(),
307                error_style: Style::new().red(),
308                warn_style: Style::new().yellow(),
309                info_style: Style::new().cyan(),
310                bold_style: Style::new().bold(),
311                dim_style: Style::new().dim(),
312            }
313        }
314    }
315
316    #[test]
317    fn test_output_no_colors_in_test() {
318        // In tests, colors should be disabled (not a TTY)
319        let output = Output::new();
320        // Just verify we can create it and format strings
321        let success = output.success("test");
322        assert!(success.contains("test"));
323    }
324
325    #[test]
326    fn test_json_mode() {
327        // Use explicit no-colors constructor to avoid race conditions with global JSON_MODE
328        let output = Output::new_without_colors();
329        // With colors disabled, styling should be plain text
330        let styled = output.success("test");
331        assert_eq!(styled, "test");
332    }
333
334    #[test]
335    fn test_key_value_format() {
336        // Use explicit no-colors constructor to avoid race conditions with global JSON_MODE
337        let output = Output::new_without_colors();
338        let kv = output.key_value("name", "value");
339        assert_eq!(kv, "name: value");
340    }
341}