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}