gglib_core/debug_switches.rs
1//! The `GGLIB_DISABLE_*` environment switches, and which are in effect here.
2//!
3//! Each one turns off a compensation so its effect can be measured — the
4//! deletion-criterion discipline [ADR 0001] describes, reachable at runtime
5//! without a rebuild.
6//!
7//! # Why they need a roster
8//!
9//! Every one of them is read with [`std::env::var`] **in the process that does
10//! the work**, and for gglib that process is the daemon. So
11//!
12//! ```text
13//! GGLIB_DISABLE_AGENTIC_SAMPLING=1 gglib benchmark agentic --model m
14//! ```
15//!
16//! sets the variable on a CLI process which resolves no sampling at all. The
17//! daemon does, and it never saw it. The switch is silently ignored, the run
18//! completes, and the numbers look like an answer.
19//!
20//! That is not a hypothetical: it produced two identical arms of an A/B eval
21//! that were meant to differ, and the only reason it was caught is that the
22//! arms were checked against `/proc/<pid>/environ` before the run rather than
23//! after it. A debugging switch whose failure mode is "quietly changes
24//! nothing" is worse than no switch, because it manufactures confident wrong
25//! conclusions from real work.
26//!
27//! So the daemon reports which of these it actually has in effect, and the CLI
28//! compares that against its own environment before handing a command over.
29//! A mismatch is stated, not swallowed.
30//!
31//! # Why a name list and not typed constants
32//!
33//! The switches live in three crates — `request_pipeline` here,
34//! `canonicalization`/`repair` in `gglib-proxy`, `command`/`cache_ram`/
35//! `kv_cache_type` in `gglib-runtime` — and this crate sits below all of them.
36//! Names are the only thing they share. [`ALL`] is therefore a hand-written
37//! list, which is exactly the shape that rotted in `model_service`'s
38//! `AUTO_TAG_NAMES`, so `all_lists_every_switch_the_tree_reads` greps the
39//! source tree and fails if the two disagree in either direction.
40//!
41//! [ADR 0001]: https://github.com/mmogr/gglib/blob/main/docs/adr/0001-runtime-capability-tiers.md
42
43use std::fmt::Write as _;
44
45/// Every `GGLIB_DISABLE_*` switch the tree reads.
46///
47/// Kept sorted so the reported order is stable across processes — a mismatch
48/// warning that reorders itself is harder to read than one that does not.
49pub const ALL: &[&str] = &[
50 "GGLIB_DISABLE_AGENTIC_SAMPLING",
51 "GGLIB_DISABLE_CACHE_AUTOSIZE",
52 "GGLIB_DISABLE_CACHE_REUSE",
53 "GGLIB_DISABLE_CONTEXT_FIT",
54 "GGLIB_DISABLE_GRAMMAR",
55 "GGLIB_DISABLE_KV_QUANT",
56 "GGLIB_DISABLE_MTP",
57 "GGLIB_DISABLE_PROMPT_CANONICALIZATION",
58 "GGLIB_DISABLE_TOOL_REPAIR",
59];
60
61/// Whether an environment value reads as "on".
62///
63/// The spelling every switch in the tree already accepts, gathered here so a
64/// ninth one cannot quietly accept a different set. That claim used to be
65/// false: five sites had their own copy of this `matches!`, two of them in
66/// this very crate, and this one had no callers outside its own module.
67#[must_use]
68pub fn is_truthy(value: &str) -> bool {
69 matches!(
70 value.trim().to_ascii_lowercase().as_str(),
71 "1" | "true" | "yes" | "on"
72 )
73}
74
75/// Whether the named environment switch is set to a truthy value **now**.
76///
77/// Reads the live environment on every call rather than caching. A switch
78/// consulted once at startup would ignore a `.env` loaded later, and the tests
79/// that set one per case would leak into each other.
80#[must_use]
81pub fn enabled(var: &str) -> bool {
82 std::env::var(var).ok().is_some_and(|v| is_truthy(&v))
83}
84
85/// The switches set truthy **in this process**.
86///
87/// Reads the live environment, so a caller in the CLI gets the CLI's answer
88/// and one in the daemon gets the daemon's. That difference is the whole
89/// point — see the module docs.
90#[must_use]
91pub fn active() -> Vec<&'static str> {
92 ALL.iter().copied().filter(|name| enabled(name)).collect()
93}
94
95/// What the CLI should say when its switches differ from the daemon's.
96///
97/// `None` when they agree. Returned rather than printed so the caller owns the
98/// output stream and this stays testable.
99#[must_use]
100pub fn describe_mismatch(here: &[&str], daemon: &[String]) -> Option<String> {
101 let ignored: Vec<&str> = here
102 .iter()
103 .copied()
104 .filter(|n| !daemon.iter().any(|d| d == n))
105 .collect();
106 let unexpected: Vec<&String> = daemon
107 .iter()
108 .filter(|d| !here.contains(&d.as_str()))
109 .collect();
110
111 if ignored.is_empty() && unexpected.is_empty() {
112 return None;
113 }
114
115 let mut out = String::from("debug switches differ between this command and the daemon\n");
116 if !ignored.is_empty() {
117 let _ = writeln!(
118 out,
119 " set here but NOT in effect: {}\n \
120 The daemon does the work, so these are being ignored.",
121 ignored.join(", ")
122 );
123 }
124 if !unexpected.is_empty() {
125 let names: Vec<&str> = unexpected.iter().map(|s| s.as_str()).collect();
126 let _ = writeln!(
127 out,
128 " in effect in the daemon but not set here: {}",
129 names.join(", ")
130 );
131 }
132 out.push_str(" Restart the daemon to apply them: `gglib daemon stop`, then re-run.");
133 Some(out)
134}
135
136#[cfg(test)]
137#[path = "debug_switches_tests.rs"]
138mod tests;