Skip to main content

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}