Skip to main content

ctl_core/
view.rs

1//! Pretty, colorless, and JSON emission from one typed model.
2
3use std::io::{self, IsTerminal, Write};
4use std::process::ExitCode;
5
6use anstream::AutoStream;
7use serde::Serialize;
8
9use crate::color::ColorMode;
10use crate::document::{Document, Text};
11use crate::format::OutputFormat;
12use crate::model::{Envelope, ErrorBody};
13use crate::render::{
14    DEFAULT_COLUMN_BUFFER_ENVS, DEFAULT_FALLBACK_WIDTH, DEFAULT_MINIMUM_AUTOMATIC_WIDTH, EnvChoice,
15    RenderOptions, env_choice, process_env,
16};
17
18/// Default environment variable for the operator's JSON layout.
19pub const DEFAULT_JSON_LAYOUT_ENV: &str = "CTL_CORE_JSON_LAYOUT";
20/// Default ordered environment lookup for the JSON layout.
21pub const DEFAULT_JSON_LAYOUT_ENVS: &[&str] = &[DEFAULT_JSON_LAYOUT_ENV];
22
23/// A serializable domain model with one semantic human presentation.
24pub trait Present: Serialize {
25    /// Build the human document. JSON serializes `self` directly.
26    fn present(&self) -> Document;
27
28    /// Whether this value represents success or failure.
29    fn message_kind(&self) -> MessageKind {
30        MessageKind::Success
31    }
32
33    /// Process exit code. Typed protocols can distinguish usage errors from
34    /// operational failures without changing their presentation stream.
35    fn exit_code(&self) -> u8 {
36        self.message_kind().default_exit_code()
37    }
38}
39
40/// Human stream and exit semantics for a presented model.
41#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
42pub enum MessageKind {
43    /// Successful command result.
44    #[default]
45    Success,
46    /// Failed command result.
47    Error,
48}
49
50impl MessageKind {
51    /// Default process exit code.
52    #[must_use]
53    pub const fn default_exit_code(self) -> u8 {
54        match self {
55            Self::Success => 0,
56            Self::Error => 1,
57        }
58    }
59}
60
61/// Destination selected by a view.
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum Stream {
64    /// No output, used for quiet successful pretty output.
65    None,
66    /// Standard output.
67    Stdout,
68    /// Standard error.
69    Stderr,
70}
71
72/// Rendered bytes plus their destination and exit semantics.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct Captured {
75    stream: Stream,
76    content: String,
77    exit_code: u8,
78}
79
80impl Captured {
81    /// Destination stream.
82    #[must_use]
83    pub const fn stream(&self) -> Stream {
84        self.stream
85    }
86
87    /// Rendered bytes.
88    #[must_use]
89    pub fn bytes(&self) -> &[u8] {
90        self.content.as_bytes()
91    }
92
93    /// UTF-8 rendered content.
94    #[must_use]
95    pub fn text(&self) -> &str {
96        &self.content
97    }
98
99    /// Process exit code.
100    #[must_use = "return it from main, or the command exits 0"]
101    pub fn exit_code(&self) -> ExitCode {
102        ExitCode::from(self.exit_code)
103    }
104}
105
106/// Line layout of JSON output. No layout adds ANSI.
107#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
108pub enum JsonLayout {
109    /// One line per document.
110    Compact,
111    /// Two-space indentation.
112    #[default]
113    Pretty,
114    /// Two-space indentation when stdout is a terminal, one line otherwise.
115    PrettyOnTerminal,
116}
117
118impl EnvChoice for JsonLayout {
119    const VALUES: &'static [(&'static str, Self)] = &[
120        ("pretty", Self::Pretty),
121        ("compact", Self::Compact),
122        ("pretty-on-terminal", Self::PrettyOnTerminal),
123    ];
124}
125
126impl JsonLayout {
127    fn pretty(self) -> bool {
128        match self {
129            Self::Compact => false,
130            Self::Pretty => true,
131            Self::PrettyOnTerminal => io::stdout().is_terminal(),
132        }
133    }
134}
135
136/// How to present a model. JSON never contains ANSI.
137#[derive(Clone, Copy, Debug, Eq, PartialEq)]
138pub struct View {
139    /// Pretty or JSON.
140    pub format: OutputFormat,
141    /// ANSI policy for pretty output.
142    pub color: ColorMode,
143    /// Suppress successful pretty output.
144    pub quiet: bool,
145    width: Option<u16>,
146    automatic_width_buffer: Option<u16>,
147    automatic_width_buffer_envs: &'static [&'static str],
148    minimum_automatic_width: u16,
149    fallback_width: Option<u16>,
150    json_layout: Option<JsonLayout>,
151    json_layout_envs: &'static [&'static str],
152    styles: RenderOptions,
153}
154
155impl View {
156    /// Build a view that prints successes.
157    #[must_use]
158    pub const fn new(format: OutputFormat, color: ColorMode) -> Self {
159        Self {
160            format,
161            color,
162            quiet: false,
163            width: None,
164            automatic_width_buffer: None,
165            automatic_width_buffer_envs: DEFAULT_COLUMN_BUFFER_ENVS,
166            minimum_automatic_width: DEFAULT_MINIMUM_AUTOMATIC_WIDTH,
167            fallback_width: Some(DEFAULT_FALLBACK_WIDTH),
168            json_layout: None,
169            json_layout_envs: DEFAULT_JSON_LAYOUT_ENVS,
170            styles: RenderOptions::new(color),
171        }
172    }
173
174    /// Lay out JSON output as `layout`. This beats the environment.
175    #[must_use]
176    pub const fn json_layout(mut self, layout: JsonLayout) -> Self {
177        self.json_layout = Some(layout);
178        self
179    }
180
181    /// Replace the ordered environment names read for the JSON layout.
182    /// An empty slice disables lookup.
183    #[must_use]
184    pub const fn json_layout_envs(mut self, names: &'static [&'static str]) -> Self {
185        self.json_layout_envs = names;
186        self
187    }
188
189    /// JSON layout: the owner's choice, then the environment, then the
190    /// default.
191    #[must_use]
192    pub fn layout(self) -> JsonLayout {
193        self.layout_with(process_env)
194    }
195
196    pub(crate) fn layout_with(self, value: impl Fn(&str) -> Option<String>) -> JsonLayout {
197        self.json_layout
198            .or_else(|| env_choice(self.json_layout_envs, value))
199            .unwrap_or_default()
200    }
201
202    /// One line per style or JSON layout variable whose value is not
203    /// accepted. `App` prints them before it runs.
204    #[cfg(feature = "app")]
205    pub(crate) fn env_warnings(self, value: impl Fn(&str) -> Option<String>) -> Vec<String> {
206        let mut warnings = self.styles.style_env_warnings(&value);
207        if self.json_layout.is_none() {
208            warnings.extend(crate::render::env_choice_warnings::<JsonLayout>(
209                self.json_layout_envs,
210                &value,
211            ));
212        }
213        warnings
214    }
215
216    /// Take record style, list style, and row separation from `styles`.
217    /// Its color and width settings are ignored; the view owns those.
218    #[must_use]
219    pub const fn styles(mut self, styles: RenderOptions) -> Self {
220        self.styles = styles;
221        self
222    }
223
224    /// Suppress successful pretty output when `quiet` is set.
225    #[must_use]
226    pub const fn quiet(mut self, quiet: bool) -> Self {
227        self.quiet = quiet;
228        self
229    }
230
231    /// Force an explicit presentation width.
232    #[must_use]
233    pub const fn width(mut self, width: u16) -> Self {
234        self.width = Some(width);
235        self
236    }
237
238    /// Override the buffer subtracted from automatically detected widths.
239    /// Zero disables buffering. Explicit [`Self::width`] remains exact.
240    #[must_use]
241    pub const fn automatic_width_buffer(mut self, columns: u16) -> Self {
242        self.automatic_width_buffer = Some(columns);
243        self
244    }
245
246    /// Replace the ordered environment names used for the automatic buffer.
247    #[must_use]
248    pub const fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
249        self.automatic_width_buffer_envs = names;
250        self
251    }
252
253    /// Set the floor for automatically detected effective widths.
254    #[must_use]
255    pub const fn minimum_automatic_width(mut self, columns: u16) -> Self {
256        self.minimum_automatic_width = columns;
257        self
258    }
259
260    /// Lay out to `width` when no width is detected; `None` disables it.
261    #[must_use]
262    pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
263        self.fallback_width = width;
264        self
265    }
266
267    /// Explicit automatic-width buffer, when set.
268    #[must_use]
269    pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
270        self.automatic_width_buffer
271    }
272
273    /// Ordered environment names used for the automatic-width buffer.
274    #[must_use]
275    pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
276        self.automatic_width_buffer_envs
277    }
278
279    /// Floor applied only to automatically detected widths.
280    #[must_use]
281    pub const fn automatic_width_minimum(self) -> u16 {
282        self.minimum_automatic_width
283    }
284
285    pub(crate) fn render_options(self) -> RenderOptions {
286        let mut options = RenderOptions::new(self.color)
287            .automatic_width_buffer_envs(self.automatic_width_buffer_envs)
288            .minimum_automatic_width(self.minimum_automatic_width)
289            .fallback_width(self.fallback_width)
290            .record_style(self.styles.record())
291            .list_style(self.styles.list())
292            .row_separation(self.styles.separation());
293        if let Some(width) = self.width {
294            options = options.width(width);
295        }
296        if let Some(buffer) = self.automatic_width_buffer {
297            options = options.automatic_width_buffer(buffer);
298        }
299        options
300    }
301
302    /// One JSON document in this view's layout, newline-terminated. Successes
303    /// and error envelopes share it, so both follow the same layout.
304    fn json(self, value: &impl Serialize) -> io::Result<String> {
305        let mut content = if self.layout().pretty() {
306            serde_json::to_string_pretty(value)?
307        } else {
308            serde_json::to_string(value)?
309        };
310        content.push('\n');
311        Ok(content)
312    }
313
314    /// Render without writing. Tests and alternate transports use this path.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error when `value` does not serialize to JSON.
319    pub fn capture(self, value: &impl Present) -> io::Result<Captured> {
320        let kind = value.message_kind();
321        let exit_code = value.exit_code();
322        if self.format.is_json() {
323            return Ok(Captured {
324                stream: Stream::Stdout,
325                content: self.json(value)?,
326                exit_code,
327            });
328        }
329        if self.quiet && kind == MessageKind::Success {
330            return Ok(Captured {
331                stream: Stream::None,
332                content: String::new(),
333                exit_code,
334            });
335        }
336        let content = value.present().render(self.render_options());
337        Ok(Captured {
338            stream: match kind {
339                MessageKind::Success => Stream::Stdout,
340                MessageKind::Error => Stream::Stderr,
341            },
342            content,
343            exit_code,
344        })
345    }
346
347    /// Render and write one typed model. Returns its process exit semantics.
348    ///
349    /// # Errors
350    ///
351    /// Returns an error when `value` does not serialize to JSON, or when
352    /// writing to stdout or stderr fails.
353    pub fn show(self, value: &impl Present) -> io::Result<ExitCode> {
354        let captured = self.capture(value)?;
355        match captured.stream() {
356            Stream::None => {}
357            Stream::Stdout => write_stdout(captured.bytes(), self.color)?,
358            Stream::Stderr => write_stderr(captured.bytes(), self.color)?,
359        }
360        Ok(captured.exit_code())
361    }
362
363    /// Emit `{bin}: {message}` or a JSON error envelope.
364    ///
365    /// # Errors
366    ///
367    /// Returns an error when writing to stdout or stderr fails.
368    pub fn emit_err(self, bin: &str, message: &str) -> io::Result<ExitCode> {
369        if self.format.is_json() {
370            let envelope = Envelope::<()>::err(ErrorBody::new(bin, message));
371            write_stdout(self.json(&envelope)?.as_bytes(), ColorMode::Never)?;
372            return Ok(ExitCode::FAILURE);
373        }
374        let document =
375            Document::new().paragraph(Text::new().error(bin).then(": ").then(message.to_owned()));
376        write_stderr(
377            document.render(self.render_options()).as_bytes(),
378            self.color,
379        )?;
380        Ok(ExitCode::FAILURE)
381    }
382}
383
384/// Write raw bytes to stdout with `color`.
385pub(crate) fn write_stdout(bytes: &[u8], color: ColorMode) -> io::Result<()> {
386    let mut stream = AutoStream::new(io::stdout().lock(), color.choice());
387    stream.write_all(bytes)?;
388    stream.flush()
389}
390
391/// Write raw bytes to stderr with `color`.
392pub(crate) fn write_stderr(bytes: &[u8], color: ColorMode) -> io::Result<()> {
393    let mut stream = AutoStream::new(io::stderr().lock(), color.choice());
394    stream.write_all(bytes)?;
395    stream.flush()
396}
397
398#[cfg(test)]
399mod tests {
400    use super::View;
401    use crate::color::ColorMode;
402    use crate::format::OutputFormat;
403    use crate::model::{Envelope, ErrorBody};
404    use crate::view::JsonLayout;
405
406    #[test]
407    fn error_envelopes_follow_the_json_layout() {
408        let envelope = Envelope::<()>::err(ErrorBody::new("toy", "failed"));
409        let view = View::new(OutputFormat::Json, ColorMode::Never);
410        assert!(view.json(&envelope).unwrap().contains("\n  "));
411        let compact = view
412            .json_layout(JsonLayout::Compact)
413            .json(&envelope)
414            .unwrap();
415        assert_eq!(compact.lines().count(), 1, "{compact}");
416    }
417
418    #[test]
419    fn the_environment_picks_the_json_layout_the_owner_left_open() {
420        let compact = |name: &str| (name == "CTL_CORE_JSON_LAYOUT").then(|| "compact".to_owned());
421        let view = View::new(OutputFormat::Json, ColorMode::Never);
422        assert_eq!(view.layout_with(compact), JsonLayout::Compact);
423        assert_eq!(
424            view.json_layout(JsonLayout::Pretty).layout_with(compact),
425            JsonLayout::Pretty
426        );
427        assert_eq!(
428            view.json_layout_envs(&[]).layout_with(compact),
429            JsonLayout::Pretty
430        );
431    }
432
433    #[cfg(feature = "app")]
434    #[test]
435    fn view_warnings_cover_styles_and_the_json_layout() {
436        let typos = |name: &str| match name {
437            "CTL_CORE_LIST_STYLE" => Some("boxes".to_owned()),
438            "CTL_CORE_JSON_LAYOUT" => Some("tight".to_owned()),
439            _ => None,
440        };
441        let view = View::new(OutputFormat::Pretty, ColorMode::Never);
442        assert_eq!(
443            view.env_warnings(typos),
444            [
445                "CTL_CORE_LIST_STYLE=boxes is ignored; use one of grid, header-rule, plain",
446                "CTL_CORE_JSON_LAYOUT=tight is ignored; use one of pretty, compact, pretty-on-terminal",
447            ]
448        );
449    }
450}