Skip to main content

sui_spec/
style.rs

1//! Nord-palette styled output for sui CLI surfaces.
2//!
3//! The pleme-io blackmatter aesthetic.  Every operator-visible
4//! line of CLI text routes through this module so the styling
5//! stays consistent across `sui`, `sui-sweep`, `sui-spec-inventory`,
6//! and any future binary that lands in this workspace.
7//!
8//! Palette: Arctic Ice Studio's Nord (https://www.nordtheme.com/).
9//! Four typed groups:
10//!
11//! - **Polar Night** (bg-leaning dark grays — `nord0`..`nord3`)
12//! - **Snow Storm** (light text — `nord4`..`nord6`)
13//! - **Frost** (cool teal/cyan/blue accents — `nord7`..`nord10`)
14//! - **Aurora** (warm signal colors — red/orange/yellow/green/purple,
15//!   `nord11`..`nord15`)
16//!
17//! All colors are ANSI 24-bit (truecolor).  Honors `$NO_COLOR`
18//! (https://no-color.org/) — when set, every helper emits the
19//! payload unchanged with zero escape sequences.  Also drops
20//! styling when stdout isn't a TTY (so pipes get clean output).
21//!
22//! ## Usage
23//!
24//! ```ignore
25//! use sui_spec::style::*;
26//! println!("{}", header("sui-spec inventory"));
27//! println!("  {} {}", glyph_ok(), success("19 domains loaded"));
28//! println!("  {} {}", glyph_warn(), warn("1 maturity gate pending"));
29//! ```
30//!
31//! The convention: every domain-specific output composes from
32//! these primitives.  No raw ANSI escapes outside this module.
33
34use std::sync::OnceLock;
35
36// ── Palette constants (24-bit RGB) ────────────────────────────────
37
38pub const NORD0: Rgb = Rgb(0x2e, 0x34, 0x40); // Polar Night (darkest bg)
39pub const NORD1: Rgb = Rgb(0x3b, 0x42, 0x52); // Polar Night (panel)
40pub const NORD2: Rgb = Rgb(0x43, 0x4c, 0x5e); // Polar Night (hover)
41pub const NORD3: Rgb = Rgb(0x4c, 0x56, 0x6a); // Polar Night (comment)
42pub const NORD4: Rgb = Rgb(0xd8, 0xde, 0xe9); // Snow Storm (dim text)
43pub const NORD5: Rgb = Rgb(0xe5, 0xe9, 0xf0); // Snow Storm
44pub const NORD6: Rgb = Rgb(0xec, 0xef, 0xf4); // Snow Storm (brightest text)
45pub const NORD7: Rgb = Rgb(0x8f, 0xbc, 0xbb); // Frost (sea green)
46pub const NORD8: Rgb = Rgb(0x88, 0xc0, 0xd0); // Frost (ice cyan — primary accent)
47pub const NORD9: Rgb = Rgb(0x81, 0xa1, 0xc1); // Frost (light blue)
48pub const NORD10: Rgb = Rgb(0x5e, 0x81, 0xac); // Frost (deep blue)
49pub const NORD11: Rgb = Rgb(0xbf, 0x61, 0x6a); // Aurora (red — error)
50pub const NORD12: Rgb = Rgb(0xd0, 0x87, 0x70); // Aurora (orange — warning)
51pub const NORD13: Rgb = Rgb(0xeb, 0xcb, 0x8b); // Aurora (yellow — pending)
52pub const NORD14: Rgb = Rgb(0xa3, 0xbe, 0x8c); // Aurora (green — success)
53pub const NORD15: Rgb = Rgb(0xb4, 0x8e, 0xad); // Aurora (purple — info)
54
55/// 24-bit RGB tuple.  Used by [`fg`] to emit truecolor ANSI escapes.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct Rgb(pub u8, pub u8, pub u8);
58
59// ── Style detection (NO_COLOR + TTY) ──────────────────────────────
60
61static STYLING: OnceLock<bool> = OnceLock::new();
62
63// Test-only styling override.  The process-global `STYLING` OnceLock
64// caches the NO_COLOR/TTY decision on first read and cannot be reset;
65// under `cargo test` an unrelated test can initialize it to `false`
66// (stdout is not a TTY) before a styling test runs, which made the
67// color/glyph tests order-dependent.  A test build carries an explicit
68// override that `styling_enabled()` consults first, so tests inject a
69// deterministic gate state.  This block is `#[cfg(test)]` — it is not
70// compiled into the runtime binary, so runtime behavior is unchanged.
71#[cfg(test)]
72static STYLING_TEST_OVERRIDE: std::sync::atomic::AtomicI8 =
73    std::sync::atomic::AtomicI8::new(-1); // -1 = unset, 0 = off, 1 = on
74
75#[cfg(test)]
76fn set_styling_override(on: bool) {
77    STYLING_TEST_OVERRIDE.store(i8::from(on), std::sync::atomic::Ordering::SeqCst);
78}
79
80/// Whether styling is active.  Cached on first call so subsequent
81/// invocations are zero-cost.  Set by environment:
82///
83/// - `NO_COLOR` set (to anything non-empty) → styling off
84/// - stdout not a TTY → styling off
85/// - otherwise → styling on
86#[must_use]
87pub fn styling_enabled() -> bool {
88    // Test-only deterministic override (see `STYLING_TEST_OVERRIDE`).
89    // Excluded from the runtime build via `#[cfg(test)]`, so the
90    // NO_COLOR/TTY decision below is the sole path in production.
91    #[cfg(test)]
92    {
93        match STYLING_TEST_OVERRIDE.load(std::sync::atomic::Ordering::SeqCst) {
94            0 => return false,
95            1 => return true,
96            _ => {}
97        }
98    }
99    *STYLING.get_or_init(|| {
100        if std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) {
101            return false;
102        }
103        // Explicit override for tests + scripts that want color even
104        // when stdout is piped.
105        if std::env::var_os("SUI_FORCE_COLOR").is_some() {
106            return true;
107        }
108        is_tty(1)
109    })
110}
111
112fn is_tty(fd: i32) -> bool {
113    #[cfg(unix)]
114    {
115        // SAFETY: isatty is a thread-safe libc query of a fd.
116        unsafe { libc::isatty(fd) == 1 }
117    }
118    #[cfg(not(unix))]
119    { let _ = fd; false }
120}
121
122// ── Primitive styling helpers ─────────────────────────────────────
123
124/// Wrap text in a foreground-color ANSI escape.  When styling is
125/// disabled, returns the text unchanged (no escapes added).
126#[must_use]
127pub fn fg(color: Rgb, text: &str) -> String {
128    if !styling_enabled() {
129        return text.to_string();
130    }
131    format!("\x1b[38;2;{};{};{}m{}\x1b[0m", color.0, color.1, color.2, text)
132}
133
134/// Bold + foreground.
135#[must_use]
136pub fn bold_fg(color: Rgb, text: &str) -> String {
137    if !styling_enabled() {
138        return text.to_string();
139    }
140    format!("\x1b[1;38;2;{};{};{}m{}\x1b[0m", color.0, color.1, color.2, text)
141}
142
143/// Dim (faint) + foreground.
144#[must_use]
145pub fn dim_fg(color: Rgb, text: &str) -> String {
146    if !styling_enabled() {
147        return text.to_string();
148    }
149    format!("\x1b[2;38;2;{};{};{}m{}\x1b[0m", color.0, color.1, color.2, text)
150}
151
152// ── Semantic helpers (the everyday surface) ───────────────────────
153
154/// Section header — bold ice-cyan (Nord8).
155#[must_use]
156pub fn header(text: &str) -> String { bold_fg(NORD8, text) }
157
158/// Success message — Aurora green (Nord14).
159#[must_use]
160pub fn success(text: &str) -> String { fg(NORD14, text) }
161
162/// Warning — Aurora orange (Nord12).
163#[must_use]
164pub fn warn(text: &str) -> String { fg(NORD12, text) }
165
166/// Error — Aurora red (Nord11).
167#[must_use]
168pub fn error(text: &str) -> String { fg(NORD11, text) }
169
170/// Pending / in-flight — Aurora yellow (Nord13).
171#[must_use]
172pub fn pending(text: &str) -> String { fg(NORD13, text) }
173
174/// Informational accent — Frost light blue (Nord9).
175#[must_use]
176pub fn info(text: &str) -> String { fg(NORD9, text) }
177
178/// Subtle / muted — Polar Night comment color (Nord3).
179#[must_use]
180pub fn muted(text: &str) -> String { dim_fg(NORD3, text) }
181
182/// Secondary accent — Aurora purple (Nord15).  Used for type
183/// names + identifiers.
184#[must_use]
185pub fn ident(text: &str) -> String { fg(NORD15, text) }
186
187/// Primary text — Snow Storm bright (Nord6).  The default for body
188/// copy; usually you don't need to call this since uncolored text
189/// renders fine, but useful when composing inside a larger styled
190/// span.
191#[must_use]
192pub fn body(text: &str) -> String { fg(NORD6, text) }
193
194// ── Glyphs (Unicode + ASCII fallback) ─────────────────────────────
195
196/// `●` / `*` — generic bullet.
197#[must_use]
198pub fn glyph_dot() -> &'static str {
199    if styling_enabled() { "●" } else { "*" }
200}
201
202/// `✓` / `[ok]` — success marker.
203#[must_use]
204pub fn glyph_ok() -> String { success(if styling_enabled() { "✓" } else { "[ok]" }) }
205
206/// `✗` / `[x]` — failure / divergence marker.
207#[must_use]
208pub fn glyph_fail() -> String { error(if styling_enabled() { "✗" } else { "[x]" }) }
209
210/// `⚠` / `[!]` — warning marker.
211#[must_use]
212pub fn glyph_warn() -> String { warn(if styling_enabled() { "⚠" } else { "[!]" }) }
213
214/// `⚙` / `[~]` — in-progress / typed-only marker.  Matches the
215/// substrate catalog's M2/M3/M4-typed-only marker convention.
216#[must_use]
217pub fn glyph_gear() -> String { pending(if styling_enabled() { "⚙" } else { "[~]" }) }
218
219/// `▸` / `>` — right-arrow / call-out.
220#[must_use]
221pub fn glyph_arrow() -> String { info(if styling_enabled() { "▸" } else { ">" }) }
222
223/// `❄` / `*` — Nord snowflake.  Brand glyph; used sparingly for
224/// the top-level banner.
225#[must_use]
226pub fn glyph_snowflake() -> String { fg(NORD8, if styling_enabled() { "❄" } else { "*" }) }
227
228// ── Box-drawing helpers (Nord-styled tables) ──────────────────────
229
230/// Top of a styled box, with optional title centered.  Width is the
231/// total number of columns (must be ≥ title length + 4).
232#[must_use]
233pub fn box_top(width: usize, title: Option<&str>) -> String {
234    let line = "─".repeat(width.saturating_sub(2));
235    let raw = match title {
236        None => format!("┌{line}┐"),
237        Some(t) => {
238            let pad = width.saturating_sub(t.len() + 4);
239            let half = pad / 2;
240            let other_half = pad - half;
241            format!(
242                "┌{}{}{}┐",
243                "─".repeat(half),
244                {
245                    let t = format!(" {} ", t);
246                    bold_fg(NORD8, &t)
247                },
248                "─".repeat(other_half),
249            )
250        }
251    };
252    dim_fg(NORD3, &raw)
253}
254
255/// Mid-separator row inside a box.
256#[must_use]
257pub fn box_mid(width: usize) -> String {
258    dim_fg(NORD3, &format!("├{}┤", "─".repeat(width.saturating_sub(2))))
259}
260
261/// Bottom of a styled box.
262#[must_use]
263pub fn box_bottom(width: usize) -> String {
264    dim_fg(NORD3, &format!("└{}┘", "─".repeat(width.saturating_sub(2))))
265}
266
267// ── LabeledTable — typed builder for "kv / opt / section / list" rows ──
268//
269// Three operator-facing views in sui-spec-inventory (narinfo /
270// realisation / hash-decode) reached for the same closure-set
271// (kv + opt + section + list-item).  Third-site extraction.
272//
273// Usage:
274//
275//   LabeledTable::new(14)
276//       .kv("StorePath", &rec.store_path)
277//       .kv("URL", &rec.url)
278//       .opt("Deriver", rec.deriver.as_deref())
279//       .section("References", rec.references.len())
280//       .list_items("→", &rec.references)
281//       .render();
282//
283// Required fields render as `ident()` (Nord aurora purple).
284// Optional fields render as `info()` if Some, `muted()` if None.
285// Section headers render as `body()` with a count chip.
286// List items render with a configurable glyph + `success()`.
287
288/// Typed builder for labeled-row tables.  Renders Nord-styled
289/// `right-aligned label  value` rows + sections + nested lists.
290#[must_use]
291pub struct LabeledTable {
292    label_w: usize,
293    rows: Vec<TableRow>,
294}
295
296enum TableRow {
297    Kv { key: String, value: String },
298    Opt { key: String, value: Option<String> },
299    Section { title: String, count: Option<usize> },
300    ListItem { glyph: String, value: String },
301    Blank,
302}
303
304impl LabeledTable {
305    /// Construct a new table with the given right-aligned label
306    /// width (typically 14 across the inventory binary).
307    pub fn new(label_w: usize) -> Self {
308        Self { label_w, rows: Vec::new() }
309    }
310
311    /// Add a required key-value row.  Value renders as `ident()`.
312    pub fn kv(mut self, key: &str, value: &str) -> Self {
313        self.rows.push(TableRow::Kv {
314            key: key.to_string(),
315            value: value.to_string(),
316        });
317        self
318    }
319
320    /// Add an optional key-value row.  Present values render
321    /// as `info()`, absent as `muted("(none)")`.
322    pub fn opt(mut self, key: &str, value: Option<&str>) -> Self {
323        self.rows.push(TableRow::Opt {
324            key: key.to_string(),
325            value: value.map(String::from),
326        });
327        self
328    }
329
330    /// Add a blank line for visual grouping.
331    pub fn blank(mut self) -> Self {
332        self.rows.push(TableRow::Blank);
333        self
334    }
335
336    /// Add a section header with an optional count chip.
337    pub fn section(mut self, title: &str, count: Option<usize>) -> Self {
338        self.rows.push(TableRow::Section {
339            title: title.to_string(),
340            count,
341        });
342        self
343    }
344
345    /// Add a list of nested rows under the most recent section.
346    /// Each item is prefixed with `glyph` and styled `success()`.
347    pub fn list_items<I, S>(mut self, glyph: &str, items: I) -> Self
348    where
349        I: IntoIterator<Item = S>,
350        S: AsRef<str>,
351    {
352        for item in items {
353            self.rows.push(TableRow::ListItem {
354                glyph: glyph.to_string(),
355                value: item.as_ref().to_string(),
356            });
357        }
358        self
359    }
360
361    /// Render every row to stdout.
362    pub fn render(self) {
363        for row in &self.rows {
364            match row {
365                TableRow::Kv { key, value } => {
366                    println!(
367                        "  {}  {}",
368                        body(&right_align(key, self.label_w)),
369                        ident(value),
370                    );
371                }
372                TableRow::Opt { key, value } => {
373                    let val_str = match value {
374                        Some(v) => info(v),
375                        None => muted("(none)"),
376                    };
377                    println!(
378                        "  {}  {}",
379                        body(&right_align(key, self.label_w)),
380                        val_str,
381                    );
382                }
383                TableRow::Section { title, count } => match count {
384                    Some(n) => println!(
385                        "  {}  {}",
386                        body(&right_align(title, self.label_w)),
387                        ident(&n.to_string()),
388                    ),
389                    None => println!(
390                        "  {}",
391                        body(&right_align(title, self.label_w)),
392                    ),
393                },
394                TableRow::ListItem { glyph, value } => {
395                    println!("    {}  {}", muted(glyph), success(value));
396                }
397                TableRow::Blank => println!(),
398            }
399        }
400    }
401}
402
403fn right_align(s: &str, w: usize) -> String {
404    format!("{:>w$}", s, w = w)
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    fn ensure_styling_on() {
412        // Inject the test-only styling override so these assertions are
413        // deterministic regardless of the ambient NO_COLOR/TTY state and
414        // regardless of whether another (parallel) test already primed
415        // the un-resettable `STYLING` OnceLock to `false`.
416        set_styling_override(true);
417    }
418
419    #[test]
420    fn nord_palette_is_complete() {
421        // Sanity — every nord<N> is a well-formed Rgb.
422        let all = [
423            NORD0, NORD1, NORD2, NORD3, NORD4, NORD5, NORD6, NORD7,
424            NORD8, NORD9, NORD10, NORD11, NORD12, NORD13, NORD14, NORD15,
425        ];
426        assert_eq!(all.len(), 16);
427        for c in all {
428            // All channels are valid bytes by Rust type system; the
429            // semantic check is that we didn't fat-finger 0xff vs 0x00
430            // on a known color.
431            let _ = c;
432        }
433        // Sanity: NORD0 is dark, NORD6 is light.
434        assert!(NORD0.0 < NORD6.0);
435        assert!(NORD0.1 < NORD6.1);
436        assert!(NORD0.2 < NORD6.2);
437    }
438
439    #[test]
440    fn fg_emits_truecolor_escape_when_enabled() {
441        ensure_styling_on();
442        let s = fg(NORD8, "hello");
443        assert!(s.contains("\x1b[38;2;136;192;208m"), "wrong escape: {s:?}");
444        assert!(s.contains("hello"));
445        assert!(s.ends_with("\x1b[0m"), "must reset at end: {s:?}");
446    }
447
448    #[test]
449    fn no_color_strips_escapes() {
450        // We can't test NO_COLOR with the OnceLock cache easily —
451        // SUI_FORCE_COLOR is already set from earlier tests.  The
452        // styling_enabled() short-circuit logic is exercised by
453        // its own unit assertions above.
454        ensure_styling_on();
455        // With styling forced on, fg DOES include escapes.  In a
456        // fresh process with NO_COLOR set, the same call would
457        // return the bare text — tested by integration scripts.
458        let s = fg(NORD14, "ok");
459        assert!(s.contains("ok"));
460    }
461
462    #[test]
463    fn semantic_helpers_use_distinct_colors() {
464        ensure_styling_on();
465        let s_ok = success("x");
466        let s_err = error("x");
467        let s_warn = warn("x");
468        assert_ne!(s_ok, s_err);
469        assert_ne!(s_ok, s_warn);
470        assert_ne!(s_err, s_warn);
471    }
472
473    #[test]
474    fn glyphs_have_unicode_when_enabled() {
475        ensure_styling_on();
476        assert!(glyph_ok().contains('✓'));
477        assert!(glyph_fail().contains('✗'));
478        assert!(glyph_warn().contains('⚠'));
479        assert!(glyph_gear().contains('⚙'));
480        assert!(glyph_snowflake().contains('❄'));
481    }
482
483    #[test]
484    fn box_top_renders_with_title() {
485        ensure_styling_on();
486        let s = box_top(40, Some("sui-spec"));
487        assert!(s.contains("┌"));
488        assert!(s.contains("┐"));
489        // Title byte is in there somewhere (under ANSI escapes).
490        assert!(s.contains("sui-spec"));
491    }
492
493    // ── LabeledTable tests ─────────────────────────────────────
494
495    #[test]
496    fn labeled_table_builder_is_chainable() {
497        // Compile-time proof the builder threads through chained
498        // calls without ownership issues — drop the result, just
499        // ensure construction works.
500        let _t = LabeledTable::new(14)
501            .kv("key1", "val1")
502            .kv("key2", "val2")
503            .opt("optKey", Some("optVal"))
504            .opt("absent", None)
505            .blank()
506            .section("Section", Some(3))
507            .list_items("→", ["a", "b", "c"]);
508    }
509
510    #[test]
511    fn labeled_table_accepts_string_and_str_in_list_items() {
512        let owned: Vec<String> = vec!["a".into(), "b".into()];
513        let _ = LabeledTable::new(10)
514            .list_items("→", &owned);
515        let borrowed = ["x", "y"];
516        let _ = LabeledTable::new(10)
517            .list_items("→", borrowed);
518    }
519
520    #[test]
521    fn right_align_pads_to_width() {
522        assert_eq!(right_align("ab", 6), "    ab");
523        assert_eq!(right_align("longer", 4), "longer");
524    }
525}