git_policy_cli/foundation/diagnostic_log.rs
1//! What:
2//! Line-oriented debug and warning diagnostics on standard error,
3//! switched like the
4//! incumbent's console sink.
5//! Why:
6//! The incumbent prints debug records only when `MONOCHROMATIC_VERBOSE=true` and warnings
7//! unless `MONOCHROMATIC_WARN=false` (`package/logger/core/src/sink/console.ts`),
8//! and its
9//! spec requires that diagnostics never corrupt a JSONL stream:
10//! each diagnostic is one
11//! complete line written in one call (`SPEC.md`,
12//! "Debug logging").
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const l = tagged({ tag: 'cli-git' }); l.debug(message); l.warn(message);
17//! ```
18
19/// The current time in `toISOString` form.
20use crate::iso_time::now_iso;
21/// The trait that gives output streams `.write_all(..)`.
22use std::io::Write;
23
24/// What:
25/// Whether debug diagnostics are on:
26/// `MONOCHROMATIC_VERBOSE` is exactly `true`.
27/// Why:
28/// The switch is shared with the TypeScript tools (decision recorded in
29/// `doc/handover/cli-git-rust-implementation.md`,
30/// "Adopted without a question";
31/// that section moved on 2026-11-03 to
32/// `doc/planning/cli-git-rust-open-decisions.md`).
33///
34/// In TS you'd write (pseudocode):
35/// ```ts
36/// process.env.MONOCHROMATIC_VERBOSE === 'true'
37/// ```
38pub fn verbose_enabled() -> bool {
39 return std::env::var_os("MONOCHROMATIC_VERBOSE").is_some_and(is_true);
40}
41
42/// What:
43/// Whether an environment value is exactly `true`.
44/// Why:
45/// A named function keeps the check out of a closure.
46///
47/// In TS you'd write (pseudocode):
48/// ```ts
49/// (value: string) => value === 'true'
50/// ```
51fn is_true(value: std::ffi::OsString) -> bool {
52 return value == "true";
53}
54
55/// What:
56/// Whether warnings are suppressed:
57/// `MONOCHROMATIC_WARN` is exactly `false`.
58/// Why:
59/// Machine-protocol consumers silence warnings this way in the incumbent.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// process.env.MONOCHROMATIC_WARN === 'false'
64/// ```
65pub fn warnings_suppressed() -> bool {
66 return std::env::var_os("MONOCHROMATIC_WARN").is_some_and(is_false);
67}
68
69/// What:
70/// Whether an environment value is exactly `false`.
71/// Why:
72/// A named function keeps the check out of a closure.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// (value: string) => value === 'false'
77/// ```
78fn is_false(value: std::ffi::OsString) -> bool {
79 return value == "false";
80}
81
82/// What:
83/// One diagnostic line in the incumbent's console format,
84/// `[<level>] [<ISO time>] [cli-git] [<tag>] <message>`,
85/// with every control character of
86/// the message replaced by a space.
87/// Why:
88/// The incumbent's tagged loggers print `[warn] [2026-10-06T21:40:39.524Z] [cli-git] ...`;
89/// one line per record keeps standard error parseable beside JSONL events.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// `[${level}] [${new Date().toISOString()}] [cli-git] [${tag}] ${neutralize(message)}\n`
94/// ```
95pub fn diagnostic_line(level: &str, time: &str, tag: &str, message: &str) -> String {
96 // `mut` allows building the neutralized message one character at a time.
97 let mut single: String = String::with_capacity(message.len());
98 for character in message.chars() {
99 if character.is_control() {
100 single.push(' ');
101 } else {
102 single.push(character);
103 }
104 }
105 return format!("[{level}] [{time}] [cli-git] [{tag}] {single}\n");
106}
107
108/// What:
109/// Write one complete line to standard error,
110/// ignoring a closed stream.
111/// Why:
112/// A diagnostic must never turn into a failure of the command it describes.
113///
114/// In TS you'd write (pseudocode):
115/// ```ts
116/// process.stderr.write(line);
117/// ```
118fn write_line(line: &str) {
119 // `let _ =` discards the write result: there is nowhere left to report it.
120 let _ = std::io::stderr().write_all(line.as_bytes());
121}
122
123/// What:
124/// Print a debug diagnostic when verbose diagnostics are on.
125/// `tag` names the function or module boundary,
126/// as the incumbent's tagged loggers do.
127/// Why:
128/// Decisions such as "owner alive,
129/// skipping" are invisible otherwise.
130///
131/// In TS you'd write (pseudocode):
132/// ```ts
133/// tagged({ tag }).debug(message);
134/// ```
135pub fn debug(tag: &str, message: &str) {
136 if verbose_enabled() {
137 write_line(diagnostic_line("debug", now_iso().as_str(), tag, message).as_str());
138 }
139}
140
141/// What:
142/// Print a warning unless warnings are suppressed.
143/// Why:
144/// Conditions the user should know about that do not fail the command,
145/// such as a
146/// landed-capture record that could not be written.
147///
148/// In TS you'd write (pseudocode):
149/// ```ts
150/// tagged({ tag }).warn(message);
151/// ```
152pub fn warn(tag: &str, message: &str) {
153 if !warnings_suppressed() {
154 write_line(diagnostic_line("warn", now_iso().as_str(), tag, message).as_str());
155 }
156}
157
158/// Line formatting controls stay out of the release executable.
159#[cfg(test)]
160#[path = "diagnostic_log_tests.rs"]
161mod tests;