Skip to main content

vela_protocol/
cli_style.rs

1//! CLI output discipline for Vela.
2//!
3//! The rules:
4//!   - Signal blue (#3B5BDB) is reserved for live state — the current step of a
5//!     running audit or the active cursor. It is never used for success.
6//!   - State chips use a muted engraved palette derived from ink, not a
7//!     traffic-light green/red: moss for ok, brass for contested, dust for
8//!     stale, madder for lost.
9//!   - `·` (middle dot) is the only decorative separator.
10//!   - Banners are a dim tick row and a mono eyebrow, never `===` or `---`.
11//!   - All ANSI is gated on a TTY stdout and NO_COLOR being unset.
12//!
13//! Every styled string in `cli.rs` should route through a helper here so the
14//! discipline is enforced in one place.
15
16use colored::{ColoredString, Colorize};
17use indicatif::ProgressStyle;
18use std::io::IsTerminal;
19use std::sync::Once;
20
21// --- palette -----------------------------------------------------------------
22
23pub const MOSS: (u8, u8, u8) = (0x3F, 0x6B, 0x4E);
24pub const BRASS: (u8, u8, u8) = (0x8A, 0x6A, 0x1F);
25pub const DUST: (u8, u8, u8) = (0x7A, 0x6F, 0x5C);
26pub const MADDER: (u8, u8, u8) = (0x8A, 0x3A, 0x3A);
27pub const SIGNAL: (u8, u8, u8) = (0x3B, 0x5B, 0xDB);
28
29// --- init --------------------------------------------------------------------
30
31static INIT: Once = Once::new();
32
33/// Initialize styling. Call once near the CLI entry point.
34///
35/// Disables all ANSI output when stdout is not a terminal or `NO_COLOR` is set.
36/// Safe to call multiple times; only the first call takes effect.
37pub fn init() {
38    INIT.call_once(|| {
39        let no_color = std::env::var_os("NO_COLOR").is_some();
40        let is_tty = std::io::stdout().is_terminal();
41        if no_color || !is_tty {
42            colored::control::set_override(false);
43        }
44    });
45}
46
47// --- primitives --------------------------------------------------------------
48
49#[must_use]
50pub fn dim(s: &str) -> ColoredString {
51    s.dimmed()
52}
53
54#[must_use]
55pub fn mono_eyebrow(label: &str) -> ColoredString {
56    // Tracked uppercase lives at the call site; we just dim the mono.
57    label.dimmed()
58}
59
60/// A tick row — dim `·` characters of a given visual width.
61#[must_use]
62pub fn tick_row(width: usize) -> String {
63    let row = "·".repeat(width);
64    format!("{}", row.dimmed())
65}
66
67// --- ink-derived color wrappers ---------------------------------------------
68// These exist so `cli.rs` and friends can migrate from `.green()`/`.red()` to
69// a muted engraved palette without loss of semantic pairing (added / removed,
70// ok / lost, current / stale).
71
72#[must_use]
73pub fn moss(s: impl AsRef<str>) -> ColoredString {
74    let (r, g, b) = MOSS;
75    s.as_ref().truecolor(r, g, b)
76}
77
78#[must_use]
79pub fn madder(s: impl AsRef<str>) -> ColoredString {
80    let (r, g, b) = MADDER;
81    s.as_ref().truecolor(r, g, b)
82}
83
84#[must_use]
85pub fn brass(s: impl AsRef<str>) -> ColoredString {
86    let (r, g, b) = BRASS;
87    s.as_ref().truecolor(r, g, b)
88}
89
90#[must_use]
91pub fn dust_color(s: impl AsRef<str>) -> ColoredString {
92    let (r, g, b) = DUST;
93    s.as_ref().truecolor(r, g, b)
94}
95
96#[must_use]
97pub fn signal(s: impl AsRef<str>) -> ColoredString {
98    let (r, g, b) = SIGNAL;
99    s.as_ref().truecolor(r, g, b)
100}
101
102// --- state chips -------------------------------------------------------------
103
104/// Engraved state chip: `· label` in the state color. Lowercase, mono-ish.
105#[must_use]
106pub fn chip(label: &str, rgb: (u8, u8, u8)) -> String {
107    let dot = "·".truecolor(rgb.0, rgb.1, rgb.2);
108    let text = label.truecolor(rgb.0, rgb.1, rgb.2);
109    format!("{dot} {text}")
110}
111
112#[must_use]
113pub fn ok(label: &str) -> String {
114    chip(label, MOSS)
115}
116
117#[must_use]
118pub fn warn(label: &str) -> String {
119    chip(label, BRASS)
120}
121
122#[must_use]
123pub fn stale(label: &str) -> String {
124    chip(label, DUST)
125}
126
127#[must_use]
128pub fn lost(label: &str) -> String {
129    chip(label, MADDER)
130}
131
132/// Signal-blue chip — reserved for live state only.
133#[must_use]
134pub fn live(label: &str) -> String {
135    chip(label, SIGNAL)
136}
137
138// --- headers -----------------------------------------------------------------
139
140/// A header block: dim mono eyebrow, bold title, dim tick row.
141///
142/// Prints three lines to stdout.
143pub fn header(eyebrow: &str, title: &str) {
144    println!("  {}", eyebrow.dimmed());
145    println!("  {}", title.bold());
146    println!("  {}", tick_row(60));
147}
148
149/// An error prefix — `err ·` in madder, lowercase.
150#[must_use]
151pub fn err_prefix() -> String {
152    let (r, g, b) = MADDER;
153    format!("{}", "err ·".truecolor(r, g, b))
154}
155
156// --- progress bar ------------------------------------------------------------
157
158/// Progress-bar style for long-running work.
159///
160/// Uses a hairline bar (`──` filled, blank unfilled), signal-blue for the
161/// current position, and `·` as the separator.
162#[must_use]
163pub fn progress_style(unit: &str) -> ProgressStyle {
164    let template = format!("  {{bar:30}} {{pos}}/{{len}} · {unit} · {{msg}}");
165    ProgressStyle::with_template(&template)
166        .unwrap_or_else(|_| ProgressStyle::default_bar())
167        .progress_chars("──╌")
168}