doiget_cli/commands/output.rs
1//! Output-mode resolution for the `doiget` CLI (ADR-0017, #144).
2//!
3//! ADR-0017 specifies the precedence ladder
4//! `--mode > --json/--quiet > DOIGET_MODE env > subcommand-implicit > TTY > quiet`.
5//! CONFIG.md §5 additionally pins `doiget serve` to `mcp` mode regardless
6//! of flags — a load-bearing security invariant (the stdout-purity Slice 9
7//! CI job already enforces "MCP mode forbids non-JSON stdout"). The
8//! `forced_implicit` parameter to [`resolve`] expresses that override: when
9//! `Some(_)`, it overrides everything else.
10//!
11//! Resolution is split into a pure function ([`resolve`]) plus a thin
12//! TTY-detection wrapper ([`stdout_is_tty`]) so the ladder is fully
13//! unit-testable without environment manipulation.
14//!
15//! # Per-mode honoring across the CLI surface
16//!
17//! - `Human` — default for TTY stdout. Human-readable text, the
18//! pre-#144 behaviour.
19//! - `Quiet` — informational stdout suppressed across the six
20//! info-emitting commands (audit-log / info / list-recent / search /
21//! config show / config path / provenance migrate) per #203.
22//! Errors (stderr) and exit codes are unaffected. Product-output
23//! commands (bib / csl / graph / *-dry-run / batch JSONL) are NOT
24//! suppressed.
25//! - `Json` — structured JSON bodies for the human-table commands
26//! (#204) plus the ERRORS.md §3 JSON-Lines per-ref shape for batch
27//! (#205). Single-value-per-stdout for the table commands;
28//! line-oriented for batch.
29//! - `Mcp` — JSON-RPC framing on stdout (only reachable via
30//! `doiget serve`; forced by `forced_implicit_for` in `main.rs`).
31//!
32//! # JSON wire conventions (a single-line note)
33//!
34//! Two intentional conventions live side-by-side in the codebase, and
35//! they are different on purpose:
36//!
37//! 1. **Pretty-printed single value** for the table commands' `--mode
38//! json` bodies (info / list-recent / search / config show /
39//! audit-log / provenance migrate). Optimised for `| jq .` and
40//! human-on-a-screen reading.
41//! 2. **Compact JSON-Lines** for `batch --mode json` per the
42//! ERRORS.md §3 CI persona — one record per stdout line, no embedded
43//! newlines, so a consumer can `split('\n').map(json.loads)`.
44//!
45//! Future-maintainer reminder: do NOT unify these by accident — they
46//! serve different consumers.
47
48use clap::ValueEnum;
49
50/// The four output modes from `docs/CONFIG.md` §3 / ADR-0017.
51///
52/// - `Human`: line-oriented text, intended for a terminal.
53/// - `Json`: structured machine output (where the command supports it).
54/// - `Quiet`: no informational stdout; errors still go to stderr.
55/// - `Mcp`: JSON-RPC framing on stdout (forbidden for non-`serve`
56/// commands; entered only via the `Serve` subcommand).
57#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
58#[clap(rename_all = "lower")]
59pub enum OutputMode {
60 /// Line-oriented text output, intended for a terminal.
61 Human,
62 /// Structured JSON output (where the command supports it).
63 Json,
64 /// No informational stdout; errors still go to stderr.
65 Quiet,
66 /// JSON-RPC framing on stdout (only via `doiget serve`).
67 Mcp,
68}
69
70/// Which short-form implication, if any, was given on the command line.
71///
72/// `--mode <m>` carries an explicit [`OutputMode`]; `--json` / `--quiet`
73/// are short-form implications per CONFIG.md §5. Mutual exclusion among
74/// the three flags is enforced at the clap layer via `conflicts_with`,
75/// so [`resolve`]'s caller is guaranteed to pass at most one of the
76/// three.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum FlagInput {
79 /// `--mode <human|json|quiet|mcp>` was given.
80 Explicit(OutputMode),
81 /// `--json` was given (implies `Json`).
82 JsonShort,
83 /// `--quiet`/`-q` was given (implies `Quiet`).
84 QuietShort,
85 /// No mode-related flag was given.
86 None,
87}
88
89/// Resolve the effective [`OutputMode`] per ADR-0017.
90///
91/// Precedence (highest first):
92///
93/// 1. `forced_implicit` — a subcommand-pinned mode that overrides
94/// everything (e.g. `doiget serve` → `Mcp` per CONFIG.md §5; required
95/// for the Slice 9 stdout-purity invariant).
96/// 2. `flag` — `--mode` / `--json` / `--quiet` on the command line.
97/// 3. `env` — `DOIGET_MODE` (parsed by [`parse_env_mode`]; unrecognised
98/// values are ignored, matching CONFIG.md §4's "doiget reads only the
99/// keys it knows about" posture).
100/// 4. `is_tty` — `Human` when stdout is a terminal, otherwise `Quiet`
101/// (CONFIG.md §3.b's "implicit + TTY > quiet (default)").
102///
103/// This function is pure: no env reads, no I/O. The caller plumbs
104/// `env::var("DOIGET_MODE").ok()` and an `is_tty` probe in.
105pub fn resolve(
106 forced_implicit: Option<OutputMode>,
107 flag: FlagInput,
108 env: Option<&str>,
109 is_tty: bool,
110) -> OutputMode {
111 if let Some(m) = forced_implicit {
112 return m;
113 }
114 match flag {
115 FlagInput::Explicit(m) => m,
116 FlagInput::JsonShort => OutputMode::Json,
117 FlagInput::QuietShort => OutputMode::Quiet,
118 FlagInput::None => env.and_then(parse_env_mode).unwrap_or(if is_tty {
119 OutputMode::Human
120 } else {
121 OutputMode::Quiet
122 }),
123 }
124}
125
126/// Parse a `DOIGET_MODE` env-var value. Recognises the four
127/// CONFIG.md §3 modes case-insensitively; returns `None` for empty,
128/// whitespace-only, or unrecognised input (the resolution ladder then
129/// falls through to TTY detection).
130pub fn parse_env_mode(s: &str) -> Option<OutputMode> {
131 match s.trim().to_ascii_lowercase().as_str() {
132 "human" => Some(OutputMode::Human),
133 "json" => Some(OutputMode::Json),
134 "quiet" => Some(OutputMode::Quiet),
135 "mcp" => Some(OutputMode::Mcp),
136 _ => None,
137 }
138}
139
140/// `true` if stdout is attached to a terminal. Wraps the standard
141/// library's [`std::io::IsTerminal`] probe; the trait is in scope only
142/// here so test code can call [`resolve`] with a synthetic `is_tty`
143/// boolean without linking to `IsTerminal`.
144pub fn stdout_is_tty() -> bool {
145 use std::io::IsTerminal;
146 std::io::stdout().is_terminal()
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 // ---- forced_implicit overrides everything ---------------------------
154
155 #[test]
156 fn forced_mcp_wins_over_flag_env_and_tty() {
157 // `doiget serve` MUST be `Mcp` even if the user passes
158 // `--mode quiet` or sets `DOIGET_MODE=human` (CONFIG.md §5).
159 let m = resolve(
160 Some(OutputMode::Mcp),
161 FlagInput::Explicit(OutputMode::Quiet),
162 Some("human"),
163 true,
164 );
165 assert_eq!(m, OutputMode::Mcp);
166 }
167
168 // ---- flag > env > tty ---------------------------------------------
169
170 #[test]
171 fn explicit_flag_wins_over_env_and_tty() {
172 let m = resolve(
173 None,
174 FlagInput::Explicit(OutputMode::Json),
175 Some("human"),
176 true,
177 );
178 assert_eq!(m, OutputMode::Json);
179 }
180
181 #[test]
182 fn json_short_flag_implies_json() {
183 let m = resolve(None, FlagInput::JsonShort, Some("human"), true);
184 assert_eq!(m, OutputMode::Json);
185 }
186
187 #[test]
188 fn quiet_short_flag_implies_quiet() {
189 let m = resolve(None, FlagInput::QuietShort, Some("human"), true);
190 assert_eq!(m, OutputMode::Quiet);
191 }
192
193 #[test]
194 fn env_wins_when_no_flag() {
195 let m = resolve(None, FlagInput::None, Some("json"), true);
196 assert_eq!(m, OutputMode::Json);
197 }
198
199 #[test]
200 fn env_is_case_insensitive_and_trims_whitespace() {
201 assert_eq!(parse_env_mode("HUMAN"), Some(OutputMode::Human));
202 assert_eq!(parse_env_mode(" Json "), Some(OutputMode::Json));
203 assert_eq!(parse_env_mode("MCP"), Some(OutputMode::Mcp));
204 }
205
206 #[test]
207 fn unrecognised_env_falls_through_to_tty() {
208 // `DOIGET_MODE=garbage` is ignored, ladder continues to TTY.
209 let tty = resolve(None, FlagInput::None, Some("garbage"), true);
210 let pipe = resolve(None, FlagInput::None, Some("garbage"), false);
211 assert_eq!(tty, OutputMode::Human);
212 assert_eq!(pipe, OutputMode::Quiet);
213 }
214
215 #[test]
216 fn empty_env_falls_through_to_tty() {
217 // `DOIGET_MODE=""` (empty) is treated as unset (parse_env_mode
218 // returns None on an empty/whitespace string).
219 assert_eq!(parse_env_mode(""), None);
220 assert_eq!(parse_env_mode(" "), None);
221 let tty = resolve(None, FlagInput::None, Some(""), true);
222 assert_eq!(tty, OutputMode::Human);
223 }
224
225 // ---- TTY tail ------------------------------------------------------
226
227 #[test]
228 fn tty_with_no_flag_no_env_yields_human() {
229 let m = resolve(None, FlagInput::None, None, true);
230 assert_eq!(m, OutputMode::Human);
231 }
232
233 #[test]
234 fn no_tty_with_no_flag_no_env_yields_quiet() {
235 let m = resolve(None, FlagInput::None, None, false);
236 assert_eq!(m, OutputMode::Quiet);
237 }
238
239 // ---- env never overrides flag, never beats forced_implicit --------
240
241 #[test]
242 fn env_does_not_override_explicit_flag() {
243 let m = resolve(
244 None,
245 FlagInput::Explicit(OutputMode::Quiet),
246 Some("human"),
247 true,
248 );
249 assert_eq!(m, OutputMode::Quiet);
250 }
251
252 #[test]
253 fn forced_implicit_overrides_env() {
254 let m = resolve(Some(OutputMode::Mcp), FlagInput::None, Some("human"), true);
255 assert_eq!(m, OutputMode::Mcp);
256 }
257}