doiget_cli/commands/output.rs
1//! Output-mode resolution for the `doiget` CLI (ADR-0017, #144;
2//! Amendment 1 = #219/#220).
3//!
4//! ADR-0017 specifies the precedence ladder
5//! `--mode > --json/--quiet > DOIGET_MODE env > subcommand-implicit > TTY > quiet`.
6//! CONFIG.md §5 additionally pins `doiget serve` to `mcp` mode regardless
7//! of flags — a load-bearing security invariant (the stdout-purity Slice 9
8//! CI job already enforces "MCP mode forbids non-JSON stdout"). The
9//! `forced_implicit` parameter to [`resolve`] expresses that override: when
10//! `Some(_)`, it overrides everything else.
11//!
12//! [`resolve`] returns a [`ResolvedOutput`] carrying the [`OutputMode`]
13//! plus a `quiet_was_explicit` discriminator. The distinction is
14//! load-bearing per ADR-0017 Amendment 1: artifact-producing commands
15//! (`bib` / `csl` / `capabilities` / `audit-log --verify --mode json`)
16//! suppress only on **explicit** Quiet (`--quiet` / `-q` /
17//! `DOIGET_MODE=quiet` / `--mode quiet`), not on the non-TTY default.
18//! Informational commands continue to suppress on any Quiet.
19//!
20//! Resolution is split into a pure function ([`resolve`]) plus a thin
21//! TTY-detection wrapper ([`stdout_is_tty`]) so the ladder is fully
22//! unit-testable without environment manipulation.
23//!
24//! # Per-mode honoring across the CLI surface
25//!
26//! - `Human` — default for TTY stdout. Human-readable text, the
27//! pre-#144 behaviour.
28//! - `Quiet` — informational stdout suppressed across the six
29//! info-emitting commands (audit-log / info / list-recent / search /
30//! config show / config path / provenance migrate) per #203.
31//! Errors (stderr) and exit codes are unaffected. Product-output
32//! commands (bib / csl / graph / *-dry-run / batch JSONL) are NOT
33//! suppressed.
34//! - `Json` — structured JSON bodies for the human-table commands
35//! (#204) plus the ERRORS.md §3 JSON-Lines per-ref shape for batch
36//! (#205). Single-value-per-stdout for the table commands;
37//! line-oriented for batch.
38//! - `Mcp` — JSON-RPC framing on stdout (only reachable via
39//! `doiget serve`; forced by `forced_implicit_for` in `main.rs`).
40//!
41//! # JSON wire conventions (a single-line note)
42//!
43//! Two intentional conventions live side-by-side in the codebase, and
44//! they are different on purpose:
45//!
46//! 1. **Pretty-printed single value** for the table commands' `--mode
47//! json` bodies (info / list-recent / search / config show /
48//! audit-log / provenance migrate). Optimised for `| jq .` and
49//! human-on-a-screen reading.
50//! 2. **Compact JSON-Lines** for `batch --mode json` per the
51//! ERRORS.md §3 CI persona — one record per stdout line, no embedded
52//! newlines, so a consumer can `split('\n').map(json.loads)`.
53//!
54//! Future-maintainer reminder: do NOT unify these by accident — they
55//! serve different consumers.
56
57use clap::ValueEnum;
58
59/// The four output modes from `docs/CONFIG.md` §3 / ADR-0017.
60///
61/// - `Human`: line-oriented text, intended for a terminal.
62/// - `Json`: structured machine output (where the command supports it).
63/// - `Quiet`: no informational stdout; errors still go to stderr.
64/// - `Mcp`: JSON-RPC framing on stdout (forbidden for non-`serve`
65/// commands; entered only via the `Serve` subcommand).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
67#[clap(rename_all = "lower")]
68pub enum OutputMode {
69 /// Line-oriented text output, intended for a terminal.
70 Human,
71 /// Structured JSON output (where the command supports it).
72 Json,
73 /// No informational stdout; errors still go to stderr.
74 Quiet,
75 /// JSON-RPC framing on stdout (only via `doiget serve`).
76 Mcp,
77}
78
79/// Which short-form implication, if any, was given on the command line.
80///
81/// `--mode <m>` carries an explicit [`OutputMode`]; `--json` / `--quiet`
82/// are short-form implications per CONFIG.md §5. Mutual exclusion among
83/// the three flags is enforced at the clap layer via `conflicts_with`,
84/// so [`resolve`]'s caller is guaranteed to pass at most one of the
85/// three.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum FlagInput {
88 /// `--mode <human|json|quiet|mcp>` was given.
89 Explicit(OutputMode),
90 /// `--json` was given (implies `Json`).
91 JsonShort,
92 /// `--quiet`/`-q` was given (implies `Quiet`).
93 QuietShort,
94 /// No mode-related flag was given.
95 None,
96}
97
98/// The resolved output state per ADR-0017 Amendment 1: the
99/// [`OutputMode`] the resolution ladder picked, plus a
100/// `quiet_was_explicit` discriminator that distinguishes the user's
101/// **explicit** request for silence (`--quiet` / `-q` /
102/// `DOIGET_MODE=quiet` / `--mode quiet`) from the resolver's
103/// **implicit** fallback to Quiet when stdout is not a TTY.
104///
105/// Per ADR-0017 Amendment 1, *informational* commands (audit-log Human,
106/// list-recent, search, info, config show/path, provenance migrate,
107/// fetch/batch status) suppress on any Quiet; *artifact* commands
108/// (`bib` / `csl` / `capabilities` / `audit-log --verify --mode json`)
109/// suppress only on **explicit** Quiet. The wire format of
110/// [`OutputMode`] (`DOIGET_MODE` string values, the `modes` array in
111/// `capabilities` JSON, the `--mode` clap values) is **unchanged**;
112/// this struct lives only in-memory.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct ResolvedOutput {
115 /// The effective mode for this invocation.
116 pub mode: OutputMode,
117 /// `true` iff the user supplied an explicit Quiet signal
118 /// (`--quiet`, `-q`, `--mode quiet`, `DOIGET_MODE=quiet`).
119 /// `false` for the non-TTY fallback to Quiet, and for any
120 /// non-Quiet mode.
121 pub quiet_was_explicit: bool,
122}
123
124/// `true` if `name` identifies an artifact-producing subcommand whose
125/// stdout output IS the deliverable (per ADR-0017 Amendment 1).
126/// Artifact commands suppress only on **explicit** Quiet
127/// ([`ResolvedOutput::quiet_was_explicit`] == `true`).
128///
129/// `audit-log` is omitted on purpose: it is *informational* in Human
130/// mode and *artifact* in Json mode; the command checks the resolved
131/// mode rather than its name, so this classifier doesn't apply.
132pub fn is_artifact_command(name: &str) -> bool {
133 matches!(name, "bib" | "csl" | "capabilities")
134}
135
136/// Resolve the effective [`OutputMode`] per ADR-0017 and the
137/// `quiet_was_explicit` discriminator per ADR-0017 Amendment 1.
138///
139/// Precedence (highest first):
140///
141/// 1. `forced_implicit` — a subcommand-pinned mode that overrides
142/// everything (e.g. `doiget serve` → `Mcp` per CONFIG.md §5; required
143/// for the Slice 9 stdout-purity invariant). Pinned modes are
144/// **never** counted as explicit user Quiet; they are system policy.
145/// 2. `flag` — `--mode` / `--json` / `--quiet` on the command line.
146/// A `Quiet` mode reached via `--mode quiet`, `--quiet`, or `-q`
147/// is **explicit**.
148/// 3. `env` — `DOIGET_MODE` (parsed by [`parse_env_mode`]; unrecognised
149/// values are ignored, matching CONFIG.md §4's "doiget reads only the
150/// keys it knows about" posture). A `Quiet` mode reached via
151/// `DOIGET_MODE=quiet` is **explicit**.
152/// 4. `is_tty` — `Human` when stdout is a terminal, otherwise `Quiet`
153/// (CONFIG.md §3.b's "implicit + TTY > quiet (default)"). A `Quiet`
154/// mode reached this way is **implicit**.
155///
156/// This function is pure: no env reads, no I/O. The caller plumbs
157/// `env::var("DOIGET_MODE").ok()` and an `is_tty` probe in.
158pub fn resolve(
159 forced_implicit: Option<OutputMode>,
160 flag: FlagInput,
161 env: Option<&str>,
162 is_tty: bool,
163) -> ResolvedOutput {
164 if let Some(m) = forced_implicit {
165 return ResolvedOutput {
166 mode: m,
167 quiet_was_explicit: false,
168 };
169 }
170 let (mode, quiet_was_explicit) = match flag {
171 FlagInput::Explicit(OutputMode::Quiet) => (OutputMode::Quiet, true),
172 FlagInput::Explicit(m) => (m, false),
173 FlagInput::JsonShort => (OutputMode::Json, false),
174 FlagInput::QuietShort => (OutputMode::Quiet, true),
175 FlagInput::None => match env.and_then(parse_env_mode) {
176 Some(OutputMode::Quiet) => (OutputMode::Quiet, true),
177 Some(m) => (m, false),
178 None => {
179 if is_tty {
180 (OutputMode::Human, false)
181 } else {
182 (OutputMode::Quiet, false)
183 }
184 }
185 },
186 };
187 ResolvedOutput {
188 mode,
189 quiet_was_explicit,
190 }
191}
192
193/// Parse a `DOIGET_MODE` env-var value. Recognises the four
194/// CONFIG.md §3 modes case-insensitively; returns `None` for empty,
195/// whitespace-only, or unrecognised input (the resolution ladder then
196/// falls through to TTY detection).
197pub fn parse_env_mode(s: &str) -> Option<OutputMode> {
198 match s.trim().to_ascii_lowercase().as_str() {
199 "human" => Some(OutputMode::Human),
200 "json" => Some(OutputMode::Json),
201 "quiet" => Some(OutputMode::Quiet),
202 "mcp" => Some(OutputMode::Mcp),
203 _ => None,
204 }
205}
206
207/// `true` if stdout is attached to a terminal. Wraps the standard
208/// library's [`std::io::IsTerminal`] probe; the trait is in scope only
209/// here so test code can call [`resolve`] with a synthetic `is_tty`
210/// boolean without linking to `IsTerminal`.
211pub fn stdout_is_tty() -> bool {
212 use std::io::IsTerminal;
213 std::io::stdout().is_terminal()
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 // ---- forced_implicit overrides everything ---------------------------
221
222 #[test]
223 fn forced_mcp_wins_over_flag_env_and_tty() {
224 // `doiget serve` MUST be `Mcp` even if the user passes
225 // `--mode quiet` or sets `DOIGET_MODE=human` (CONFIG.md §5).
226 let out = resolve(
227 Some(OutputMode::Mcp),
228 FlagInput::Explicit(OutputMode::Quiet),
229 Some("human"),
230 true,
231 );
232 assert_eq!(out.mode, OutputMode::Mcp);
233 assert!(
234 !out.quiet_was_explicit,
235 "forced_implicit is system policy, never explicit user Quiet"
236 );
237 }
238
239 // ---- flag > env > tty ---------------------------------------------
240
241 #[test]
242 fn explicit_flag_wins_over_env_and_tty() {
243 let out = resolve(
244 None,
245 FlagInput::Explicit(OutputMode::Json),
246 Some("human"),
247 true,
248 );
249 assert_eq!(out.mode, OutputMode::Json);
250 assert!(!out.quiet_was_explicit);
251 }
252
253 #[test]
254 fn json_short_flag_implies_json() {
255 let out = resolve(None, FlagInput::JsonShort, Some("human"), true);
256 assert_eq!(out.mode, OutputMode::Json);
257 assert!(!out.quiet_was_explicit);
258 }
259
260 #[test]
261 fn quiet_short_flag_implies_quiet() {
262 let out = resolve(None, FlagInput::QuietShort, Some("human"), true);
263 assert_eq!(out.mode, OutputMode::Quiet);
264 assert!(
265 out.quiet_was_explicit,
266 "`--quiet`/`-q` is an explicit Quiet signal"
267 );
268 }
269
270 #[test]
271 fn env_wins_when_no_flag() {
272 let out = resolve(None, FlagInput::None, Some("json"), true);
273 assert_eq!(out.mode, OutputMode::Json);
274 assert!(!out.quiet_was_explicit);
275 }
276
277 #[test]
278 fn env_is_case_insensitive_and_trims_whitespace() {
279 assert_eq!(parse_env_mode("HUMAN"), Some(OutputMode::Human));
280 assert_eq!(parse_env_mode(" Json "), Some(OutputMode::Json));
281 assert_eq!(parse_env_mode("MCP"), Some(OutputMode::Mcp));
282 }
283
284 #[test]
285 fn unrecognised_env_falls_through_to_tty() {
286 // `DOIGET_MODE=garbage` is ignored, ladder continues to TTY.
287 let tty = resolve(None, FlagInput::None, Some("garbage"), true);
288 let pipe = resolve(None, FlagInput::None, Some("garbage"), false);
289 assert_eq!(tty.mode, OutputMode::Human);
290 assert_eq!(pipe.mode, OutputMode::Quiet);
291 assert!(
292 !pipe.quiet_was_explicit,
293 "pipe-default Quiet is implicit, not explicit"
294 );
295 }
296
297 #[test]
298 fn empty_env_falls_through_to_tty() {
299 // `DOIGET_MODE=""` (empty) is treated as unset (parse_env_mode
300 // returns None on an empty/whitespace string).
301 assert_eq!(parse_env_mode(""), None);
302 assert_eq!(parse_env_mode(" "), None);
303 let tty = resolve(None, FlagInput::None, Some(""), true);
304 assert_eq!(tty.mode, OutputMode::Human);
305 }
306
307 // ---- TTY tail ------------------------------------------------------
308
309 #[test]
310 fn tty_with_no_flag_no_env_yields_human() {
311 let out = resolve(None, FlagInput::None, None, true);
312 assert_eq!(out.mode, OutputMode::Human);
313 assert!(!out.quiet_was_explicit);
314 }
315
316 #[test]
317 fn no_tty_with_no_flag_no_env_yields_quiet() {
318 let out = resolve(None, FlagInput::None, None, false);
319 assert_eq!(out.mode, OutputMode::Quiet);
320 assert!(
321 !out.quiet_was_explicit,
322 "non-TTY default to Quiet is implicit (#219 / #220 / ADR-0017 Am1)"
323 );
324 }
325
326 // ---- env never overrides flag, never beats forced_implicit --------
327
328 #[test]
329 fn env_does_not_override_explicit_flag() {
330 let out = resolve(
331 None,
332 FlagInput::Explicit(OutputMode::Quiet),
333 Some("human"),
334 true,
335 );
336 assert_eq!(out.mode, OutputMode::Quiet);
337 assert!(
338 out.quiet_was_explicit,
339 "`--mode quiet` is an explicit Quiet signal"
340 );
341 }
342
343 #[test]
344 fn forced_implicit_overrides_env() {
345 let out = resolve(Some(OutputMode::Mcp), FlagInput::None, Some("human"), true);
346 assert_eq!(out.mode, OutputMode::Mcp);
347 }
348
349 // ---- ADR-0017 Amendment 1: explicit vs implicit Quiet ------------
350
351 #[test]
352 fn doiget_mode_quiet_env_is_explicit_quiet() {
353 // DOIGET_MODE=quiet without any flag is treated as explicit
354 // user intent — artifact commands must respect it.
355 let out = resolve(None, FlagInput::None, Some("quiet"), true);
356 assert_eq!(out.mode, OutputMode::Quiet);
357 assert!(out.quiet_was_explicit);
358 }
359
360 #[test]
361 fn non_tty_quiet_default_is_implicit_quiet() {
362 // The TTY-driven fallback to Quiet is implicit — artifact
363 // commands (capabilities/bib/csl) MUST still emit. This is
364 // the #219/#220 LLM cold-boot fix.
365 let out = resolve(None, FlagInput::None, None, /* is_tty */ false);
366 assert_eq!(out.mode, OutputMode::Quiet);
367 assert!(!out.quiet_was_explicit);
368 }
369
370 // ---- artifact-command classifier (ADR-0017 Am1) ------------------
371
372 #[test]
373 fn artifact_command_classifier_covers_bib_csl_capabilities() {
374 assert!(is_artifact_command("bib"));
375 assert!(is_artifact_command("csl"));
376 assert!(is_artifact_command("capabilities"));
377 // audit-log is informational-vs-artifact per resolved mode,
378 // not per name; the classifier does NOT match it.
379 assert!(!is_artifact_command("audit-log"));
380 assert!(!is_artifact_command("fetch"));
381 assert!(!is_artifact_command("info"));
382 assert!(!is_artifact_command(""));
383 }
384}