Skip to main content

ctl_core/
view.rs

1//! Pretty, colorless, and JSON emission from one typed model.
2
3use std::io::{self, 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]
93    pub fn exit_code(&self) -> ExitCode {
94        ExitCode::from(self.exit_code)
95    }
96}
97
98/// How to present a model. JSON never contains ANSI.
99#[derive(Clone, Copy, Debug, Eq, PartialEq)]
100pub struct View {
101    /// Pretty or JSON.
102    pub format: OutputFormat,
103    /// ANSI policy for pretty output.
104    pub color: ColorMode,
105    /// Suppress successful pretty output.
106    pub quiet: bool,
107    width: Option<u16>,
108    automatic_width_buffer: Option<u16>,
109    automatic_width_buffer_envs: &'static [&'static str],
110    minimum_automatic_width: u16,
111}
112
113impl View {
114    /// Build a view that prints successes.
115    #[must_use]
116    pub const fn new(format: OutputFormat, color: ColorMode) -> Self {
117        Self {
118            format,
119            color,
120            quiet: false,
121            width: None,
122            automatic_width_buffer: None,
123            automatic_width_buffer_envs: DEFAULT_COLUMN_BUFFER_ENVS,
124            minimum_automatic_width: DEFAULT_MINIMUM_AUTOMATIC_WIDTH,
125        }
126    }
127
128    /// Suppress successful pretty output when `quiet` is set.
129    #[must_use]
130    pub const fn quiet(mut self, quiet: bool) -> Self {
131        self.quiet = quiet;
132        self
133    }
134
135    /// Force an explicit presentation width.
136    #[must_use]
137    pub const fn width(mut self, width: u16) -> Self {
138        self.width = Some(width);
139        self
140    }
141
142    /// Override the buffer subtracted from automatically detected widths.
143    /// Zero disables buffering. Explicit [`Self::width`] remains exact.
144    #[must_use]
145    pub const fn automatic_width_buffer(mut self, columns: u16) -> Self {
146        self.automatic_width_buffer = Some(columns);
147        self
148    }
149
150    /// Replace the ordered environment names used for the automatic buffer.
151    #[must_use]
152    pub const fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
153        self.automatic_width_buffer_envs = names;
154        self
155    }
156
157    /// Set the floor for automatically detected effective widths.
158    #[must_use]
159    pub const fn minimum_automatic_width(mut self, columns: u16) -> Self {
160        self.minimum_automatic_width = columns;
161        self
162    }
163
164    /// Explicit automatic-width buffer, when set.
165    #[must_use]
166    pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
167        self.automatic_width_buffer
168    }
169
170    /// Ordered environment names used for the automatic-width buffer.
171    #[must_use]
172    pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
173        self.automatic_width_buffer_envs
174    }
175
176    /// Floor applied only to automatically detected widths.
177    #[must_use]
178    pub const fn automatic_width_minimum(self) -> u16 {
179        self.minimum_automatic_width
180    }
181
182    pub(crate) fn render_options(self) -> RenderOptions {
183        let mut options = RenderOptions::new(self.color)
184            .automatic_width_buffer_envs(self.automatic_width_buffer_envs)
185            .minimum_automatic_width(self.minimum_automatic_width);
186        if let Some(width) = self.width {
187            options = options.width(width);
188        }
189        if let Some(buffer) = self.automatic_width_buffer {
190            options = options.automatic_width_buffer(buffer);
191        }
192        options
193    }
194
195    /// Render without writing. Tests and alternate transports use this path.
196    pub fn capture(self, value: &impl Present) -> io::Result<Captured> {
197        let kind = value.message_kind();
198        let exit_code = value.exit_code();
199        if self.format.is_json() {
200            let mut content = serde_json::to_string(value)?;
201            content.push('\n');
202            return Ok(Captured {
203                stream: Stream::Stdout,
204                content,
205                exit_code,
206            });
207        }
208        if self.quiet && kind == MessageKind::Success {
209            return Ok(Captured {
210                stream: Stream::None,
211                content: String::new(),
212                exit_code,
213            });
214        }
215        let content = value.present().render(self.render_options());
216        Ok(Captured {
217            stream: match kind {
218                MessageKind::Success => Stream::Stdout,
219                MessageKind::Error => Stream::Stderr,
220            },
221            content,
222            exit_code,
223        })
224    }
225
226    /// Render and write one typed model. Returns its process exit semantics.
227    pub fn show(self, value: &impl Present) -> io::Result<ExitCode> {
228        let captured = self.capture(value)?;
229        match captured.stream() {
230            Stream::None => {}
231            Stream::Stdout => write_stdout(captured.bytes(), self.color)?,
232            Stream::Stderr => write_stderr(captured.bytes(), self.color)?,
233        }
234        Ok(captured.exit_code())
235    }
236
237    /// Emit `{bin}: {message}` or a JSON error envelope.
238    pub fn emit_err(self, bin: &str, message: &str) -> io::Result<ExitCode> {
239        if self.format.is_json() {
240            emit_json(&Envelope::<()>::err(ErrorBody::new(bin, message)))?;
241            return Ok(ExitCode::FAILURE);
242        }
243        let document =
244            Document::new().paragraph(Text::new().error(bin).then(": ").then(message.to_owned()));
245        write_stderr(
246            document.render(self.render_options()).as_bytes(),
247            self.color,
248        )?;
249        Ok(ExitCode::FAILURE)
250    }
251}
252
253/// Write `value` as one JSON line to stdout. No ANSI.
254pub(crate) fn emit_json<T: Serialize>(value: &T) -> io::Result<()> {
255    let stdout = io::stdout();
256    let mut lock = stdout.lock();
257    serde_json::to_writer(&mut lock, value)?;
258    lock.write_all(b"\n")?;
259    lock.flush()
260}
261
262/// Write raw bytes to stdout with `color`.
263pub(crate) fn write_stdout(bytes: &[u8], color: ColorMode) -> io::Result<()> {
264    let mut stream = AutoStream::new(io::stdout().lock(), color.choice());
265    stream.write_all(bytes)?;
266    stream.flush()
267}
268
269/// Write raw bytes to stderr with `color`.
270pub(crate) fn write_stderr(bytes: &[u8], color: ColorMode) -> io::Result<()> {
271    let mut stream = AutoStream::new(io::stderr().lock(), color.choice());
272    stream.write_all(bytes)?;
273    stream.flush()
274}