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