use std::sync::OnceLock;
use serde::Serialize;
use crate::error::{MindError, Result};
static CTX: OnceLock<OutputCtx> = OnceLock::new();
pub(crate) fn print_json<T: Serialize>(value: &T) -> Result<()> {
let s = serde_json::to_string_pretty(value).map_err(|e| MindError::json("json output", e))?;
println!("{s}");
Ok(())
}
pub(crate) fn print_json_envelope<T: Serialize>(items: &[T]) -> Result<()> {
#[derive(Serialize)]
struct Envelope<'a, T: Serialize> {
schema: u8,
items: &'a [T],
}
print_json(&Envelope { schema: 1, items })
}
pub fn note(msg: impl AsRef<str>) {
let msg = msg.as_ref();
if ctx().json {
eprintln!("{msg}");
} else {
println!("{msg}");
}
}
pub fn warn(msg: impl AsRef<str>) {
let out = ctx();
note(format!("{} {}", out.warn(), msg.as_ref()));
}
pub fn set_ctx(ctx: OutputCtx) {
let _ = CTX.set(ctx);
}
pub fn ctx() -> OutputCtx {
CTX.get().copied().unwrap_or(OutputCtx {
json: false,
color: false,
unicode: false,
verbose: false,
})
}
#[derive(Clone, Copy)]
pub struct OutputCtx {
pub json: bool,
pub color: bool,
pub unicode: bool,
pub verbose: bool,
}
impl OutputCtx {
pub fn detect(json: bool, ascii: bool, verbose: bool) -> Self {
use std::io::IsTerminal;
let is_tty = std::io::stdout().is_terminal();
let no_color = std::env::var_os("NO_COLOR").is_some();
let utf8_locale = detect_utf8_locale();
Self {
verbose,
..Self::compute(json, ascii, is_tty, no_color, utf8_locale)
}
}
pub fn compute(
json: bool,
ascii: bool,
is_tty: bool,
no_color: bool,
utf8_locale: bool,
) -> Self {
let rich = is_tty && utf8_locale && !no_color && !json && !ascii;
Self {
json,
color: rich,
unicode: rich,
verbose: false,
}
}
pub fn ok(&self) -> String {
if self.unicode {
self.green("✓")
} else {
"+".to_string()
}
}
pub fn stale(&self) -> String {
if self.unicode {
self.yellow("↑")
} else {
"^".to_string()
}
}
pub fn available(&self) -> String {
if self.unicode {
self.dim("○")
} else {
"-".to_string()
}
}
pub fn warn(&self) -> String {
if self.unicode {
self.yellow("!")
} else {
"!".to_string()
}
}
pub fn err(&self) -> String {
if self.unicode {
self.red("✗")
} else {
"x".to_string()
}
}
pub fn bullet(&self) -> String {
if self.unicode {
"●".to_string()
} else {
"*".to_string()
}
}
pub fn green(&self, s: &str) -> String {
if self.color {
format!("\x1b[32m{s}\x1b[0m")
} else {
s.to_string()
}
}
pub fn yellow(&self, s: &str) -> String {
if self.color {
format!("\x1b[33m{s}\x1b[0m")
} else {
s.to_string()
}
}
pub fn red(&self, s: &str) -> String {
if self.color {
format!("\x1b[31m{s}\x1b[0m")
} else {
s.to_string()
}
}
pub fn dim(&self, s: &str) -> String {
if self.color {
format!("\x1b[2m{s}\x1b[0m")
} else {
s.to_string()
}
}
pub fn bold(&self, s: &str) -> String {
if self.color {
format!("\x1b[1m{s}\x1b[0m")
} else {
s.to_string()
}
}
pub fn print_rows(&self, rows: &[Vec<String>]) {
let Some(ncols) = rows.iter().map(Vec::len).max() else {
return;
};
let mut widths = vec![0usize; ncols];
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i + 1 < ncols {
widths[i] = widths[i].max(visible_width(cell));
}
}
}
for row in rows {
let mut line = String::new();
for (i, cell) in row.iter().enumerate() {
if i > 0 {
line.push_str(" ");
}
if i + 1 < ncols {
let pad = widths[i].saturating_sub(visible_width(cell));
line.push_str(cell);
line.extend(std::iter::repeat_n(' ', pad));
} else {
line.push_str(cell);
}
}
println!("{}", line.trim_end());
}
}
}
pub fn visible_width(s: &str) -> usize {
use unicode_width::UnicodeWidthChar;
let mut count = 0usize;
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.next() == Some('[') {
for c2 in chars.by_ref() {
if c2.is_ascii_alphabetic() {
break;
}
}
}
} else {
count += c.width().unwrap_or(0);
}
}
count
}
fn detect_utf8_locale() -> bool {
for var in &["LC_ALL", "LC_CTYPE", "LANG"] {
if let Ok(val) = std::env::var(var)
&& !val.is_empty()
{
let lower = val.to_lowercase();
return lower.contains("utf-8") || lower.contains("utf8");
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn plain() -> OutputCtx {
OutputCtx::compute(false, false, false, false, false)
}
fn rich() -> OutputCtx {
OutputCtx::compute(false, false, true, false, true)
}
#[test]
fn compute_all_true_yields_rich() {
let ctx = OutputCtx::compute(false, false, true, false, true);
assert!(ctx.color, "color should be true when all conditions met");
assert!(
ctx.unicode,
"unicode should be true when all conditions met"
);
assert!(!ctx.json);
}
#[test]
fn compute_json_disables_color() {
let ctx = OutputCtx::compute(true, false, true, false, true);
assert!(!ctx.color, "json disables color");
assert!(!ctx.unicode, "json disables unicode");
assert!(ctx.json);
}
#[test]
fn compute_ascii_disables_color() {
let ctx = OutputCtx::compute(false, true, true, false, true);
assert!(!ctx.color, "ascii flag disables color");
assert!(!ctx.unicode, "ascii flag disables unicode");
}
#[test]
fn compute_non_tty_disables_color() {
let ctx = OutputCtx::compute(false, false, false, false, true);
assert!(!ctx.color, "non-tty disables color");
assert!(!ctx.unicode, "non-tty disables unicode");
}
#[test]
fn compute_no_color_env_disables_color() {
let ctx = OutputCtx::compute(false, false, true, true, true);
assert!(!ctx.color, "NO_COLOR disables color");
assert!(!ctx.unicode, "NO_COLOR disables unicode");
}
#[test]
fn compute_non_utf8_locale_disables_color() {
let ctx = OutputCtx::compute(false, false, true, false, false);
assert!(!ctx.color, "non-utf8 locale disables color");
assert!(!ctx.unicode, "non-utf8 locale disables unicode");
}
#[test]
fn compute_color_and_unicode_always_equal() {
for json in [false, true] {
for ascii in [false, true] {
for tty in [false, true] {
for no_color in [false, true] {
for utf8 in [false, true] {
let ctx = OutputCtx::compute(json, ascii, tty, no_color, utf8);
assert_eq!(
ctx.color, ctx.unicode,
"color and unicode must be equal \
(json={json} ascii={ascii} tty={tty} no_color={no_color} utf8={utf8})"
);
}
}
}
}
}
}
#[test]
fn ok_unicode_contains_checkmark_and_ansi() {
let s = rich().ok();
assert!(s.contains('✓'), "ok() unicode should contain ✓, got {s:?}");
assert!(
s.contains('\x1b'),
"ok() unicode should contain ANSI code, got {s:?}"
);
}
#[test]
fn ok_ascii_is_plus_no_ansi() {
let s = plain().ok();
assert_eq!(s, "+", "ok() ascii should be '+', got {s:?}");
assert!(!s.contains('\x1b'), "ok() ascii must not contain ANSI code");
}
#[test]
fn stale_unicode_contains_uparrow_and_ansi() {
let s = rich().stale();
assert!(
s.contains('↑'),
"stale() unicode should contain ↑, got {s:?}"
);
assert!(
s.contains('\x1b'),
"stale() unicode should contain ANSI code, got {s:?}"
);
}
#[test]
fn stale_ascii_is_caret_no_ansi() {
let s = plain().stale();
assert_eq!(s, "^", "stale() ascii should be '^', got {s:?}");
assert!(
!s.contains('\x1b'),
"stale() ascii must not contain ANSI code"
);
}
#[test]
fn available_unicode_contains_circle_and_ansi() {
let s = rich().available();
assert!(
s.contains('○'),
"available() unicode should contain ○, got {s:?}"
);
assert!(
s.contains('\x1b'),
"available() unicode should contain ANSI code"
);
}
#[test]
fn available_ascii_is_dash_no_ansi() {
let s = plain().available();
assert_eq!(s, "-", "available() ascii should be '-', got {s:?}");
assert!(!s.contains('\x1b'));
}
#[test]
fn warn_unicode_contains_bang_and_ansi() {
let s = rich().warn();
assert!(s.contains('!'), "warn() unicode should contain '!'");
assert!(
s.contains('\x1b'),
"warn() unicode should contain ANSI code"
);
}
#[test]
fn warn_ascii_is_bang_no_ansi() {
let s = plain().warn();
assert_eq!(s, "!", "warn() ascii should be '!'");
assert!(!s.contains('\x1b'));
}
#[test]
fn err_unicode_contains_cross_and_ansi() {
let s = rich().err();
assert!(s.contains('✗'), "err() unicode should contain ✗, got {s:?}");
assert!(s.contains('\x1b'), "err() unicode should contain ANSI code");
}
#[test]
fn err_ascii_is_x_no_ansi() {
let s = plain().err();
assert_eq!(s, "x", "err() ascii should be 'x', got {s:?}");
assert!(!s.contains('\x1b'));
}
#[test]
fn bullet_unicode_is_filled_circle_no_ansi_wrap() {
let s = rich().bullet();
assert!(
s.contains('●'),
"bullet() unicode should contain ●, got {s:?}"
);
assert!(
!s.contains('\x1b'),
"bullet() should not contain ANSI codes"
);
}
#[test]
fn bullet_ascii_is_star_no_ansi() {
let s = plain().bullet();
assert_eq!(s, "*", "bullet() ascii should be '*', got {s:?}");
assert!(!s.contains('\x1b'));
}
#[test]
fn color_wrappers_are_noop_when_color_off() {
let ctx = plain();
assert_eq!(ctx.green("hi"), "hi");
assert_eq!(ctx.yellow("hi"), "hi");
assert_eq!(ctx.red("hi"), "hi");
assert_eq!(ctx.dim("hi"), "hi");
assert_eq!(ctx.bold("hi"), "hi");
}
#[test]
fn color_wrappers_wrap_and_reset_when_color_on() {
let ctx = rich();
let g = ctx.green("hi");
assert!(g.starts_with("\x1b[32m"), "green should start with SGR 32");
assert!(g.ends_with("\x1b[0m"), "green should end with reset");
assert!(g.contains("hi"), "green should contain the text");
let y = ctx.yellow("hi");
assert!(y.starts_with("\x1b[33m"), "yellow should start with SGR 33");
assert!(y.ends_with("\x1b[0m"));
let r = ctx.red("hi");
assert!(r.starts_with("\x1b[31m"), "red should start with SGR 31");
assert!(r.ends_with("\x1b[0m"));
let d = ctx.dim("hi");
assert!(d.starts_with("\x1b[2m"), "dim should start with SGR 2");
assert!(d.ends_with("\x1b[0m"));
let b = ctx.bold("hi");
assert!(b.starts_with("\x1b[1m"), "bold should start with SGR 1");
assert!(b.ends_with("\x1b[0m"));
}
#[test]
fn visible_width_plain_string_counts_chars() {
assert_eq!(visible_width("hello"), 5);
assert_eq!(visible_width(""), 0);
assert_eq!(visible_width("abc"), 3);
}
#[test]
fn visible_width_colored_equals_plain_width() {
let colored = "\x1b[32mhello\x1b[0m";
assert_eq!(
visible_width(colored),
5,
"ANSI codes must not count toward width"
);
}
#[test]
fn visible_width_multibyte_counts_chars_not_bytes() {
assert_eq!(visible_width("✓"), 1);
assert_eq!(visible_width("●"), 1);
let s = "\x1b[32m✓\x1b[0m";
assert_eq!(
visible_width(s),
1,
"multi-byte char inside ANSI must count as 1"
);
}
#[test]
fn visible_width_strips_multiple_escapes() {
let s = "\x1b[1mhello\x1b[0m \x1b[31mworld\x1b[0m";
assert_eq!(visible_width(s), 11);
}
#[test]
fn visible_width_counts_wide_cjk_chars_as_two_columns() {
assert_eq!(
visible_width("\u{4e2d}"),
2,
"one wide CJK char is 2 columns"
);
assert_eq!(
visible_width("\u{4e2d}\u{6587}"),
4,
"two wide CJK chars are 4 columns"
);
assert_eq!(visible_width("ab\u{4e2d}c"), 5);
}
fn compute_column_widths(rows: &[Vec<String>]) -> Vec<usize> {
let Some(ncols) = rows.iter().map(Vec::len).max() else {
return vec![];
};
let mut widths = vec![0usize; ncols];
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i + 1 < ncols {
widths[i] = widths[i].max(visible_width(cell));
}
}
}
widths
}
#[test]
fn print_rows_alignment_plain_cells() {
let rows = vec![
vec!["a".to_string(), "bc".to_string(), "desc1".to_string()],
vec!["xyz".to_string(), "d".to_string(), "desc2".to_string()],
];
let widths = compute_column_widths(&rows);
assert_eq!(widths[0], 3, "col 0 width should be 3");
assert_eq!(widths[1], 2, "col 1 width should be 2");
assert_eq!(widths[2], 0, "last col not padded");
}
#[test]
fn print_rows_colored_cell_aligns_same_as_uncolored() {
let colored_plus = "\x1b[32m+\x1b[0m".to_string();
let plain_plus = "+".to_string();
let rows_colored = vec![
vec![
colored_plus.clone(),
"long-name".to_string(),
"desc".to_string(),
],
vec!["x".to_string(), "n".to_string(), "desc2".to_string()],
];
let rows_plain = vec![
vec![
plain_plus.clone(),
"long-name".to_string(),
"desc".to_string(),
],
vec!["x".to_string(), "n".to_string(), "desc2".to_string()],
];
let widths_colored = compute_column_widths(&rows_colored);
let widths_plain = compute_column_widths(&rows_plain);
assert_eq!(
widths_colored[0], widths_plain[0],
"colored and plain cells in the same column must produce the same width: \
colored={} plain={}",
widths_colored[0], widths_plain[0]
);
}
#[test]
fn print_rows_single_column_no_padding() {
let rows = vec![vec!["hello".to_string()], vec!["world!".to_string()]];
let widths = compute_column_widths(&rows);
assert_eq!(widths[0], 0);
}
#[test]
fn print_rows_empty_rows_no_panic() {
let ctx = plain();
ctx.print_rows(&[]);
ctx.print_rows(&[vec![]]);
}
#[test]
fn detect_propagates_verbose_flag() {
assert!(
OutputCtx::detect(false, false, true).verbose,
"detect(_, _, true) must yield verbose=true"
);
assert!(
!OutputCtx::detect(false, false, false).verbose,
"detect(_, _, false) must yield verbose=false"
);
}
#[test]
fn verbose_does_not_affect_color_or_unicode() {
for json in [false, true] {
for ascii in [false, true] {
let off = OutputCtx::detect(json, ascii, false);
let on = OutputCtx::detect(json, ascii, true);
assert_eq!(
off.color, on.color,
"verbose must not change color (json={json} ascii={ascii})"
);
assert_eq!(
off.unicode, on.unicode,
"verbose must not change unicode (json={json} ascii={ascii})"
);
assert_eq!(
off.json, on.json,
"verbose must not change json (json={json} ascii={ascii})"
);
assert!(!off.verbose, "verbose=false expected");
assert!(on.verbose, "verbose=true expected");
}
}
}
#[test]
fn compute_leaves_verbose_false() {
for tty in [false, true] {
for utf8 in [false, true] {
let ctx = OutputCtx::compute(false, false, tty, false, utf8);
assert!(
!ctx.verbose,
"compute must not set verbose (tty={tty} utf8={utf8})"
);
}
}
}
#[test]
fn ctx_default_is_plain() {
let c = ctx();
assert!(!c.color, "default/plain ctx must have color=false");
assert!(!c.unicode, "default/plain ctx must have unicode=false");
assert!(!c.json, "default/plain ctx must have json=false");
}
const ROUTING_MODE_ENV: &str = "MIND_TEST_RENDER_ROUTING_MODE";
const NOTE_SENTINEL: &str = "ZZ-RENDER-ROUTING-NOTE-ZZ";
const WARN_SENTINEL: &str = "ZZ-RENDER-ROUTING-WARN-ZZ";
const ROUTING_TEST: &str = "render::tests::note_and_warn_route_by_output_mode";
fn routing_probe(mode: &str) -> (String, String) {
let exe = std::env::current_exe().expect("path of the running test binary");
let out = std::process::Command::new(exe)
.args(["--exact", ROUTING_TEST, "--nocapture", "--test-threads=1"])
.env(ROUTING_MODE_ENV, mode)
.output()
.expect("re-exec the test binary");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[test]
fn note_and_warn_route_by_output_mode() {
if let Ok(mode) = std::env::var(ROUTING_MODE_ENV) {
set_ctx(OutputCtx {
json: mode == "json",
color: false,
unicode: false,
verbose: false,
});
note(NOTE_SENTINEL);
warn(WARN_SENTINEL);
std::process::exit(0);
}
let (text_out, text_err) = routing_probe("text");
assert!(
text_out.contains(NOTE_SENTINEL),
"text mode must write a note to STDOUT (this is the `println!` the \
conversion replaced): stdout={text_out:?} stderr={text_err:?}"
);
assert!(
!text_err.contains(NOTE_SENTINEL),
"text mode must not move the note to stderr: {text_err:?}"
);
assert!(
text_out.contains(&format!("! {WARN_SENTINEL}")),
"warn must prefix the context's warn marker (`!`) and land on \
stdout in text mode: {text_out:?}"
);
assert!(
!text_err.contains(WARN_SENTINEL),
"text mode must not move the warning to stderr: {text_err:?}"
);
let (json_out, json_err) = routing_probe("json");
assert!(
!json_out.contains(NOTE_SENTINEL),
"under --json a note must never touch stdout: {json_out:?}"
);
assert!(
json_err.contains(NOTE_SENTINEL),
"under --json the note must still be emitted, on stderr: \
stdout={json_out:?} stderr={json_err:?}"
);
assert!(
!json_out.contains(WARN_SENTINEL),
"under --json a warning must never touch stdout: {json_out:?}"
);
assert!(
json_err.contains(&format!("! {WARN_SENTINEL}")),
"under --json the warning must still be emitted on stderr, marker \
and all (json forces the capability gate off, so the marker is the \
ASCII `!`): {json_err:?}"
);
}
#[test]
fn ctx_set_then_get_reflects_installed_value() {
let installed = OutputCtx {
json: false,
color: false,
unicode: false,
verbose: false,
};
set_ctx(installed);
let got = ctx();
assert_eq!(got.json, installed.json);
assert_eq!(got.color, installed.color);
assert_eq!(got.unicode, installed.unicode);
assert_eq!(got.verbose, installed.verbose);
}
}