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}