Skip to main content

zad_cli/
output.rs

1//! Central semantic output module (OSS_SPEC.md §19.4).
2//!
3//! Non-contract CLI output (progress, warnings, headers, status lines)
4//! should go through this module rather than raw `println!`/`eprintln!`
5//! so every message is:
6//!
7//! 1. Echoed to stderr for humans, leaving stdout reserved for
8//!    machine-readable output (`--json`, `--help-agent`, etc.) —
9//!    tools piping stdout to `jq` never see incidental status text.
10//! 2. Mirrored to the always-on tracing file log so we have a trail of
11//!    what the CLI told the user.
12//! 3. Coloured consistently on a TTY (via a tiny ANSI helper) and plain
13//!    when stderr is redirected.
14//!
15//! Callers that emit machine-readable output (e.g. JSON replies to
16//! `--json` flags) or dedicated discovery surfaces
17//! (`--help-agent`, `--debug-agent`, `zad commands`, `zad docs`,
18//! `zad man`) MUST continue to use raw `println!` so the contract holds
19//! byte-for-byte.
20
21use std::io::{IsTerminal, Write};
22use std::sync::OnceLock;
23
24/// Renders `msg` as a neutral status line — the default for user-facing
25/// progress ("Loaded 4 channels").
26pub fn status(msg: &str) {
27    emit(Tone::Status, msg);
28    tracing::info!("{msg}");
29}
30
31/// Renders `msg` as an informational note (same weight as `status` but
32/// typed so future theming can distinguish them).
33pub fn info(msg: &str) {
34    emit(Tone::Info, msg);
35    tracing::info!("{msg}");
36}
37
38/// Renders `msg` as a warning that doesn't abort the command.
39pub fn warn(msg: &str) {
40    emit(Tone::Warn, msg);
41    tracing::warn!("{msg}");
42}
43
44/// Renders `msg` as a prominent header/section separator. Unlike the
45/// others, headers include a blank line before the banner.
46pub fn header(msg: &str) {
47    eprintln!();
48    emit(Tone::Header, msg);
49    tracing::info!("{msg}");
50}
51
52/// Renders `msg` as an error. Does not exit — the caller propagates a
53/// `Result` upward.
54pub fn error(msg: &str) {
55    emit(Tone::Error, msg);
56    tracing::error!("{msg}");
57}
58
59#[derive(Clone, Copy)]
60enum Tone {
61    Status,
62    Info,
63    Warn,
64    Header,
65    Error,
66}
67
68fn emit(tone: Tone, msg: &str) {
69    let ansi = ansi_enabled();
70    let mut stderr = std::io::stderr().lock();
71    match tone {
72        Tone::Status => {
73            if ansi {
74                let _ = writeln!(stderr, "\x1b[2m{msg}\x1b[0m");
75            } else {
76                let _ = writeln!(stderr, "{msg}");
77            }
78        }
79        Tone::Info => {
80            let _ = writeln!(stderr, "{msg}");
81        }
82        Tone::Warn => {
83            if ansi {
84                let _ = writeln!(stderr, "\x1b[33mwarning:\x1b[0m {msg}");
85            } else {
86                let _ = writeln!(stderr, "warning: {msg}");
87            }
88        }
89        Tone::Header => {
90            if ansi {
91                let _ = writeln!(stderr, "\x1b[1m{msg}\x1b[0m");
92            } else {
93                let _ = writeln!(stderr, "{msg}");
94            }
95        }
96        Tone::Error => {
97            if ansi {
98                let _ = writeln!(stderr, "\x1b[31merror:\x1b[0m {msg}");
99            } else {
100                let _ = writeln!(stderr, "error: {msg}");
101            }
102        }
103    }
104}
105
106fn ansi_enabled() -> bool {
107    static CACHED: OnceLock<bool> = OnceLock::new();
108    *CACHED.get_or_init(|| {
109        if std::env::var_os("NO_COLOR").is_some() {
110            return false;
111        }
112        std::io::stderr().is_terminal()
113    })
114}