Skip to main content

apr_cli/
verbosity.rs

1//! Process-wide output level for `--quiet` / `--verbose` (dogfood-0.63.0, #2401).
2//!
3//! `-q, --quiet` and `-v, --verbose` are declared as clap **globals** on
4//! [`crate::Cli`], so clap prints them in the Options block of all 104
5//! subcommands' `--help`. Until this module existed nothing read them: in
6//! v0.63.0 `apr inspect m.apr` and `apr inspect m.apr --quiet` produced
7//! byte-identical stdout on 14 of 16 sampled commands, `apr hex m.apr
8//! --quiet` still wrote 303 972 bytes, and `apr gbnf-lint ... -q` still
9//! printed the full PASS report. Only `list` and `lint` — the two commands
10//! that happened to receive `quiet` as a parameter — honoured it.
11//!
12//! Threading a `quiet` parameter into every command is the design that
13//! already failed: it is the same forwarding bug `--offline` had (see
14//! [`crate::commands::offline`]), where three commands forgot to pass the
15//! flag along and the control was silently inert. So this is a **latch**,
16//! set once in [`crate::execute_command`], plus a crate-wide shadow of
17//! `println!`/`print!` that consults it. A command cannot disarm `--quiet`
18//! by forgetting to plumb a parameter, because it never receives one.
19//!
20//! Semantics:
21//!
22//! * `--quiet` suppresses ordinary stdout. stderr is untouched, so the
23//!   `error: ...` line printed by [`crate::cli_main`] and the process exit
24//!   code both survive — "errors only", as the help text promises.
25//! * `--quiet` does **not** suppress `--json`: the JSON document is the
26//!   machine-readable payload a script asked for, and swallowing it would
27//!   make `--json --quiet` useless. [`stdout_suppressed`] returns false
28//!   whenever `--json` is in effect.
29//! * A command that implements its own richer quiet semantics opts out of
30//!   the blanket gate with [`emitln!`]/[`emit!`]. Two do: `apr list --quiet`
31//!   must still print one model identifier per line (contract
32//!   `apr-list-quiet-wiring-v1` F-LIST-QUIET-001), and `apr lint --quiet`
33//!   filters its table down to errors rather than going silent.
34//! * `--verbose` raises the level so commands can print detail they
35//!   otherwise elide; see [`is_verbose`] and [`vprintln!`].
36//! * `--quiet` wins over `--verbose` when both are given.
37
38use std::cell::Cell;
39use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
40
41/// How much ordinary stdout a run should produce.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Level {
44    /// `--quiet`: ordinary stdout suppressed.
45    Quiet,
46    /// Neither flag given.
47    Normal,
48    /// `--verbose`: commands may print elided detail.
49    Verbose,
50}
51
52impl Level {
53    const fn as_u8(self) -> u8 {
54        match self {
55            Level::Quiet => 0,
56            Level::Normal => 1,
57            Level::Verbose => 2,
58        }
59    }
60
61    const fn from_u8(v: u8) -> Level {
62        match v {
63            0 => Level::Quiet,
64            2 => Level::Verbose,
65            _ => Level::Normal,
66        }
67    }
68}
69
70/// Resolve the two flags into one level.
71///
72/// `--quiet` wins over `--verbose`: the pair used to be accepted with no
73/// error and no effect at all, so *some* rule is needed, and silencing is
74/// the safer of the two for anything reading the stream.
75#[must_use]
76pub fn resolve(quiet: bool, verbose: bool) -> Level {
77    if quiet {
78        Level::Quiet
79    } else if verbose {
80        Level::Verbose
81    } else {
82        Level::Normal
83    }
84}
85
86static PROCESS_LEVEL: AtomicU8 = AtomicU8::new(Level::Normal.as_u8());
87static PROCESS_JSON: AtomicBool = AtomicBool::new(false);
88
89thread_local! {
90    /// Thread-scoped override used by [`scope`] so tests can drive the gate
91    /// without mutating process-global state shared with ~6 600 siblings.
92    static THREAD_LEVEL: Cell<Option<Level>> = const { Cell::new(None) };
93    static THREAD_JSON: Cell<Option<bool>> = const { Cell::new(None) };
94}
95
96/// Record the run's output level. Called once from `execute_command`.
97///
98/// Never resets to [`Level::Normal`]: an in-process test that runs a second
99/// command must not be able to un-quiet a run the user asked to be quiet.
100pub fn latch(quiet: bool, verbose: bool, json: bool) {
101    match resolve(quiet, verbose) {
102        Level::Normal => {}
103        level => PROCESS_LEVEL.store(level.as_u8(), Ordering::SeqCst),
104    }
105    if json {
106        PROCESS_JSON.store(true, Ordering::SeqCst);
107    }
108}
109
110/// RAII guard returned by [`scope`]; restores the previous thread values.
111pub struct VerbosityScope(Option<Level>, Option<bool>);
112
113impl Drop for VerbosityScope {
114    fn drop(&mut self) {
115        THREAD_LEVEL.with(|c| c.set(self.0));
116        THREAD_JSON.with(|c| c.set(self.1));
117    }
118}
119
120/// Override the level for the current thread until the guard drops.
121#[must_use]
122pub fn scope(level: Level, json: bool) -> VerbosityScope {
123    let prev_level = THREAD_LEVEL.with(|c| c.replace(Some(level)));
124    let prev_json = THREAD_JSON.with(|c| c.replace(Some(json)));
125    VerbosityScope(prev_level, prev_json)
126}
127
128/// The level in effect for this call.
129#[must_use]
130pub fn level() -> Level {
131    if let Some(l) = THREAD_LEVEL.with(Cell::get) {
132        return l;
133    }
134    Level::from_u8(PROCESS_LEVEL.load(Ordering::SeqCst))
135}
136
137/// True iff `--json` is in effect for this call.
138#[must_use]
139pub fn json_enabled() -> bool {
140    if let Some(j) = THREAD_JSON.with(Cell::get) {
141        return j;
142    }
143    PROCESS_JSON.load(Ordering::SeqCst)
144}
145
146/// True iff `--quiet` was given.
147#[must_use]
148pub fn is_quiet() -> bool {
149    level() == Level::Quiet
150}
151
152/// True iff `--verbose` was given (and `--quiet` was not).
153#[must_use]
154pub fn is_verbose() -> bool {
155    level() == Level::Verbose
156}
157
158/// The single decision the shadowed `println!`/`print!` consult.
159///
160/// Quiet suppresses ordinary stdout, except when `--json` is in effect —
161/// the JSON document is the payload, not chatter.
162#[must_use]
163pub fn stdout_suppressed() -> bool {
164    !json_enabled() && is_quiet()
165}
166
167/// The `--verbose` preamble `execute_command` prints before dispatching.
168///
169/// `--verbose` was the other half of #2401: byte-inert on 13 of 16 sampled
170/// commands, because only `check`, `oracle` and `trace` ever received it as
171/// a parameter. Rather than invent per-command chatter for 104 commands,
172/// this reports what the *dispatcher* actually resolved and decided — facts
173/// the run already computed and then threw away:
174///
175/// * which model paths `extract_model_paths` pulled out of the parsed
176///   command, and their sizes on disk;
177/// * whether the PMAT-237 contract gate ran over them, was disabled with
178///   `--skip-contract`, or did not apply because the command is one of the
179///   diagnostic ones the gate deliberately exempts. That last line also
180///   explains the audit's separate observation that `--skip-contract` has
181///   "zero effect" on `inspect`/`validate`/`tensors`: there is nothing for
182///   it to skip there, and now the CLI says so instead of staying mute.
183/// * whether `--offline` is latched.
184///
185/// Pure so it can be asserted directly; the caller prints the lines.
186#[must_use]
187pub fn preamble_lines(
188    version: &str,
189    offline: bool,
190    skip_contract: bool,
191    paths: &[std::path::PathBuf],
192) -> Vec<String> {
193    let mut out = vec![format!("verbose: apr {version}")];
194    out.push(format!(
195        "verbose: offline = {}",
196        if offline { "on" } else { "off" }
197    ));
198    if skip_contract {
199        out.push("verbose: contract gate = skipped (--skip-contract)".to_string());
200    } else if paths.is_empty() {
201        out.push(
202            "verbose: contract gate = not applicable (no gated model path for this command)"
203                .to_string(),
204        );
205    } else {
206        out.push(format!(
207            "verbose: contract gate = enforced over {} path(s)",
208            paths.len()
209        ));
210    }
211    for p in paths {
212        let size = std::fs::metadata(p).map_or_else(
213            |_| "unreadable".to_string(),
214            |m| format!("{} bytes", m.len()),
215        );
216        out.push(format!("verbose: model = {} ({size})", p.display()));
217    }
218    out
219}
220
221/// Crate-wide shadow of `std::println!` that honours `--quiet`.
222///
223/// Declared with `#[macro_use] mod verbosity;` before `mod commands;` in
224/// `lib.rs`, so every `println!` in the ~9 000 call sites below that point
225/// resolves here instead of to the standard-library prelude. That is the
226/// whole point: `--quiet` cannot be forgotten by a command author, because
227/// there is nothing for a command author to remember.
228macro_rules! println {
229    () => {
230        if !$crate::verbosity::stdout_suppressed() { ::std::println!() }
231    };
232    ($($arg:tt)*) => {
233        if !$crate::verbosity::stdout_suppressed() { ::std::println!($($arg)*) }
234    };
235}
236
237/// Crate-wide shadow of `std::print!` that honours `--quiet`.
238macro_rules! print {
239    ($($arg:tt)*) => {
240        if !$crate::verbosity::stdout_suppressed() { ::std::print!($($arg)*) }
241    };
242}
243
244/// Print only under `--verbose`.
245macro_rules! vprintln {
246    ($($arg:tt)*) => {
247        if $crate::verbosity::is_verbose() { ::std::println!($($arg)*) }
248    };
249}
250
251/// Print regardless of `--quiet` — the opt-out for commands that implement
252/// their own quiet semantics (`apr list --quiet`, `apr lint --quiet`).
253macro_rules! emitln {
254    () => { ::std::println!() };
255    ($($arg:tt)*) => { ::std::println!($($arg)*) };
256}
257
258/// `print!` counterpart of [`emitln!`].
259macro_rules! emit {
260    ($($arg:tt)*) => { ::std::print!($($arg)*) };
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn quiet_wins_over_verbose() {
269        assert_eq!(resolve(true, true), Level::Quiet);
270        assert_eq!(resolve(true, false), Level::Quiet);
271        assert_eq!(resolve(false, true), Level::Verbose);
272        assert_eq!(resolve(false, false), Level::Normal);
273    }
274
275    #[test]
276    fn default_level_prints() {
277        let _s = scope(Level::Normal, false);
278        assert!(!stdout_suppressed());
279        assert!(!is_quiet());
280        assert!(!is_verbose());
281    }
282
283    #[test]
284    fn quiet_suppresses_stdout() {
285        let _s = scope(Level::Quiet, false);
286        assert!(stdout_suppressed(), "--quiet must suppress ordinary stdout");
287    }
288
289    #[test]
290    fn json_survives_quiet() {
291        let _s = scope(Level::Quiet, true);
292        assert!(
293            !stdout_suppressed(),
294            "--json --quiet must still emit the JSON document"
295        );
296    }
297
298    #[test]
299    fn verbose_does_not_suppress() {
300        let _s = scope(Level::Verbose, false);
301        assert!(!stdout_suppressed());
302        assert!(is_verbose());
303    }
304
305    #[test]
306    fn scope_restores_previous_level() {
307        let baseline = level();
308        {
309            let _s = scope(Level::Quiet, false);
310            assert_eq!(level(), Level::Quiet);
311        }
312        assert_eq!(level(), baseline, "scope must restore on drop");
313    }
314
315    #[test]
316    fn preamble_reports_the_gate_decision_not_a_fixed_string() {
317        let none: Vec<std::path::PathBuf> = vec![];
318        let skipped = preamble_lines("9.9.9", false, true, &none);
319        let inapplicable = preamble_lines("9.9.9", false, false, &none);
320        let enforced = preamble_lines("9.9.9", true, false, &[std::path::PathBuf::from("/x.apr")]);
321
322        assert!(
323            skipped.iter().any(|l| l.contains("--skip-contract")),
324            "--skip-contract must be visible under --verbose, got {skipped:?}"
325        );
326        assert!(
327            inapplicable.iter().any(|l| l.contains("not applicable")),
328            "a command the gate exempts must say so rather than stay mute, got {inapplicable:?}"
329        );
330        assert!(
331            enforced.iter().any(|l| l.contains("enforced over 1 path")),
332            "an enforced gate must report its paths, got {enforced:?}"
333        );
334        assert!(
335            enforced.iter().any(|l| l.contains("/x.apr")),
336            "the resolved model path must be reported, got {enforced:?}"
337        );
338        assert!(
339            enforced.iter().any(|l| l.contains("offline = on")),
340            "--offline must be visible under --verbose, got {enforced:?}"
341        );
342        assert_ne!(
343            skipped, inapplicable,
344            "the three gate outcomes must be distinguishable"
345        );
346    }
347
348    // ---------------------------------------------------------------
349    // Behavioural falsifiers for #2401.
350    //
351    // The unit tests above only prove the *decision function*. They cannot
352    // see the thing that was actually broken in v0.63.0: `execute_command`
353    // never recorded the flags, so the ~9 000 `println!` call sites below
354    // `mod commands;` printed regardless. Proving that needs a real command
355    // run end to end with real stdout.
356    //
357    // The level is a process-wide latch that deliberately cannot be un-set
358    // (an in-process test must not be able to un-quiet a run the user asked
359    // to be quiet), so each mode runs in its own child process — the same
360    // pattern `commands::offline` uses for the same reason.
361    //
362    // `gbnf-lint` is the audit's own repro from finding 1 and needs nothing
363    // but a small JSON file, so the falsifier is hermetic.
364    // ---------------------------------------------------------------
365
366    const CHILD_ENV: &str = "APR_VERBOSITY_LATCH_CHILD";
367    const BEGIN: &str = "<<<APR-2401-BEGIN>>>";
368    const END: &str = "<<<APR-2401-END>>>";
369    const TEST_PATH: &str =
370        "verbosity::tests::quiet_and_verbose_reach_a_command_that_never_receives_them";
371
372    /// The parent creates this once and hands the SAME path to all three
373    /// children. An earlier draft stamped the child's pid into the name, so
374    /// `normal` and `verbose` differed by the path alone and the
375    /// "--verbose is not a no-op" assertion passed while --verbose was
376    /// disabled — a test that would have locked the defect in. One path for
377    /// every mode makes the comparison a byte comparison, which is the
378    /// methodology the audit used.
379    const OBS_ENV: &str = "APR_VERBOSITY_OBS_FILE";
380
381    fn parent_observation_file() -> std::path::PathBuf {
382        let p = std::env::temp_dir().join(format!("apr-2401-obs-{}.json", std::process::id()));
383        std::fs::write(&p, r#"{"output":"{\"a\":1}","finish_reason":"stop"}"#)
384            .expect("write observation file");
385        p
386    }
387
388    /// Run `apr gbnf-lint` in this child with whichever flag the parent asked
389    /// for, going through the real `execute_command` so the latch is exercised.
390    fn child_runs_gbnf_lint(mode: &str) {
391        // `Commands` is a very large enum; parsing it on libtest's default
392        // stack overflows, exactly as `commands::offline`'s child does.
393        let mode = mode.to_string();
394        std::thread::Builder::new()
395            .stack_size(16 * 1024 * 1024)
396            .spawn(move || {
397                use clap::Parser;
398
399                let obs = std::env::var(OBS_ENV).expect("parent must hand down the same obs file");
400                let mut argv = vec!["apr", "gbnf-lint", "--observation-file", obs.as_str()];
401                match mode.as_str() {
402                    "quiet" => argv.push("--quiet"),
403                    "verbose" => argv.push("--verbose"),
404                    _ => {}
405                }
406                let cli = crate::Cli::parse_from(argv);
407                // Markers use `::std::println!` so they survive `--quiet` and
408                // give the parent an exact slice of the command's own stdout;
409                // libtest with --nocapture interleaves its own text otherwise.
410                ::std::println!("{BEGIN}");
411                crate::execute_command(&cli)
412                    .expect("gbnf-lint on a well-formed observation must succeed");
413                ::std::println!("{END}");
414            })
415            .expect("spawn")
416            .join()
417            .expect("gbnf-lint verbosity falsifier panicked");
418    }
419
420    fn run_child(mode: &str, obs: &std::path::Path) -> String {
421        let exe = std::env::current_exe().expect("current test binary");
422        let out = std::process::Command::new(exe)
423            .args(["--exact", TEST_PATH, "--nocapture", "--test-threads=1"])
424            .env(CHILD_ENV, mode)
425            .env(OBS_ENV, obs)
426            .output()
427            .expect("re-run this test binary as a child");
428        assert!(
429            out.status.success(),
430            "child ({mode}) failed: {}",
431            String::from_utf8_lossy(&out.stderr)
432        );
433        // libtest's own progress lines share this stdout, so slice out the
434        // marked region rather than trying to recognise them.
435        let all = String::from_utf8_lossy(&out.stdout).into_owned();
436        let start = all
437            .find(BEGIN)
438            .map(|i| i + BEGIN.len())
439            .unwrap_or_else(|| panic!("child ({mode}) never reached the command; got:\n{all}"));
440        let end = all[start..]
441            .find(END)
442            .unwrap_or_else(|| panic!("child ({mode}) never finished the command; got:\n{all}"));
443        all[start..start + end].trim().to_string()
444    }
445
446    #[test]
447    fn quiet_and_verbose_reach_a_command_that_never_receives_them() {
448        if let Ok(mode) = std::env::var(CHILD_ENV) {
449            child_runs_gbnf_lint(&mode);
450            return;
451        }
452
453        let obs = parent_observation_file();
454        let normal = run_child("normal", &obs);
455        let quiet = run_child("quiet", &obs);
456        let verbose = run_child("verbose", &obs);
457        let _ = std::fs::remove_file(&obs);
458
459        assert!(
460            normal.contains("gbnf-lint report"),
461            "control run must print the report, got:\n{normal}"
462        );
463        assert!(
464            quiet.is_empty(),
465            "--quiet must suppress the PASS report as its own help text promises \
466             (`Quiet mode (errors only)`); `apr gbnf-lint -q` still printed:\n{quiet}"
467        );
468        assert_ne!(
469            normal, verbose,
470            "--verbose must not be a byte-for-byte no-op; it was on 13 of 16 \
471             sampled commands in v0.63.0"
472        );
473        assert!(
474            verbose.contains("verbose: contract gate ="),
475            "--verbose must report the dispatcher's gate decision, got:\n{verbose}"
476        );
477    }
478}