ctl_core/view.rs
1//! Pretty, colourless, and JSON output from one typed model.
2//!
3//! A command result implements [`Present`]: it serializes for JSON and builds
4//! a [`Document`] for people. A [`View`] holds the output format, the colour
5//! mode, and the layout policy, and writes the result with [`View::show`] or
6//! returns it unwritten with [`View::capture`].
7//!
8//! Success goes to stdout and failure to stderr, except JSON, which always
9//! goes to stdout. JSON never contains ANSI whatever the colour mode says.
10//!
11//! ```
12//! use ctl_core::color::ColorMode;
13//! use ctl_core::document::{Document, Fields};
14//! use ctl_core::format::OutputFormat;
15//! use ctl_core::view::{Destination, JsonLayout, Present, View};
16//! use serde::Serialize;
17//!
18//! #[derive(Serialize)]
19//! struct Status {
20//! pending: usize,
21//! }
22//!
23//! impl Present for Status {
24//! fn present(&self) -> Document {
25//! Document::new().fields(Fields::new().row("pending", self.pending.to_string()))
26//! }
27//! }
28//!
29//! let status = Status { pending: 2 };
30//! let json = View::new(OutputFormat::Json, ColorMode::Never)
31//! .json_layout(JsonLayout::Pretty)
32//! .capture(&status)?;
33//! assert_eq!(json.stream(), Destination::Stdout);
34//! assert!(json.text().contains("\"pending\": 2"));
35//!
36//! let pretty = View::new(OutputFormat::Pretty, ColorMode::Never)
37//! .width(40)
38//! .capture(&status)?;
39//! assert!(pretty.text().contains("pending"));
40//! # Ok::<(), std::io::Error>(())
41//! ```
42
43use std::io::{self, IsTerminal, Write};
44use std::process::ExitCode;
45
46use serde::Serialize;
47
48use crate::color::ColorMode;
49use crate::document::{Document, Text};
50use crate::format::OutputFormat;
51use crate::model::{Envelope, ErrorBody};
52use crate::render::{EnvChoice, RenderOptions, env_choice, process_env};
53use crate::theme::Theme;
54
55/// The environment variable that sets the JSON layout when the CLI has not:
56/// `CTL_CORE_JSON_LAYOUT`. Its values are the [`JsonLayout`] names
57/// `pretty`, `compact`, and `pretty-on-terminal`.
58pub const DEFAULT_JSON_LAYOUT_ENV: &str = "CTL_CORE_JSON_LAYOUT";
59/// The ordered list of variables [`View::resolved_json_layout`] reads,
60/// holding only [`DEFAULT_JSON_LAYOUT_ENV`]. [`View::json_layout_envs`]
61/// replaces it.
62pub const DEFAULT_JSON_LAYOUT_ENVS: &[&str] = &[DEFAULT_JSON_LAYOUT_ENV];
63
64/// A command result that serializes for machines and presents itself to people.
65///
66/// JSON output is `self` serialized as is. Pretty and colourless output is the
67/// [`Document`] that [`Self::present`] builds. The two share one value, so they
68/// cannot drift apart.
69pub trait Present: Serialize {
70 /// Build the human document. JSON output serializes `self` and ignores
71 /// this.
72 fn present(&self) -> Document;
73
74 /// Whether the result is a success or a failure. It picks the stream of
75 /// pretty output (stdout or stderr) and the default exit code. The default
76 /// is [`Outcome::Success`].
77 fn outcome(&self) -> Outcome {
78 Outcome::Success
79 }
80
81 /// The process exit code. The default is `0` for a success and `1` for an
82 /// error ([`Outcome::default_exit_code`]).
83 ///
84 /// Override it to give a failure its own code, such as a usage error
85 /// distinct from an operational failure. The stream still follows
86 /// [`Self::outcome`].
87 fn exit_code(&self) -> u8 {
88 self.outcome().default_exit_code()
89 }
90}
91
92/// Whether a [`Present`] result succeeded. It picks the stream pretty output
93/// goes to and the default exit code.
94///
95/// The set is closed, and the enum is not `#[non_exhaustive]`: a command
96/// either succeeded or failed.
97#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
98pub enum Outcome {
99 /// The command succeeded. Pretty output goes to stdout, and the exit code
100 /// is `0`.
101 #[default]
102 Success,
103 /// The command failed. Pretty output goes to stderr, and the exit code is
104 /// `1`.
105 Error,
106}
107
108impl Outcome {
109 /// The exit code for a result of this variant, unless the result
110 /// overrides [`Present::exit_code`]: `0` for a success, `1` for an error.
111 #[must_use]
112 pub const fn default_exit_code(self) -> u8 {
113 match self {
114 Self::Success => 0,
115 Self::Error => 1,
116 }
117 }
118}
119
120/// Where a view sends rendered output.
121///
122/// [`ctl_term::Stream`](crate::term::Stream) names the stream that colour
123/// and width are decided for. A destination can also be no output at all.
124///
125/// The set is closed, and the enum is not `#[non_exhaustive]`: a process has
126/// one standard output and one standard error, and quiet output goes to
127/// neither.
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129pub enum Destination {
130 /// No output, used for quiet successful pretty output.
131 None,
132 /// Standard output.
133 Stdout,
134 /// Standard error.
135 Stderr,
136}
137
138/// A rendered result that has not been written: its text, the stream it
139/// belongs on, and its exit code.
140///
141/// [`View::capture`] returns one. [`View::show`] writes it. Tests and
142/// alternate transports read it directly.
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub struct Captured {
145 stream: Destination,
146 content: String,
147 exit_code: u8,
148}
149
150impl Captured {
151 /// The stream the text belongs on.
152 #[must_use]
153 pub const fn stream(&self) -> Destination {
154 self.stream
155 }
156
157 /// The rendered text as UTF-8 bytes. It is empty when the stream is
158 /// [`Destination::None`].
159 #[must_use]
160 pub fn bytes(&self) -> &[u8] {
161 self.content.as_bytes()
162 }
163
164 /// The rendered text. It ends with a newline unless it is empty.
165 #[must_use]
166 pub fn text(&self) -> &str {
167 &self.content
168 }
169
170 /// The exit code to return from `main`.
171 #[must_use = "return it from main, or the command exits 0"]
172 pub fn exit_code(&self) -> ExitCode {
173 ExitCode::from(self.exit_code)
174 }
175}
176
177/// How JSON output is broken into lines. No layout adds ANSI.
178///
179/// The layout changes only whitespace, so every layout parses to the same
180/// value.
181#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
182#[non_exhaustive]
183pub enum JsonLayout {
184 /// The whole document on one line.
185 Compact,
186 /// One value per line, indented by two spaces.
187 #[default]
188 Pretty,
189 /// [`Self::Pretty`] when stdout is a terminal, and [`Self::Compact`]
190 /// otherwise.
191 PrettyOnTerminal,
192}
193
194impl EnvChoice for JsonLayout {
195 const VALUES: &'static [(&'static str, Self)] = &[
196 ("pretty", Self::Pretty),
197 ("compact", Self::Compact),
198 ("pretty-on-terminal", Self::PrettyOnTerminal),
199 ];
200}
201
202impl JsonLayout {
203 fn pretty(self) -> bool {
204 match self {
205 Self::Compact => false,
206 Self::Pretty => true,
207 Self::PrettyOnTerminal => io::stdout().is_terminal(),
208 }
209 }
210}
211
212/// How to present a [`Present`] result: the format, the colour mode, and the
213/// layout policy. JSON never contains ANSI.
214///
215/// Pretty output decides colour and width for the stream it goes to: a
216/// success for stdout, a failure for stderr. So a failure printed to a
217/// terminal is coloured while a success piped to a file is not.
218/// [`Self::capture`] under [`ColorMode::Auto`] holds no escape sequence when
219/// that stream is not a terminal.
220///
221/// The colour, width, and style settings are one [`RenderOptions`], which
222/// [`Self::render_options`] returns. A setter takes the name of its setting
223/// and returns the view changed, as on `RenderOptions`. [`Self::format`] and
224/// [`Self::is_quiet`] read the format and the quiet setting.
225#[derive(Clone, Copy, Debug, Eq, PartialEq)]
226pub struct View {
227 format: OutputFormat,
228 quiet: bool,
229 options: RenderOptions,
230 json_layout: Option<JsonLayout>,
231 json_layout_envs: &'static [&'static str],
232}
233
234impl View {
235 /// Build a view in `format` and `color` that prints successes. Width,
236 /// buffer, minimum, fallback, styles, and JSON layout take their defaults.
237 #[must_use]
238 pub const fn new(format: OutputFormat, color: ColorMode) -> Self {
239 Self {
240 format,
241 quiet: false,
242 options: RenderOptions::new(color),
243 json_layout: None,
244 json_layout_envs: DEFAULT_JSON_LAYOUT_ENVS,
245 }
246 }
247
248 /// Lay out JSON output as `layout`. This beats the environment.
249 #[must_use]
250 pub const fn json_layout(mut self, layout: JsonLayout) -> Self {
251 self.json_layout = Some(layout);
252 self
253 }
254
255 /// Replace the ordered environment names read for the JSON layout.
256 /// An empty slice disables lookup.
257 #[must_use]
258 pub const fn json_layout_envs(mut self, names: &'static [&'static str]) -> Self {
259 self.json_layout_envs = names;
260 self
261 }
262
263 /// JSON layout: the CLI's choice, then the environment, then the
264 /// default. It reads the process environment.
265 #[must_use]
266 pub fn resolved_json_layout(self) -> JsonLayout {
267 self.json_layout_with(process_env)
268 }
269
270 pub(crate) fn json_layout_with(self, value: impl Fn(&str) -> Option<String>) -> JsonLayout {
271 self.json_layout
272 .or_else(|| env_choice(self.json_layout_envs, value))
273 .unwrap_or_default()
274 }
275
276 /// One line per style or JSON layout variable whose value is not
277 /// accepted. `App` prints them before it runs.
278 #[cfg(feature = "app")]
279 pub(crate) fn env_warnings(self, value: impl Fn(&str) -> Option<String>) -> Vec<String> {
280 let mut warnings = self.options.style_env_warnings(&value);
281 if self.json_layout.is_none() {
282 warnings.extend(crate::render::env_choice_warnings::<JsonLayout>(
283 self.json_layout_envs,
284 &value,
285 ));
286 }
287 warnings
288 }
289
290 /// Take the theme, record style, table frame, row separation, and the
291 /// variables they read from `styles`. Its colour and width settings are
292 /// ignored; the view owns those.
293 #[must_use]
294 pub const fn styles(mut self, styles: RenderOptions) -> Self {
295 self.options = self.options.styles(styles);
296 self
297 }
298
299 /// Draw each role in the style `theme` gives it, in place of
300 /// [`Theme::default`]. Colour is still the colour mode's choice:
301 /// [`ColorMode::Never`] writes no style. A later [`Self::styles`]
302 /// replaces this theme with its own.
303 #[must_use]
304 pub const fn theme(mut self, theme: Theme) -> Self {
305 self.options = self.options.theme(theme);
306 self
307 }
308
309 /// Write colour as `color` says, in place of the mode given to
310 /// [`Self::new`].
311 #[must_use]
312 pub const fn color(mut self, color: ColorMode) -> Self {
313 self.options = self.options.color(color);
314 self
315 }
316
317 /// Suppress a successful pretty result when `quiet` is true. Errors and
318 /// JSON are still written.
319 #[must_use]
320 pub const fn quiet(mut self, quiet: bool) -> Self {
321 self.quiet = quiet;
322 self
323 }
324
325 /// Pretty or JSON output, as [`Self::new`] set it.
326 #[must_use]
327 pub const fn format(self) -> OutputFormat {
328 self.format
329 }
330
331 /// Whether a successful pretty result is suppressed, as [`Self::quiet`]
332 /// set it.
333 #[must_use]
334 pub const fn is_quiet(self) -> bool {
335 self.quiet
336 }
337
338 /// The view in `format`, every other setting kept. The parse-error path
339 /// uses it when `--format` is read before the parser runs.
340 #[cfg(feature = "app")]
341 pub(crate) const fn in_format(mut self, format: OutputFormat) -> Self {
342 self.format = format;
343 self
344 }
345
346 /// Lay out pretty output to exactly `width` terminal cells, skipping
347 /// detection, the buffer, and the minimum.
348 #[must_use]
349 pub const fn width(mut self, width: u16) -> Self {
350 self.options = self.options.width(width);
351 self
352 }
353
354 /// Override the number of cells subtracted from an automatically
355 /// detected width. Zero disables the subtraction. An explicit
356 /// [`Self::width`] stays exact.
357 #[must_use]
358 pub const fn automatic_width_buffer(mut self, cells: u16) -> Self {
359 self.options = self.options.automatic_width_buffer(cells);
360 self
361 }
362
363 /// Replace the ordered environment names that set the buffer. An empty
364 /// slice disables lookup.
365 #[must_use]
366 pub const fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
367 self.options = self.options.automatic_width_buffer_envs(names);
368 self
369 }
370
371 /// Set the floor, in cells, for an automatically detected width.
372 #[must_use]
373 pub const fn minimum_automatic_width(mut self, cells: u16) -> Self {
374 self.options = self.options.minimum_automatic_width(cells);
375 self
376 }
377
378 /// Lay out to `width` cells when no width is detected. `None` turns the
379 /// fallback off, so tables render at their natural width.
380 #[must_use]
381 pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
382 self.options = self.options.fallback_width(width);
383 self
384 }
385
386 /// The colour, width, and style settings pretty output renders with:
387 /// the buffer, its environment names, the minimum, the fallback, and the
388 /// styles.
389 #[must_use]
390 pub const fn render_options(self) -> RenderOptions {
391 self.options
392 }
393
394 /// One JSON document in this view's layout, ending in a newline. A success
395 /// and an error envelope share it, so both follow the same layout.
396 fn json(self, value: &impl Serialize) -> io::Result<String> {
397 let mut content = if self.resolved_json_layout().pretty() {
398 serde_json::to_string_pretty(value)?
399 } else {
400 serde_json::to_string(value)?
401 };
402 content.push('\n');
403 Ok(content)
404 }
405
406 /// `document` in the colour and at the width decided for `stream`.
407 fn pretty(self, stream: ctl_term::Stream, document: &Document) -> String {
408 self.options.render_for(stream, document)
409 }
410
411 /// Render `value` without writing it. Tests and alternate transports use
412 /// this path.
413 ///
414 /// JSON output is `value` serialized in the layout
415 /// [`Self::resolved_json_layout`] returns, for stdout. Pretty output is
416 /// rendered as [`Self::show`] would write it. Colour and width are decided
417 /// for the stream [`Captured::stream`] names, from the colour mode, the
418 /// process environment, and whether that stream is a terminal.
419 ///
420 /// # Errors
421 ///
422 /// Returns an error when `value` does not serialize to JSON.
423 pub fn capture(self, value: &impl Present) -> io::Result<Captured> {
424 let outcome = value.outcome();
425 let exit_code = value.exit_code();
426 if self.format.is_json() {
427 return Ok(Captured {
428 stream: Destination::Stdout,
429 content: self.json(value)?,
430 exit_code,
431 });
432 }
433 if self.quiet && outcome == Outcome::Success {
434 return Ok(Captured {
435 stream: Destination::None,
436 content: String::new(),
437 exit_code,
438 });
439 }
440 let (stream, target) = match outcome {
441 Outcome::Success => (Destination::Stdout, ctl_term::Stream::Stdout),
442 Outcome::Error => (Destination::Stderr, ctl_term::Stream::Stderr),
443 };
444 Ok(Captured {
445 stream,
446 content: self.pretty(target, &value.present()),
447 exit_code,
448 })
449 }
450
451 /// Render `value` and write it to the stream [`Self::capture`] picks.
452 /// Returns the exit code for `main` to return.
453 ///
454 /// # Errors
455 ///
456 /// Returns an error when `value` does not serialize to JSON, or when
457 /// writing to stdout or stderr fails.
458 pub fn show(self, value: &impl Present) -> io::Result<ExitCode> {
459 let captured = self.capture(value)?;
460 match captured.stream() {
461 Destination::None => {}
462 Destination::Stdout => write_stdout(captured.bytes())?,
463 Destination::Stderr => write_stderr(captured.bytes())?,
464 }
465 Ok(captured.exit_code())
466 }
467
468 /// Write a failure that has no [`Present`] model, such as a parse error.
469 ///
470 /// Pretty output is `{bin}: {message}` on stderr. JSON output is an
471 /// [`Envelope`] with an [`ErrorBody`] on stdout, in this view's layout.
472 /// The exit code is [`ExitCode::FAILURE`] in both.
473 ///
474 /// # Errors
475 ///
476 /// Returns an error when writing to stdout or stderr fails.
477 pub fn emit_err(self, bin: &str, message: &str) -> io::Result<ExitCode> {
478 if self.format.is_json() {
479 let envelope = Envelope::<()>::err(ErrorBody::new(bin, message));
480 write_stdout(self.json(&envelope)?.as_bytes())?;
481 return Ok(ExitCode::FAILURE);
482 }
483 let document =
484 Document::new().paragraph(Text::new().error(bin).then(": ").then(message.to_owned()));
485 write_stderr(self.pretty(ctl_term::Stream::Stderr, &document).as_bytes())?;
486 Ok(ExitCode::FAILURE)
487 }
488}
489
490/// Write bytes to stdout as they are. Pretty output already holds exactly
491/// the escapes decided for its stream, and JSON and protocol text hold none.
492pub(crate) fn write_stdout(bytes: &[u8]) -> io::Result<()> {
493 let mut stream = io::stdout().lock();
494 stream.write_all(bytes)?;
495 stream.flush()
496}
497
498/// Write bytes to stderr as they are; see [`write_stdout`].
499pub(crate) fn write_stderr(bytes: &[u8]) -> io::Result<()> {
500 let mut stream = io::stderr().lock();
501 stream.write_all(bytes)?;
502 stream.flush()
503}
504
505#[cfg(test)]
506mod tests {
507 use super::View;
508 use crate::color::ColorMode;
509 use crate::format::OutputFormat;
510 use crate::model::{Envelope, ErrorBody};
511 use crate::view::JsonLayout;
512
513 #[test]
514 fn error_envelopes_follow_the_json_layout() {
515 let envelope = Envelope::<()>::err(ErrorBody::new("toy", "failed"));
516 let view = View::new(OutputFormat::Json, ColorMode::Never);
517 assert!(view.json(&envelope).unwrap().contains("\n "));
518 let compact = view
519 .json_layout(JsonLayout::Compact)
520 .json(&envelope)
521 .unwrap();
522 assert_eq!(compact.lines().count(), 1, "{compact}");
523 }
524
525 #[test]
526 fn the_environment_picks_the_json_layout_the_owner_left_open() {
527 let compact = |name: &str| (name == "CTL_CORE_JSON_LAYOUT").then(|| "compact".to_owned());
528 let view = View::new(OutputFormat::Json, ColorMode::Never);
529 assert_eq!(view.json_layout_with(compact), JsonLayout::Compact);
530 assert_eq!(
531 view.json_layout(JsonLayout::Pretty)
532 .json_layout_with(compact),
533 JsonLayout::Pretty
534 );
535 assert_eq!(
536 view.json_layout_envs(&[]).json_layout_with(compact),
537 JsonLayout::Pretty
538 );
539 }
540
541 #[cfg(feature = "app")]
542 #[test]
543 fn view_warnings_cover_styles_and_the_json_layout() {
544 let typos = |name: &str| match name {
545 "CTL_CORE_LIST_STYLE" => Some("boxes".to_owned()),
546 "CTL_CORE_JSON_LAYOUT" => Some("tight".to_owned()),
547 _ => None,
548 };
549 let view = View::new(OutputFormat::Pretty, ColorMode::Never);
550 assert_eq!(
551 view.env_warnings(typos),
552 [
553 "CTL_CORE_LIST_STYLE=boxes is ignored; use one of grid, header-rule, plain",
554 "CTL_CORE_JSON_LAYOUT=tight is ignored; use one of pretty, compact, pretty-on-terminal",
555 ]
556 );
557 }
558}