Skip to main content

vanta_ui/
lib.rs

1//! `vanta-ui` — the branded terminal UI for the `vanta` CLI.
2//!
3//! Provides the ASCII wordmark [`banner`], a thin [`Progress`] handle over
4//! [`indicatif`] for download bars and indeterminate spinners, and plain
5//! [`step`] milestones. Everything is TTY- and `NO_COLOR`-aware: when stdout is
6//! not a terminal or `NO_COLOR` is set, output degrades to terse, uncolored
7//! status lines on stderr with no spinner/bar animation, keeping stdout clean
8//! for scripts and shell hooks.
9//!
10//! All decorative output (banner, bars, spinners, status lines) is written to
11//! **stderr**; callers keep machine-readable results on stdout themselves.
12#![forbid(unsafe_code)]
13
14use std::io::IsTerminal;
15use std::time::Duration;
16
17use indicatif::{ProgressBar, ProgressStyle};
18use owo_colors::OwoColorize;
19
20/// Brand accent, teal `#3DA38C`.
21const TEAL: (u8, u8, u8) = (61, 163, 140);
22/// Dimmer teal `#276F5F`, used for the bar's empty track and the tagline.
23const TEAL_DIM: (u8, u8, u8) = (39, 111, 95);
24
25/// Compact ASCII wordmark for `vanta`, rendered teal under the banner.
26const WORDMARK: &str = r"
27 __   ____ _ _ __ | |_ __ _
28 \ \ / / _` | '_ \| __/ _` |
29  \ V / (_| | | | | || (_| |
30   \_/ \__,_|_| |_|\__\__,_|";
31
32/// Whether decorative UI may animate (bars/spinners) and color may be emitted.
33///
34/// Plain mode (the inverse) is selected when stdout is not a terminal — piped,
35/// redirected, or running under CI — or when the operator has set `NO_COLOR`
36/// (<https://no-color.org/>). In plain mode we emit terse, uncolored status
37/// lines and never draw a spinner or bar.
38pub fn is_rich() -> bool {
39    !no_color() && std::io::stdout().is_terminal()
40}
41
42/// Whether `NO_COLOR` is present (and non-empty) in the environment.
43fn no_color() -> bool {
44    std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())
45}
46
47/// Print the branded wordmark banner once, at the top of an interactive
48/// top-level command. Shows the teal wordmark, a dim tagline, and the version.
49///
50/// A no-op when not in rich mode, so it never pollutes scriptable output.
51pub fn banner(version: &str) {
52    if !is_rich() {
53        return;
54    }
55    eprintln!("{}", WORDMARK.truecolor(TEAL.0, TEAL.1, TEAL.2));
56    eprintln!(
57        " {}  {}",
58        "every developer tool, one command".truecolor(TEAL_DIM.0, TEAL_DIM.1, TEAL_DIM.2),
59        format!("v{version}").truecolor(TEAL_DIM.0, TEAL_DIM.1, TEAL_DIM.2),
60    );
61    eprintln!();
62}
63
64/// Print a plain milestone line (terse status), prefixed with a teal `✓` in
65/// rich mode. Always goes to stderr so stdout stays machine-readable.
66pub fn step(msg: &str) {
67    if is_rich() {
68        eprintln!("{} {msg}", "✓".truecolor(TEAL.0, TEAL.1, TEAL.2));
69    } else {
70        eprintln!("✓ {msg}");
71    }
72}
73
74/// Print a branded "running" header (teal `▸ running: <cmd>`) before a
75/// subprocess inherits the terminal. Always emitted to stderr so the child's
76/// stdout stays clean; uncolored in plain mode.
77pub fn running(cmd: &str) {
78    if is_rich() {
79        eprintln!("{} {}", "▸ running:".truecolor(TEAL.0, TEAL.1, TEAL.2), cmd);
80    } else {
81        eprintln!("▸ running: {cmd}");
82    }
83}
84
85/// A handle around an [`indicatif::ProgressBar`] for a single operation.
86///
87/// In plain mode the underlying bar is hidden (no animation, no per-tick
88/// output); only the final [`finish_ok`](Progress::finish_ok) /
89/// [`finish_err`](Progress::finish_err) status line is emitted, as terse
90/// uncolored text on stderr.
91pub struct Progress {
92    bar: ProgressBar,
93    rich: bool,
94}
95
96impl Progress {
97    /// An indeterminate spinner with a message (e.g. verify/extract/bundle).
98    pub fn new_spinner(msg: &str) -> Progress {
99        let rich = is_rich();
100        let bar = if rich {
101            let pb = ProgressBar::new_spinner();
102            pb.set_style(spinner_style());
103            pb.enable_steady_tick(Duration::from_millis(90));
104            pb.set_message(msg.to_string());
105            pb
106        } else {
107            ProgressBar::hidden()
108        };
109        Progress { bar, rich }
110    }
111
112    /// A determinate byte-progress bar of `total` bytes (download). When the
113    /// total is unknown it falls back to a byte-counting spinner.
114    pub fn new_bar(msg: &str, total: Option<u64>) -> Progress {
115        let rich = is_rich();
116        let bar = if rich {
117            match total {
118                Some(n) => {
119                    let pb = ProgressBar::new(n);
120                    pb.set_style(bar_style());
121                    pb.set_message(msg.to_string());
122                    pb
123                }
124                None => {
125                    let pb = ProgressBar::new_spinner();
126                    pb.set_style(byte_spinner_style());
127                    pb.enable_steady_tick(Duration::from_millis(90));
128                    pb.set_message(msg.to_string());
129                    pb
130                }
131            }
132        } else {
133            ProgressBar::hidden()
134        };
135        Progress { bar, rich }
136    }
137
138    /// Advance the bar by `n` units (bytes, for a download bar).
139    pub fn inc(&self, n: u64) {
140        self.bar.inc(n);
141    }
142
143    /// Replace the bar's message.
144    pub fn set_msg(&self, msg: &str) {
145        self.bar.set_message(msg.to_string());
146    }
147
148    /// Remove the bar/spinner from the screen without printing a status line.
149    /// Used when swapping one phase's indicator for the next.
150    pub fn clear(&self) {
151        self.bar.finish_and_clear();
152    }
153
154    /// Finish the operation with a success line, prefixed by a teal `✓`.
155    pub fn finish_ok(&self, msg: &str) {
156        if self.rich {
157            self.bar.finish_and_clear();
158            eprintln!("{} {msg}", "✓".truecolor(TEAL.0, TEAL.1, TEAL.2));
159        } else {
160            self.bar.finish_and_clear();
161            eprintln!("✓ {msg}");
162        }
163    }
164
165    /// Finish the operation with a failure line, prefixed by a teal `✗`.
166    pub fn finish_err(&self, msg: &str) {
167        if self.rich {
168            self.bar.finish_and_clear();
169            eprintln!("{} {msg}", "✗".truecolor(TEAL.0, TEAL.1, TEAL.2));
170        } else {
171            self.bar.finish_and_clear();
172            eprintln!("✗ {msg}");
173        }
174    }
175}
176
177/// Template for an indeterminate phase spinner.
178fn spinner_style() -> ProgressStyle {
179    ProgressStyle::with_template("{spinner:.cyan} {msg}")
180        .unwrap()
181        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ")
182}
183
184/// Template for a byte-counting spinner (download of unknown length).
185fn byte_spinner_style() -> ProgressStyle {
186    ProgressStyle::with_template("{spinner:.cyan} {msg} {bytes} ({bytes_per_sec})")
187        .unwrap()
188        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ")
189}
190
191/// Template for a determinate download bar: teal filled `█`, dim `░` track.
192///
193/// `indicatif` templates color via `console`'s dotted-style parser, which only
194/// understands named or 256-color indices (no truecolor). 256-index `36`
195/// (`#00AF87`) is the closest match to the brand teal `#3DA38C`; `23`
196/// (`#005F5F`) approximates the dim track `#276F5F`.
197fn bar_style() -> ProgressStyle {
198    ProgressStyle::with_template(
199        "{spinner:.cyan} {msg} [{bar:24.36/23}] {bytes}/{total_bytes} ({eta})",
200    )
201    .unwrap()
202    .progress_chars("█░")
203    .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ")
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    /// `NO_COLOR` (non-empty) forces plain mode regardless of the terminal.
211    #[test]
212    fn no_color_forces_plain_mode() {
213        // Save and restore so we don't disturb other tests sharing the process.
214        let prev = std::env::var_os("NO_COLOR");
215        std::env::set_var("NO_COLOR", "1");
216        assert!(no_color());
217        assert!(!is_rich(), "NO_COLOR must disable rich mode");
218        std::env::set_var("NO_COLOR", "");
219        assert!(!no_color(), "empty NO_COLOR must not count as set");
220        match prev {
221            Some(v) => std::env::set_var("NO_COLOR", v),
222            None => std::env::remove_var("NO_COLOR"),
223        }
224    }
225
226    /// The progress styles must all parse (template + char widths valid).
227    #[test]
228    fn styles_parse() {
229        let _ = spinner_style();
230        let _ = byte_spinner_style();
231        let _ = bar_style();
232    }
233
234    /// A hidden (plain-mode) bar accepts the full API without panicking and
235    /// emits no draw output.
236    #[test]
237    fn hidden_bar_is_inert() {
238        let p = Progress {
239            bar: ProgressBar::hidden(),
240            rich: false,
241        };
242        p.inc(10);
243        p.set_msg("x");
244        p.finish_ok("done");
245    }
246}