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}