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