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,
15    RenderOptions,
16};
17
18/// A serializable domain model with one semantic human presentation.
19pub trait Present: Serialize {
20    /// Build the human document. JSON serializes `self` directly.
21    fn present(&self) -> Document;
22
23    /// Whether this value represents success or failure.
24    fn message_kind(&self) -> MessageKind {
25        MessageKind::Success
26    }
27
28    /// Process exit code. Typed protocols can distinguish usage errors from
29    /// operational failures without changing their presentation stream.
30    fn exit_code(&self) -> u8 {
31        self.message_kind().default_exit_code()
32    }
33}
34
35/// Human stream and exit semantics for a presented model.
36#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
37pub enum MessageKind {
38    /// Successful command result.
39    #[default]
40    Success,
41    /// Failed command result.
42    Error,
43}
44
45impl MessageKind {
46    /// Default process exit code.
47    #[must_use]
48    pub const fn default_exit_code(self) -> u8 {
49        match self {
50            Self::Success => 0,
51            Self::Error => 1,
52        }
53    }
54}
55
56/// Destination selected by a view.
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum Stream {
59    /// No output, used for quiet successful pretty output.
60    None,
61    /// Standard output.
62    Stdout,
63    /// Standard error.
64    Stderr,
65}
66
67/// Rendered bytes plus their destination and exit semantics.
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct Captured {
70    stream: Stream,
71    content: String,
72    exit_code: u8,
73}
74
75impl Captured {
76    /// Destination stream.
77    #[must_use]
78    pub const fn stream(&self) -> Stream {
79        self.stream
80    }
81
82    /// Rendered bytes.
83    #[must_use]
84    pub fn bytes(&self) -> &[u8] {
85        self.content.as_bytes()
86    }
87
88    /// UTF-8 rendered content.
89    #[must_use]
90    pub fn text(&self) -> &str {
91        &self.content
92    }
93
94    /// Process exit code.
95    #[must_use = "return it from main, or the command exits 0"]
96    pub fn exit_code(&self) -> ExitCode {
97        ExitCode::from(self.exit_code)
98    }
99}
100
101/// Line layout of JSON output. No layout adds ANSI.
102#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
103pub enum JsonLayout {
104    /// One line per document.
105    Compact,
106    /// Two-space indentation.
107    #[default]
108    Pretty,
109    /// Two-space indentation when stdout is a terminal, one line otherwise.
110    PrettyOnTerminal,
111}
112
113impl JsonLayout {
114    fn pretty(self) -> bool {
115        match self {
116            Self::Compact => false,
117            Self::Pretty => true,
118            Self::PrettyOnTerminal => io::stdout().is_terminal(),
119        }
120    }
121}
122
123/// How to present a model. JSON never contains ANSI.
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub struct View {
126    /// Pretty or JSON.
127    pub format: OutputFormat,
128    /// ANSI policy for pretty output.
129    pub color: ColorMode,
130    /// Suppress successful pretty output.
131    pub quiet: bool,
132    width: Option<u16>,
133    automatic_width_buffer: Option<u16>,
134    automatic_width_buffer_envs: &'static [&'static str],
135    minimum_automatic_width: u16,
136    fallback_width: Option<u16>,
137    json_layout: JsonLayout,
138    styles: RenderOptions,
139}
140
141impl View {
142    /// Build a view that prints successes.
143    #[must_use]
144    pub const fn new(format: OutputFormat, color: ColorMode) -> Self {
145        Self {
146            format,
147            color,
148            quiet: false,
149            width: None,
150            automatic_width_buffer: None,
151            automatic_width_buffer_envs: DEFAULT_COLUMN_BUFFER_ENVS,
152            minimum_automatic_width: DEFAULT_MINIMUM_AUTOMATIC_WIDTH,
153            fallback_width: Some(DEFAULT_FALLBACK_WIDTH),
154            json_layout: JsonLayout::Pretty,
155            styles: RenderOptions::new(color),
156        }
157    }
158
159    /// Lay out JSON output as `layout`.
160    #[must_use]
161    pub const fn json_layout(mut self, layout: JsonLayout) -> Self {
162        self.json_layout = layout;
163        self
164    }
165
166    /// Take record style, list style, and row separation from `styles`.
167    /// Its color and width settings are ignored; the view owns those.
168    #[must_use]
169    pub const fn styles(mut self, styles: RenderOptions) -> Self {
170        self.styles = styles;
171        self
172    }
173
174    /// Suppress successful pretty output when `quiet` is set.
175    #[must_use]
176    pub const fn quiet(mut self, quiet: bool) -> Self {
177        self.quiet = quiet;
178        self
179    }
180
181    /// Force an explicit presentation width.
182    #[must_use]
183    pub const fn width(mut self, width: u16) -> Self {
184        self.width = Some(width);
185        self
186    }
187
188    /// Override the buffer subtracted from automatically detected widths.
189    /// Zero disables buffering. Explicit [`Self::width`] remains exact.
190    #[must_use]
191    pub const fn automatic_width_buffer(mut self, columns: u16) -> Self {
192        self.automatic_width_buffer = Some(columns);
193        self
194    }
195
196    /// Replace the ordered environment names used for the automatic buffer.
197    #[must_use]
198    pub const fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
199        self.automatic_width_buffer_envs = names;
200        self
201    }
202
203    /// Set the floor for automatically detected effective widths.
204    #[must_use]
205    pub const fn minimum_automatic_width(mut self, columns: u16) -> Self {
206        self.minimum_automatic_width = columns;
207        self
208    }
209
210    /// Lay out to `width` when no width is detected; `None` disables it.
211    #[must_use]
212    pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
213        self.fallback_width = width;
214        self
215    }
216
217    /// Explicit automatic-width buffer, when set.
218    #[must_use]
219    pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
220        self.automatic_width_buffer
221    }
222
223    /// Ordered environment names used for the automatic-width buffer.
224    #[must_use]
225    pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
226        self.automatic_width_buffer_envs
227    }
228
229    /// Floor applied only to automatically detected widths.
230    #[must_use]
231    pub const fn automatic_width_minimum(self) -> u16 {
232        self.minimum_automatic_width
233    }
234
235    pub(crate) fn render_options(self) -> RenderOptions {
236        let mut options = RenderOptions::new(self.color)
237            .automatic_width_buffer_envs(self.automatic_width_buffer_envs)
238            .minimum_automatic_width(self.minimum_automatic_width)
239            .fallback_width(self.fallback_width)
240            .record_style(self.styles.record())
241            .list_style(self.styles.list())
242            .row_separation(self.styles.separation());
243        if let Some(width) = self.width {
244            options = options.width(width);
245        }
246        if let Some(buffer) = self.automatic_width_buffer {
247            options = options.automatic_width_buffer(buffer);
248        }
249        options
250    }
251
252    /// One JSON document in this view's layout, newline-terminated. Successes
253    /// and error envelopes share it, so both follow the same layout.
254    fn json(self, value: &impl Serialize) -> io::Result<String> {
255        let mut content = if self.json_layout.pretty() {
256            serde_json::to_string_pretty(value)?
257        } else {
258            serde_json::to_string(value)?
259        };
260        content.push('\n');
261        Ok(content)
262    }
263
264    /// Render without writing. Tests and alternate transports use this path.
265    pub fn capture(self, value: &impl Present) -> io::Result<Captured> {
266        let kind = value.message_kind();
267        let exit_code = value.exit_code();
268        if self.format.is_json() {
269            return Ok(Captured {
270                stream: Stream::Stdout,
271                content: self.json(value)?,
272                exit_code,
273            });
274        }
275        if self.quiet && kind == MessageKind::Success {
276            return Ok(Captured {
277                stream: Stream::None,
278                content: String::new(),
279                exit_code,
280            });
281        }
282        let content = value.present().render(self.render_options());
283        Ok(Captured {
284            stream: match kind {
285                MessageKind::Success => Stream::Stdout,
286                MessageKind::Error => Stream::Stderr,
287            },
288            content,
289            exit_code,
290        })
291    }
292
293    /// Render and write one typed model. Returns its process exit semantics.
294    pub fn show(self, value: &impl Present) -> io::Result<ExitCode> {
295        let captured = self.capture(value)?;
296        match captured.stream() {
297            Stream::None => {}
298            Stream::Stdout => write_stdout(captured.bytes(), self.color)?,
299            Stream::Stderr => write_stderr(captured.bytes(), self.color)?,
300        }
301        Ok(captured.exit_code())
302    }
303
304    /// Emit `{bin}: {message}` or a JSON error envelope.
305    pub fn emit_err(self, bin: &str, message: &str) -> io::Result<ExitCode> {
306        if self.format.is_json() {
307            let envelope = Envelope::<()>::err(ErrorBody::new(bin, message));
308            write_stdout(self.json(&envelope)?.as_bytes(), ColorMode::Never)?;
309            return Ok(ExitCode::FAILURE);
310        }
311        let document =
312            Document::new().paragraph(Text::new().error(bin).then(": ").then(message.to_owned()));
313        write_stderr(
314            document.render(self.render_options()).as_bytes(),
315            self.color,
316        )?;
317        Ok(ExitCode::FAILURE)
318    }
319}
320
321/// Write raw bytes to stdout with `color`.
322pub(crate) fn write_stdout(bytes: &[u8], color: ColorMode) -> io::Result<()> {
323    let mut stream = AutoStream::new(io::stdout().lock(), color.choice());
324    stream.write_all(bytes)?;
325    stream.flush()
326}
327
328/// Write raw bytes to stderr with `color`.
329pub(crate) fn write_stderr(bytes: &[u8], color: ColorMode) -> io::Result<()> {
330    let mut stream = AutoStream::new(io::stderr().lock(), color.choice());
331    stream.write_all(bytes)?;
332    stream.flush()
333}
334
335#[cfg(test)]
336mod tests {
337    use super::View;
338    use crate::color::ColorMode;
339    use crate::format::OutputFormat;
340    use crate::model::{Envelope, ErrorBody};
341    use crate::view::JsonLayout;
342
343    #[test]
344    fn error_envelopes_follow_the_json_layout() {
345        let envelope = Envelope::<()>::err(ErrorBody::new("toy", "failed"));
346        let view = View::new(OutputFormat::Json, ColorMode::Never);
347        assert!(view.json(&envelope).unwrap().contains("\n  "));
348        let compact = view
349            .json_layout(JsonLayout::Compact)
350            .json(&envelope)
351            .unwrap();
352        assert_eq!(compact.lines().count(), 1, "{compact}");
353    }
354}