Skip to main content

romm_cli/
cli_presentation.rs

1//! CLI output presentation: color, progress, and JSON vs text stdout rules.
2
3use std::io::{self, IsTerminal, Write};
4
5use indicatif::{MultiProgress, ProgressDrawTarget, ProgressStyle};
6use serde::Serialize;
7
8use crate::commands::OutputFormat;
9use romm_api::error::{user_message, RommError};
10
11/// Resolved CLI output mode for a single command invocation.
12#[derive(Clone, Copy, Debug)]
13pub struct CliPresentation {
14    pub format: OutputFormat,
15    pub verbose: bool,
16}
17
18impl CliPresentation {
19    pub fn from_cli(global_json: bool, local_json: bool, verbose: bool) -> Self {
20        Self {
21            format: OutputFormat::from_flags(global_json, local_json),
22            verbose,
23        }
24    }
25
26    pub fn is_json(&self) -> bool {
27        matches!(self.format, OutputFormat::Json)
28    }
29
30    pub fn is_text(&self) -> bool {
31        matches!(self.format, OutputFormat::Text)
32    }
33
34    /// True when ANSI styling is allowed on CLI output.
35    pub fn supports_ansi_color(&self) -> bool {
36        if std::env::var_os("NO_COLOR").is_some() {
37            return false;
38        }
39        if std::env::var("CLICOLOR").as_deref() == Ok("0")
40            && std::env::var("CLICOLOR_FORCE").as_deref() != Ok("1")
41        {
42            return false;
43        }
44        io::stdout().is_terminal()
45    }
46
47    /// True when indicatif progress bars/spinners may be shown.
48    pub fn shows_progress(&self) -> bool {
49        self.is_text() && io::stdout().is_terminal()
50    }
51
52    pub fn progress_draw_target(&self) -> ProgressDrawTarget {
53        ProgressDrawTarget::stderr()
54    }
55
56    pub fn progress_style(&self, template_plain: &str, template_color: &str) -> ProgressStyle {
57        let template = if self.supports_ansi_color() {
58            template_color
59        } else {
60            template_plain
61        };
62        ProgressStyle::with_template(template)
63            .expect("hardcoded progress template")
64            .progress_chars("#>-")
65    }
66
67    pub fn multi_progress(&self) -> Option<MultiProgress> {
68        if !self.shows_progress() {
69            return None;
70        }
71        let mp = MultiProgress::with_draw_target(self.progress_draw_target());
72        Some(mp)
73    }
74
75    /// Human status line on stderr (omitted in JSON mode).
76    pub fn emit_status(&self, message: impl AsRef<str>) {
77        if self.is_text() {
78            let _ = writeln!(io::stderr(), "{}", message.as_ref());
79        }
80    }
81
82    /// Pretty JSON on stdout (JSON mode only).
83    pub fn emit_json<T: Serialize>(&self, value: &T) -> Result<(), RommError> {
84        if self.is_json() {
85            println!(
86                "{}",
87                serde_json::to_string_pretty(value).map_err(|e| {
88                    RommError::Other(format!("failed to serialize JSON output: {e}"))
89                })?
90            );
91        }
92        Ok(())
93    }
94
95    /// Actionable error line on stderr (text mode batch failures).
96    pub fn emit_command_error(&self, err: &RommError) {
97        eprintln!("Error: {}", user_message(err));
98        if self.verbose {
99            eprintln!("Details: {err:#}");
100        }
101    }
102}
103
104/// Format a command-local failure for stderr.
105pub fn format_command_error(err: &RommError) -> String {
106    user_message(err)
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn clear_color_env() {
114        std::env::remove_var("NO_COLOR");
115        std::env::remove_var("CLICOLOR");
116        std::env::remove_var("CLICOLOR_FORCE");
117    }
118
119    #[test]
120    fn json_format_suppresses_progress() {
121        clear_color_env();
122        let p = CliPresentation::from_cli(true, false, false);
123        assert!(!p.shows_progress());
124        assert!(p.is_json());
125    }
126
127    #[test]
128    fn no_color_disables_ansi() {
129        clear_color_env();
130        std::env::set_var("NO_COLOR", "1");
131        let p = CliPresentation::from_cli(false, false, false);
132        assert!(!p.supports_ansi_color());
133        std::env::remove_var("NO_COLOR");
134    }
135
136    #[test]
137    fn clicolor_zero_disables_ansi() {
138        clear_color_env();
139        std::env::set_var("CLICOLOR", "0");
140        let p = CliPresentation::from_cli(false, false, false);
141        assert!(!p.supports_ansi_color());
142        std::env::remove_var("CLICOLOR");
143    }
144
145    #[test]
146    fn clicolor_force_overrides_clicolor_zero() {
147        clear_color_env();
148        std::env::set_var("CLICOLOR", "0");
149        std::env::set_var("CLICOLOR_FORCE", "1");
150        let p = CliPresentation::from_cli(false, false, false);
151        // May still be false when stdout is not a TTY in test harness.
152        let _ = p.supports_ansi_color();
153        std::env::remove_var("CLICOLOR");
154        std::env::remove_var("CLICOLOR_FORCE");
155    }
156}