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    pub fn capture(self, value: &impl Present) -> io::Result<Captured> {
316        let kind = value.message_kind();
317        let exit_code = value.exit_code();
318        if self.format.is_json() {
319            return Ok(Captured {
320                stream: Stream::Stdout,
321                content: self.json(value)?,
322                exit_code,
323            });
324        }
325        if self.quiet && kind == MessageKind::Success {
326            return Ok(Captured {
327                stream: Stream::None,
328                content: String::new(),
329                exit_code,
330            });
331        }
332        let content = value.present().render(self.render_options());
333        Ok(Captured {
334            stream: match kind {
335                MessageKind::Success => Stream::Stdout,
336                MessageKind::Error => Stream::Stderr,
337            },
338            content,
339            exit_code,
340        })
341    }
342
343    /// Render and write one typed model. Returns its process exit semantics.
344    pub fn show(self, value: &impl Present) -> io::Result<ExitCode> {
345        let captured = self.capture(value)?;
346        match captured.stream() {
347            Stream::None => {}
348            Stream::Stdout => write_stdout(captured.bytes(), self.color)?,
349            Stream::Stderr => write_stderr(captured.bytes(), self.color)?,
350        }
351        Ok(captured.exit_code())
352    }
353
354    /// Emit `{bin}: {message}` or a JSON error envelope.
355    pub fn emit_err(self, bin: &str, message: &str) -> io::Result<ExitCode> {
356        if self.format.is_json() {
357            let envelope = Envelope::<()>::err(ErrorBody::new(bin, message));
358            write_stdout(self.json(&envelope)?.as_bytes(), ColorMode::Never)?;
359            return Ok(ExitCode::FAILURE);
360        }
361        let document =
362            Document::new().paragraph(Text::new().error(bin).then(": ").then(message.to_owned()));
363        write_stderr(
364            document.render(self.render_options()).as_bytes(),
365            self.color,
366        )?;
367        Ok(ExitCode::FAILURE)
368    }
369}
370
371/// Write raw bytes to stdout with `color`.
372pub(crate) fn write_stdout(bytes: &[u8], color: ColorMode) -> io::Result<()> {
373    let mut stream = AutoStream::new(io::stdout().lock(), color.choice());
374    stream.write_all(bytes)?;
375    stream.flush()
376}
377
378/// Write raw bytes to stderr with `color`.
379pub(crate) fn write_stderr(bytes: &[u8], color: ColorMode) -> io::Result<()> {
380    let mut stream = AutoStream::new(io::stderr().lock(), color.choice());
381    stream.write_all(bytes)?;
382    stream.flush()
383}
384
385#[cfg(test)]
386mod tests {
387    use super::View;
388    use crate::color::ColorMode;
389    use crate::format::OutputFormat;
390    use crate::model::{Envelope, ErrorBody};
391    use crate::view::JsonLayout;
392
393    #[test]
394    fn error_envelopes_follow_the_json_layout() {
395        let envelope = Envelope::<()>::err(ErrorBody::new("toy", "failed"));
396        let view = View::new(OutputFormat::Json, ColorMode::Never);
397        assert!(view.json(&envelope).unwrap().contains("\n  "));
398        let compact = view
399            .json_layout(JsonLayout::Compact)
400            .json(&envelope)
401            .unwrap();
402        assert_eq!(compact.lines().count(), 1, "{compact}");
403    }
404
405    #[test]
406    fn the_environment_picks_the_json_layout_the_owner_left_open() {
407        let compact = |name: &str| (name == "CTL_CORE_JSON_LAYOUT").then(|| "compact".to_owned());
408        let view = View::new(OutputFormat::Json, ColorMode::Never);
409        assert_eq!(view.layout_with(compact), JsonLayout::Compact);
410        assert_eq!(
411            view.json_layout(JsonLayout::Pretty).layout_with(compact),
412            JsonLayout::Pretty
413        );
414        assert_eq!(
415            view.json_layout_envs(&[]).layout_with(compact),
416            JsonLayout::Pretty
417        );
418    }
419
420    #[cfg(feature = "app")]
421    #[test]
422    fn view_warnings_cover_styles_and_the_json_layout() {
423        let typos = |name: &str| match name {
424            "CTL_CORE_LIST_STYLE" => Some("boxes".to_owned()),
425            "CTL_CORE_JSON_LAYOUT" => Some("tight".to_owned()),
426            _ => None,
427        };
428        let view = View::new(OutputFormat::Pretty, ColorMode::Never);
429        assert_eq!(
430            view.env_warnings(typos),
431            [
432                "CTL_CORE_LIST_STYLE=boxes is ignored; use one of grid, header-rule, plain",
433                "CTL_CORE_JSON_LAYOUT=tight is ignored; use one of pretty, compact, pretty-on-terminal",
434            ]
435        );
436    }
437}