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