Skip to main content

consortium_cli/
output.rs

1//! Shared output policy for consortium CLI binaries.
2//!
3//! Mirrors nh's setup so all our bins (claw, molt, pinch, cast)
4//! present a consistent UI: green `>` INFO prefix, `!` yellow WARN
5//! prefix, etc. Plus resolves color and verbosity from --verbose /
6//! --color / --format flags.
7
8use std::io::IsTerminal;
9
10use clap::{Args, ValueEnum};
11
12#[derive(Args, Debug, Clone, Default)]
13pub struct OutputArgs {
14    /// Increase verbosity: -v info → -vv debug → -vvv trace
15    #[arg(short = 'v', long = "verbose", global = true, action = clap::ArgAction::Count)]
16    pub verbose: u8,
17
18    /// Color mode. `auto` (default) uses NO_COLOR env + stdout TTY detection.
19    #[arg(long = "color", global = true, value_enum, default_value = "auto")]
20    pub color: ColorMode,
21
22    /// Output format for cascade visualization (where applicable).
23    /// tree (default) | json | yaml | toml | jsonl
24    #[arg(short = 'F', long = "format", global = true, default_value = "tree")]
25    pub format: String,
26}
27
28#[derive(ValueEnum, Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub enum ColorMode {
30    #[default]
31    Auto,
32    Always,
33    Never,
34}
35
36pub struct CliOutput {
37    pub verbosity: Verbosity,
38    /// Resolved: true if color should be emitted.
39    pub color: bool,
40    pub format: String,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Verbosity {
45    /// Reserve for future -q flag; unused today.
46    Quiet,
47    /// 0 -v flags → INFO-level messages only.
48    Default,
49    /// -v → DEBUG-level messages.
50    Verbose,
51    /// -vv+ → TRACE-level messages.
52    Trace,
53}
54
55impl CliOutput {
56    pub fn from_args(args: &OutputArgs) -> Self {
57        let color = match args.color {
58            ColorMode::Always => true,
59            ColorMode::Never => false,
60            ColorMode::Auto => {
61                std::io::stdout().is_terminal() && std::env::var("NO_COLOR").is_err()
62            }
63        };
64        let verbosity = match args.verbose {
65            0 => Verbosity::Default,
66            1 => Verbosity::Verbose,
67            _ => Verbosity::Trace,
68        };
69        Self {
70            verbosity,
71            color,
72            format: args.format.clone(),
73        }
74    }
75
76    /// Format a status line with the green `>` prefix nh uses.
77    ///
78    /// Returns the formatted string — useful in tests to assert the prefix
79    /// without capturing stderr.
80    pub fn format_info(&self, msg: impl AsRef<str>) -> String {
81        use console::Style;
82        let prefix = if self.color {
83            Style::new().green().bold().apply_to(">").to_string()
84        } else {
85            ">".to_string()
86        };
87        format!("{} {}", prefix, msg.as_ref())
88    }
89
90    /// Format a warning line with the yellow `!` prefix nh uses.
91    pub fn format_warn(&self, msg: impl AsRef<str>) -> String {
92        use console::Style;
93        let prefix = if self.color {
94            Style::new().yellow().bold().apply_to("!").to_string()
95        } else {
96            "!".to_string()
97        };
98        format!("{} {}", prefix, msg.as_ref())
99    }
100
101    /// Format an error line with the red `ERROR` prefix.
102    pub fn format_error(&self, msg: impl AsRef<str>) -> String {
103        use console::Style;
104        let prefix = if self.color {
105            Style::new().red().bold().apply_to("ERROR").to_string()
106        } else {
107            "ERROR".to_string()
108        };
109        format!("{} {}", prefix, msg.as_ref())
110    }
111
112    /// Print a status message with the green `>` prefix to stderr.
113    ///
114    /// Always written to stderr so it doesn't pollute stdout pipes.
115    pub fn info(&self, msg: impl AsRef<str>) {
116        eprintln!("{}", self.format_info(msg));
117    }
118
119    /// Print a warning with the yellow `!` prefix to stderr.
120    pub fn warn(&self, msg: impl AsRef<str>) {
121        eprintln!("{}", self.format_warn(msg));
122    }
123
124    /// Print an error with red `ERROR` prefix to stderr.
125    pub fn error(&self, msg: impl AsRef<str>) {
126        eprintln!("{}", self.format_error(msg));
127    }
128
129    /// Returns true if -v / --verbose was passed at least once.
130    pub fn is_verbose(&self) -> bool {
131        matches!(self.verbosity, Verbosity::Verbose | Verbosity::Trace)
132    }
133
134    /// Returns true if -vv / --verbose --verbose was passed (trace level).
135    pub fn is_trace(&self) -> bool {
136        matches!(self.verbosity, Verbosity::Trace)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn args_with(verbose: u8, color: ColorMode, format: &str) -> OutputArgs {
145        OutputArgs {
146            verbose,
147            color,
148            format: format.to_string(),
149        }
150    }
151
152    // ── Color resolution ──────────────────────────────────────────────────────
153
154    #[test]
155    fn color_always_forces_color() {
156        let out = CliOutput::from_args(&args_with(0, ColorMode::Always, "tree"));
157        assert!(out.color);
158    }
159
160    #[test]
161    fn color_never_forces_no_color() {
162        let out = CliOutput::from_args(&args_with(0, ColorMode::Never, "tree"));
163        assert!(!out.color);
164    }
165
166    #[test]
167    fn color_auto_respects_no_color_env() {
168        // In CI / test harness stdout is not a TTY, so Auto → false already.
169        // Force the NO_COLOR path explicitly.
170        std::env::set_var("NO_COLOR", "1");
171        let out = CliOutput::from_args(&args_with(0, ColorMode::Auto, "tree"));
172        std::env::remove_var("NO_COLOR");
173        assert!(!out.color);
174    }
175
176    // ── Verbosity mapping ─────────────────────────────────────────────────────
177
178    #[test]
179    fn verbosity_zero_is_default() {
180        let out = CliOutput::from_args(&args_with(0, ColorMode::Never, "tree"));
181        assert_eq!(out.verbosity, Verbosity::Default);
182        assert!(!out.is_verbose());
183    }
184
185    #[test]
186    fn verbosity_one_is_verbose() {
187        let out = CliOutput::from_args(&args_with(1, ColorMode::Never, "tree"));
188        assert_eq!(out.verbosity, Verbosity::Verbose);
189        assert!(out.is_verbose());
190        assert!(!out.is_trace());
191    }
192
193    #[test]
194    fn verbosity_two_is_trace() {
195        let out = CliOutput::from_args(&args_with(2, ColorMode::Never, "tree"));
196        assert_eq!(out.verbosity, Verbosity::Trace);
197        assert!(out.is_verbose());
198        assert!(out.is_trace());
199    }
200
201    // ── Prefix formatting ─────────────────────────────────────────────────────
202
203    fn no_color_out() -> CliOutput {
204        CliOutput::from_args(&args_with(0, ColorMode::Never, "tree"))
205    }
206
207    #[test]
208    fn info_prefix_no_color() {
209        let out = no_color_out();
210        assert_eq!(out.format_info("hello"), "> hello");
211    }
212
213    #[test]
214    fn warn_prefix_no_color() {
215        let out = no_color_out();
216        assert_eq!(out.format_warn("careful"), "! careful");
217    }
218
219    #[test]
220    fn error_prefix_no_color() {
221        let out = no_color_out();
222        assert_eq!(out.format_error("boom"), "ERROR boom");
223    }
224
225    #[test]
226    fn format_field_passed_through() {
227        let out = CliOutput::from_args(&args_with(0, ColorMode::Never, "json"));
228        assert_eq!(out.format, "json");
229    }
230}