1use std::cell::Cell;
39use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Level {
44 Quiet,
46 Normal,
48 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#[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 static THREAD_LEVEL: Cell<Option<Level>> = const { Cell::new(None) };
93 static THREAD_JSON: Cell<Option<bool>> = const { Cell::new(None) };
94}
95
96pub 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
110pub 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#[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#[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#[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#[must_use]
148pub fn is_quiet() -> bool {
149 level() == Level::Quiet
150}
151
152#[must_use]
154pub fn is_verbose() -> bool {
155 level() == Level::Verbose
156}
157
158#[must_use]
163pub fn stdout_suppressed() -> bool {
164 !json_enabled() && is_quiet()
165}
166
167#[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
221macro_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
237macro_rules! print {
239 ($($arg:tt)*) => {
240 if !$crate::verbosity::stdout_suppressed() { ::std::print!($($arg)*) }
241 };
242}
243
244macro_rules! vprintln {
246 ($($arg:tt)*) => {
247 if $crate::verbosity::is_verbose() { ::std::println!($($arg)*) }
248 };
249}
250
251macro_rules! emitln {
254 () => { ::std::println!() };
255 ($($arg:tt)*) => { ::std::println!($($arg)*) };
256}
257
258macro_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 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 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 fn child_runs_gbnf_lint(mode: &str) {
391 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 ::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 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}