Skip to main content

logicaffeine_cli/
ui.rs

1//! Terminal output substrate for `largo`.
2//!
3//! Central home for everything user-facing that is not command logic:
4//! the [`CliError`] type rendered by the binary's `main`, the process
5//! exit-code conventions, the color/verbosity state shared by every
6//! command, and the clap help palette.
7//!
8//! All commands construct [`CliError`] (directly or via [`CliError::new`] /
9//! [`CliError::with_hint`]) instead of ad-hoc string errors when they want
10//! a hint line or a non-default exit code. Plain `String` errors continue
11//! to work through `Box<dyn Error>` and render without a hint.
12//!
13//! Every user-visible print goes through [`anstream`], so ANSI styling is
14//! automatically stripped on pipes, honored under `--color always`, and
15//! silenced by `NO_COLOR` — including styling embedded in engine-rendered
16//! messages that pass through here.
17
18use std::fmt;
19use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
20
21use anstyle::{AnsiColor, Style};
22use clap::builder::Styles;
23
24/// Process exit code for success.
25pub const EXIT_OK: i32 = 0;
26/// Process exit code for a command failure (build error, proof failure,
27/// dirty `fmt --check`, parse error, ...).
28pub const EXIT_FAILURE: i32 = 1;
29/// Process exit code for a usage error (bad arguments, reserved verb).
30/// Matches clap's own convention for argument errors.
31pub const EXIT_USAGE: i32 = 2;
32
33/// Bold red — the `error:` prefix.
34pub const ERROR_STYLE: Style = AnsiColor::Red.on_default().bold();
35/// Bold cyan — the `help:` prefix.
36pub const HELP_STYLE: Style = AnsiColor::Cyan.on_default().bold();
37/// Bold green — phase headers (`Compiling`, `Finished`, ...), cargo-style.
38pub const PHASE_STYLE: Style = AnsiColor::Green.on_default().bold();
39/// Bold yellow — the `warning:` prefix.
40pub const WARN_STYLE: Style = AnsiColor::Yellow.on_default().bold();
41
42/// The cargo help palette for clap: bold-green headers/usage, bold-cyan
43/// literals, cyan placeholders.
44pub const CLAP_STYLES: Styles = Styles::styled()
45    .header(AnsiColor::Green.on_default().bold())
46    .usage(AnsiColor::Green.on_default().bold())
47    .literal(AnsiColor::Cyan.on_default().bold())
48    .placeholder(AnsiColor::Cyan.on_default());
49
50/// The `--color` mode selected on the command line.
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
52pub enum ColorMode {
53    /// Color when stdout/stderr is a terminal and `NO_COLOR` is unset.
54    #[default]
55    Auto,
56    /// Always emit ANSI, even into pipes (CI logs, `less -R`).
57    Always,
58    /// Never emit ANSI.
59    Never,
60}
61
62static QUIET: AtomicBool = AtomicBool::new(false);
63static VERBOSITY: AtomicU8 = AtomicU8::new(0);
64
65/// Install the global output state from the parsed CLI flags.
66///
67/// Called once by `run_cli` before dispatch: records `--quiet`/`--verbose`
68/// and sets the process-wide [`anstream`] color choice so every subsequent
69/// print (ours or an engine message routed through anstream) obeys the
70/// user's `--color` selection.
71pub fn init(color: ColorMode, quiet: bool, verbosity: u8) {
72    QUIET.store(quiet, Ordering::Relaxed);
73    VERBOSITY.store(verbosity, Ordering::Relaxed);
74    let choice = match color {
75        ColorMode::Auto => anstream::ColorChoice::Auto,
76        ColorMode::Always => anstream::ColorChoice::Always,
77        ColorMode::Never => anstream::ColorChoice::Never,
78    };
79    choice.write_global();
80}
81
82/// Whether `--quiet` was passed: informational output should be suppressed.
83pub fn is_quiet() -> bool {
84    QUIET.load(Ordering::Relaxed)
85}
86
87/// The `-v` count: `0` normal, `1+` increasingly chatty.
88pub fn verbosity() -> u8 {
89    VERBOSITY.load(Ordering::Relaxed)
90}
91
92/// Print an informational line to stdout unless `--quiet` is active.
93pub fn info(msg: impl fmt::Display) {
94    if !is_quiet() {
95        anstream::println!("{msg}");
96    }
97}
98
99/// Print a cargo-style phase header (right-aligned bold-green verb) to
100/// stderr unless `--quiet` is active.
101///
102/// ```text
103///    Compiling hello v0.1.0 (LOGOS → Rust)
104///     Finished dev profile in 3.2s
105/// ```
106pub fn phase(verb: &str, rest: impl fmt::Display) {
107    if !is_quiet() {
108        anstream::eprintln!("{PHASE_STYLE}{verb:>12}{PHASE_STYLE:#} {rest}");
109    }
110}
111
112/// A user-facing CLI error: a message, an optional `help:` hint, and the
113/// exit code the process should terminate with.
114///
115/// The `largo` binary downcasts `Box<dyn Error>` values to this type and
116/// renders them as:
117///
118/// ```text
119/// error: <message>
120/// help: <hint>
121/// ```
122#[derive(Debug)]
123pub struct CliError {
124    /// The primary error message (rendered after `error:`).
125    pub message: String,
126    /// An optional actionable hint (rendered after `help:`).
127    pub hint: Option<String>,
128    /// The process exit code to terminate with.
129    pub exit_code: i32,
130}
131
132impl CliError {
133    /// Create an error with the default failure exit code and no hint.
134    pub fn new(message: impl Into<String>) -> Self {
135        Self { message: message.into(), hint: None, exit_code: EXIT_FAILURE }
136    }
137
138    /// Create an error carrying an actionable `help:` hint.
139    pub fn with_hint(message: impl Into<String>, hint: impl Into<String>) -> Self {
140        Self { message: message.into(), hint: Some(hint.into()), exit_code: EXIT_FAILURE }
141    }
142
143    /// Override the exit code (builder style).
144    pub fn exit_code(mut self, code: i32) -> Self {
145        self.exit_code = code;
146        self
147    }
148}
149
150impl fmt::Display for CliError {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        write!(f, "{}", self.message)
153    }
154}
155
156impl std::error::Error for CliError {}
157
158/// Render any top-level error to stderr in the `error:`/`help:` style and
159/// return the exit code the process should use.
160///
161/// [`CliError`] values carry their own hint and exit code; every other error
162/// renders as a bare `error:` line with [`EXIT_FAILURE`].
163pub fn render_error(e: &(dyn std::error::Error + 'static)) -> i32 {
164    if let Some(cli_err) = e.downcast_ref::<CliError>() {
165        anstream::eprintln!("{ERROR_STYLE}error:{ERROR_STYLE:#} {}", cli_err.message);
166        if let Some(hint) = &cli_err.hint {
167            anstream::eprintln!("{HELP_STYLE}help:{HELP_STYLE:#} {hint}");
168        }
169        cli_err.exit_code
170    } else {
171        anstream::eprintln!("{ERROR_STYLE}error:{ERROR_STYLE:#} {e}");
172        EXIT_FAILURE
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn cli_error_defaults_to_failure_exit_code() {
182        let e = CliError::new("boom");
183        assert_eq!(e.exit_code, EXIT_FAILURE);
184        assert!(e.hint.is_none());
185        assert_eq!(e.to_string(), "boom");
186    }
187
188    #[test]
189    fn cli_error_carries_hint_and_custom_code() {
190        let e = CliError::with_hint("boom", "try --fix").exit_code(EXIT_USAGE);
191        assert_eq!(e.hint.as_deref(), Some("try --fix"));
192        assert_eq!(e.exit_code, EXIT_USAGE);
193    }
194
195    #[test]
196    fn render_error_uses_cli_error_exit_code() {
197        let boxed: Box<dyn std::error::Error> =
198            Box::new(CliError::new("x").exit_code(EXIT_USAGE));
199        assert_eq!(render_error(boxed.as_ref()), EXIT_USAGE);
200    }
201
202    #[test]
203    fn render_error_defaults_other_errors_to_failure() {
204        let boxed: Box<dyn std::error::Error> = "plain".into();
205        assert_eq!(render_error(boxed.as_ref()), EXIT_FAILURE);
206    }
207}