git_policy_cli/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/module/logger/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 super::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///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// process.env.MONOCHROMATIC_VERBOSE === 'true'
35/// ```
36pub fn verbose_enabled() -> bool {
37 return std::env::var_os("MONOCHROMATIC_VERBOSE").is_some_and(is_true);
38}
39
40/// What:
41/// Whether an environment value is exactly `true`.
42/// Why:
43/// A named function keeps the check out of a closure.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// (value: string) => value === 'true'
48/// ```
49fn is_true(value: std::ffi::OsString) -> bool {
50 return value == "true";
51}
52
53/// What:
54/// Whether warnings are suppressed:
55/// `MONOCHROMATIC_WARN` is exactly `false`.
56/// Why:
57/// Machine-protocol consumers silence warnings this way in the incumbent.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// process.env.MONOCHROMATIC_WARN === 'false'
62/// ```
63pub fn warnings_suppressed() -> bool {
64 return std::env::var_os("MONOCHROMATIC_WARN").is_some_and(is_false);
65}
66
67/// What:
68/// Whether an environment value is exactly `false`.
69/// Why:
70/// A named function keeps the check out of a closure.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// (value: string) => value === 'false'
75/// ```
76fn is_false(value: std::ffi::OsString) -> bool {
77 return value == "false";
78}
79
80/// What:
81/// One diagnostic line in the incumbent's console format,
82/// `[<level>] [<ISO time>] [cli-git] [<tag>] <message>`,
83/// with every control character of
84/// the message replaced by a space.
85/// Why:
86/// The incumbent's tagged loggers print `[warn] [2026-10-06T21:40:39.524Z] [cli-git] ...`;
87/// one line per record keeps standard error parseable beside JSONL events.
88///
89/// In TS you'd write (pseudocode):
90/// ```ts
91/// `[${level}] [${new Date().toISOString()}] [cli-git] [${tag}] ${neutralize(message)}\n`
92/// ```
93pub fn diagnostic_line(level: &str, time: &str, tag: &str, message: &str) -> String {
94 // `mut` allows building the neutralized message one character at a time.
95 let mut single: String = String::with_capacity(message.len());
96 for character in message.chars() {
97 if character.is_control() {
98 single.push(' ');
99 } else {
100 single.push(character);
101 }
102 }
103 return format!("[{level}] [{time}] [cli-git] [{tag}] {single}\n");
104}
105
106/// What:
107/// Write one complete line to standard error,
108/// ignoring a closed stream.
109/// Why:
110/// A diagnostic must never turn into a failure of the command it describes.
111///
112/// In TS you'd write (pseudocode):
113/// ```ts
114/// process.stderr.write(line);
115/// ```
116fn write_line(line: &str) {
117 // `let _ =` discards the write result: there is nowhere left to report it.
118 let _ = std::io::stderr().write_all(line.as_bytes());
119}
120
121/// What:
122/// Print a debug diagnostic when verbose diagnostics are on.
123/// `tag` names the function or module boundary,
124/// as the incumbent's tagged loggers do.
125/// Why:
126/// Decisions such as "owner alive,
127/// skipping" are invisible otherwise.
128///
129/// In TS you'd write (pseudocode):
130/// ```ts
131/// tagged({ tag }).debug(message);
132/// ```
133pub fn debug(tag: &str, message: &str) {
134 if verbose_enabled() {
135 write_line(diagnostic_line("debug", now_iso().as_str(), tag, message).as_str());
136 }
137}
138
139/// What:
140/// Print a warning unless warnings are suppressed.
141/// Why:
142/// Conditions the user should know about that do not fail the command,
143/// such as a
144/// landed-capture record that could not be written.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// tagged({ tag }).warn(message);
149/// ```
150pub fn warn(tag: &str, message: &str) {
151 if !warnings_suppressed() {
152 write_line(diagnostic_line("warn", now_iso().as_str(), tag, message).as_str());
153 }
154}
155
156/// Line formatting controls stay out of the release executable.
157#[cfg(test)]
158#[path = "diagnostic_log_tests.rs"]
159mod tests;