Skip to main content

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/// Stderr sink for the `docs/ERRORS.md` §3 human lines — errors, `= note:`
65/// advisories, progress and skip notes.
66///
67/// stdout is reserved for the requested artifact (ADR-0001), and the
68/// workspace denies `clippy::print_stderr` so that MCP stdio purity cannot
69/// be broken by an accidental `println!`. This function is the one
70/// sanctioned exception, and the `#[allow]` lives here rather than being
71/// re-stated per command module.
72///
73/// Issue #346: ten command modules each carried a byte-identical private
74/// copy of this two-liner with its own `#[allow]`. One copy means the lint
75/// exception is auditable in one place.
76#[allow(clippy::print_stderr)]
77pub fn print_err(args: std::fmt::Arguments<'_>) {
78    eprintln!("{args}");
79}
80
81/// The four output modes from `docs/CONFIG.md` §3 / ADR-0017.
82///
83/// - `Human`: line-oriented text, intended for a terminal.
84/// - `Json`: structured machine output (where the command supports it).
85/// - `Quiet`: no informational stdout; errors still go to stderr.
86/// - `Mcp`: JSON-RPC framing on stdout (forbidden for non-`serve`
87///   commands; entered only via the `Serve` subcommand).
88#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
89#[clap(rename_all = "lower")]
90pub enum OutputMode {
91    /// Line-oriented text output, intended for a terminal.
92    Human,
93    /// Structured JSON output (where the command supports it).
94    Json,
95    /// No informational stdout; errors still go to stderr.
96    Quiet,
97    /// JSON-RPC framing on stdout (only via `doiget serve`).
98    Mcp,
99}
100
101/// Which short-form implication, if any, was given on the command line.
102///
103/// `--mode <m>` carries an explicit [`OutputMode`]; `--json` / `--quiet`
104/// are short-form implications per CONFIG.md §5. Mutual exclusion among
105/// the three flags is enforced at the clap layer via `conflicts_with`,
106/// so [`resolve`]'s caller is guaranteed to pass at most one of the
107/// three.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum FlagInput {
110    /// `--mode <human|json|quiet|mcp>` was given.
111    Explicit(OutputMode),
112    /// `--json` was given (implies `Json`).
113    JsonShort,
114    /// `--quiet`/`-q` was given (implies `Quiet`).
115    QuietShort,
116    /// No mode-related flag was given.
117    None,
118}
119
120/// The resolved output state per ADR-0017 Amendment 1: the
121/// [`OutputMode`] the resolution ladder picked, plus a
122/// `quiet_was_explicit` discriminator that distinguishes the user's
123/// **explicit** request for silence (`--quiet` / `-q` /
124/// `DOIGET_MODE=quiet` / `--mode quiet`) from the resolver's
125/// **implicit** fallback to Quiet when stdout is not a TTY.
126///
127/// Per ADR-0017 Amendment 1 (extended by Amendment 2, #301),
128/// *informational* commands whose stdout is a status report (audit-log
129/// Human, config show/path, provenance migrate, fetch/batch status)
130/// suppress on any Quiet; *artifact* commands whose stdout IS the
131/// product — `bib` / `csl` / `capabilities` plus the read/inspection
132/// commands `info` / `list-recent` / `search` / `link` / `text`, and
133/// `audit-log --verify --mode json` — suppress only on **explicit**
134/// Quiet. See [`is_artifact_command`]. The wire format of
135/// [`OutputMode`] (`DOIGET_MODE` string values, the `modes` array in
136/// `capabilities` JSON, the `--mode` clap values) is **unchanged**;
137/// this struct lives only in-memory.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub struct ResolvedOutput {
140    /// The effective mode for this invocation.
141    pub mode: OutputMode,
142    /// `true` iff the user supplied an explicit Quiet signal
143    /// (`--quiet`, `-q`, `--mode quiet`, `DOIGET_MODE=quiet`).
144    /// `false` for the non-TTY fallback to Quiet, and for any
145    /// non-Quiet mode.
146    pub quiet_was_explicit: bool,
147}
148
149/// `true` if `name` identifies an artifact-producing subcommand whose
150/// stdout output IS the deliverable (per ADR-0017 Amendment 1, extended
151/// by Amendment 2). Artifact commands suppress only on **explicit** Quiet
152/// ([`ResolvedOutput::quiet_was_explicit`] == `true`), never on the
153/// non-TTY implicit fallback.
154///
155/// The set has two cohorts:
156/// - **Export / inventory** (`bib` / `csl` / `capabilities`) — Amendment 1.
157/// - **Read / inspection** (`info` / `list-recent` / `search` / `link` /
158///   `text`) — Amendment 2 (#301). For these the stdout rendering IS the
159///   requested data, not a status report, so a non-TTY caller (agent / pipe
160///   / ssh) must still receive it; silencing it reads as "fetch failed" or
161///   "store empty" when the data is present and correct. `text` in
162///   particular is almost always piped (`doiget text arxiv:… > paper.txt`),
163///   so the implicit-Quiet fallback would otherwise blank the output.
164///
165/// Two commands are omitted on purpose, because a name-level predicate
166/// cannot express them and pretending otherwise would be worse than the
167/// omission:
168///
169/// - `audit-log` is *informational* in Human mode and *artifact* in Json
170///   mode; the command checks the resolved mode rather than its name.
171/// - `config` is artifact-class in `path` and `show` (their stdout is the
172///   requested value) and status-class in `init`; `doctor` writes to
173///   stderr and is unaffected either way. It takes `quiet_was_explicit`
174///   and decides per action (#476).
175///
176/// This is the third time the list has been found incomplete (#219/#220,
177/// #301, #476), and the reason is visible above: it is a hand-maintained
178/// enumeration of a property no type carries. Deriving it from the command
179/// definition would end the pattern. Until then, a new subcommand that
180/// writes a *value* to stdout has to be added here or handled per action,
181/// or it is silent to every non-interactive caller.
182pub fn is_artifact_command(name: &str) -> bool {
183    matches!(
184        name,
185        "bib" | "csl" | "capabilities" | "info" | "list-recent" | "search" | "link" | "text"
186    )
187}
188
189/// Resolve the effective [`OutputMode`] per ADR-0017 and the
190/// `quiet_was_explicit` discriminator per ADR-0017 Amendment 1.
191///
192/// Precedence (highest first):
193///
194/// 1. `forced_implicit` — a subcommand-pinned mode that overrides
195///    everything (e.g. `doiget serve` → `Mcp` per CONFIG.md §5; required
196///    for the Slice 9 stdout-purity invariant). Pinned modes are
197///    **never** counted as explicit user Quiet; they are system policy.
198/// 2. `flag` — `--mode` / `--json` / `--quiet` on the command line.
199///    A `Quiet` mode reached via `--mode quiet`, `--quiet`, or `-q`
200///    is **explicit**.
201/// 3. `env` — `DOIGET_MODE` (parsed by [`parse_env_mode`]; unrecognised
202///    values are ignored, matching CONFIG.md §4's "doiget reads only the
203///    keys it knows about" posture). A `Quiet` mode reached via
204///    `DOIGET_MODE=quiet` is **explicit**.
205/// 4. `is_tty` — `Human` when stdout is a terminal, otherwise `Quiet`
206///    (CONFIG.md §3.b's "implicit + TTY > quiet (default)"). A `Quiet`
207///    mode reached this way is **implicit**.
208///
209/// This function is pure: no env reads, no I/O. The caller plumbs
210/// `env::var("DOIGET_MODE").ok()` and an `is_tty` probe in.
211pub fn resolve(
212    forced_implicit: Option<OutputMode>,
213    flag: FlagInput,
214    env: Option<&str>,
215    is_tty: bool,
216) -> ResolvedOutput {
217    if let Some(m) = forced_implicit {
218        return ResolvedOutput {
219            mode: m,
220            quiet_was_explicit: false,
221        };
222    }
223    let (mode, quiet_was_explicit) = match flag {
224        FlagInput::Explicit(OutputMode::Quiet) => (OutputMode::Quiet, true),
225        FlagInput::Explicit(m) => (m, false),
226        FlagInput::JsonShort => (OutputMode::Json, false),
227        FlagInput::QuietShort => (OutputMode::Quiet, true),
228        FlagInput::None => match env.and_then(parse_env_mode) {
229            Some(OutputMode::Quiet) => (OutputMode::Quiet, true),
230            Some(m) => (m, false),
231            None => {
232                if is_tty {
233                    (OutputMode::Human, false)
234                } else {
235                    (OutputMode::Quiet, false)
236                }
237            }
238        },
239    };
240    ResolvedOutput {
241        mode,
242        quiet_was_explicit,
243    }
244}
245
246/// Parse a `DOIGET_MODE` env-var value. Recognises the four
247/// CONFIG.md §3 modes case-insensitively; returns `None` for empty,
248/// whitespace-only, or unrecognised input (the resolution ladder then
249/// falls through to TTY detection).
250pub fn parse_env_mode(s: &str) -> Option<OutputMode> {
251    match s.trim().to_ascii_lowercase().as_str() {
252        "human" => Some(OutputMode::Human),
253        "json" => Some(OutputMode::Json),
254        "quiet" => Some(OutputMode::Quiet),
255        "mcp" => Some(OutputMode::Mcp),
256        _ => None,
257    }
258}
259
260/// `true` if stdout is attached to a terminal. Wraps the standard
261/// library's [`std::io::IsTerminal`] probe; the trait is in scope only
262/// here so test code can call [`resolve`] with a synthetic `is_tty`
263/// boolean without linking to `IsTerminal`.
264pub fn stdout_is_tty() -> bool {
265    use std::io::IsTerminal;
266    std::io::stdout().is_terminal()
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    // ---- forced_implicit overrides everything ---------------------------
274
275    #[test]
276    fn forced_mcp_wins_over_flag_env_and_tty() {
277        // `doiget serve` MUST be `Mcp` even if the user passes
278        // `--mode quiet` or sets `DOIGET_MODE=human` (CONFIG.md §5).
279        let out = resolve(
280            Some(OutputMode::Mcp),
281            FlagInput::Explicit(OutputMode::Quiet),
282            Some("human"),
283            true,
284        );
285        assert_eq!(out.mode, OutputMode::Mcp);
286        assert!(
287            !out.quiet_was_explicit,
288            "forced_implicit is system policy, never explicit user Quiet"
289        );
290    }
291
292    // ---- flag > env > tty ---------------------------------------------
293
294    #[test]
295    fn explicit_flag_wins_over_env_and_tty() {
296        let out = resolve(
297            None,
298            FlagInput::Explicit(OutputMode::Json),
299            Some("human"),
300            true,
301        );
302        assert_eq!(out.mode, OutputMode::Json);
303        assert!(!out.quiet_was_explicit);
304    }
305
306    #[test]
307    fn json_short_flag_implies_json() {
308        let out = resolve(None, FlagInput::JsonShort, Some("human"), true);
309        assert_eq!(out.mode, OutputMode::Json);
310        assert!(!out.quiet_was_explicit);
311    }
312
313    #[test]
314    fn quiet_short_flag_implies_quiet() {
315        let out = resolve(None, FlagInput::QuietShort, Some("human"), true);
316        assert_eq!(out.mode, OutputMode::Quiet);
317        assert!(
318            out.quiet_was_explicit,
319            "`--quiet`/`-q` is an explicit Quiet signal"
320        );
321    }
322
323    #[test]
324    fn env_wins_when_no_flag() {
325        let out = resolve(None, FlagInput::None, Some("json"), true);
326        assert_eq!(out.mode, OutputMode::Json);
327        assert!(!out.quiet_was_explicit);
328    }
329
330    #[test]
331    fn env_is_case_insensitive_and_trims_whitespace() {
332        assert_eq!(parse_env_mode("HUMAN"), Some(OutputMode::Human));
333        assert_eq!(parse_env_mode("  Json  "), Some(OutputMode::Json));
334        assert_eq!(parse_env_mode("MCP"), Some(OutputMode::Mcp));
335    }
336
337    #[test]
338    fn unrecognised_env_falls_through_to_tty() {
339        // `DOIGET_MODE=garbage` is ignored, ladder continues to TTY.
340        let tty = resolve(None, FlagInput::None, Some("garbage"), true);
341        let pipe = resolve(None, FlagInput::None, Some("garbage"), false);
342        assert_eq!(tty.mode, OutputMode::Human);
343        assert_eq!(pipe.mode, OutputMode::Quiet);
344        assert!(
345            !pipe.quiet_was_explicit,
346            "pipe-default Quiet is implicit, not explicit"
347        );
348    }
349
350    #[test]
351    fn empty_env_falls_through_to_tty() {
352        // `DOIGET_MODE=""` (empty) is treated as unset (parse_env_mode
353        // returns None on an empty/whitespace string).
354        assert_eq!(parse_env_mode(""), None);
355        assert_eq!(parse_env_mode("   "), None);
356        let tty = resolve(None, FlagInput::None, Some(""), true);
357        assert_eq!(tty.mode, OutputMode::Human);
358    }
359
360    // ---- TTY tail ------------------------------------------------------
361
362    #[test]
363    fn tty_with_no_flag_no_env_yields_human() {
364        let out = resolve(None, FlagInput::None, None, true);
365        assert_eq!(out.mode, OutputMode::Human);
366        assert!(!out.quiet_was_explicit);
367    }
368
369    #[test]
370    fn no_tty_with_no_flag_no_env_yields_quiet() {
371        let out = resolve(None, FlagInput::None, None, false);
372        assert_eq!(out.mode, OutputMode::Quiet);
373        assert!(
374            !out.quiet_was_explicit,
375            "non-TTY default to Quiet is implicit (#219 / #220 / ADR-0017 Am1)"
376        );
377    }
378
379    // ---- env never overrides flag, never beats forced_implicit --------
380
381    #[test]
382    fn env_does_not_override_explicit_flag() {
383        let out = resolve(
384            None,
385            FlagInput::Explicit(OutputMode::Quiet),
386            Some("human"),
387            true,
388        );
389        assert_eq!(out.mode, OutputMode::Quiet);
390        assert!(
391            out.quiet_was_explicit,
392            "`--mode quiet` is an explicit Quiet signal"
393        );
394    }
395
396    #[test]
397    fn forced_implicit_overrides_env() {
398        let out = resolve(Some(OutputMode::Mcp), FlagInput::None, Some("human"), true);
399        assert_eq!(out.mode, OutputMode::Mcp);
400    }
401
402    // ---- ADR-0017 Amendment 1: explicit vs implicit Quiet ------------
403
404    #[test]
405    fn doiget_mode_quiet_env_is_explicit_quiet() {
406        // DOIGET_MODE=quiet without any flag is treated as explicit
407        // user intent — artifact commands must respect it.
408        let out = resolve(None, FlagInput::None, Some("quiet"), true);
409        assert_eq!(out.mode, OutputMode::Quiet);
410        assert!(out.quiet_was_explicit);
411    }
412
413    #[test]
414    fn non_tty_quiet_default_is_implicit_quiet() {
415        // The TTY-driven fallback to Quiet is implicit — artifact
416        // commands (capabilities/bib/csl) MUST still emit. This is
417        // the #219/#220 LLM cold-boot fix.
418        let out = resolve(None, FlagInput::None, None, /* is_tty */ false);
419        assert_eq!(out.mode, OutputMode::Quiet);
420        assert!(!out.quiet_was_explicit);
421    }
422
423    // ---- artifact-command classifier (ADR-0017 Am1) ------------------
424
425    #[test]
426    fn artifact_command_classifier_covers_export_and_inspection_commands() {
427        // Amendment 1: export / inventory commands.
428        assert!(is_artifact_command("bib"));
429        assert!(is_artifact_command("csl"));
430        assert!(is_artifact_command("capabilities"));
431        // Amendment 2 (#301): read / inspection commands — their stdout
432        // rendering IS the requested artifact, so the non-TTY implicit
433        // Quiet must NOT erase it.
434        assert!(is_artifact_command("info"));
435        assert!(is_artifact_command("list-recent"));
436        assert!(is_artifact_command("search"));
437        assert!(is_artifact_command("link"));
438        // `text` extracts paper prose to stdout — almost always piped, so
439        // implicit non-TTY Quiet must not blank it (review #318).
440        assert!(is_artifact_command("text"));
441        // audit-log is informational-vs-artifact per resolved mode,
442        // not per name; the classifier does NOT match it.
443        assert!(!is_artifact_command("audit-log"));
444        // Informational status-only commands stay suppressible on any Quiet.
445        assert!(!is_artifact_command("fetch"));
446        assert!(!is_artifact_command("batch"));
447        assert!(!is_artifact_command("config"));
448        assert!(!is_artifact_command(""));
449    }
450}