1use 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
18pub const DEFAULT_JSON_LAYOUT_ENV: &str = "CTL_CORE_JSON_LAYOUT";
20pub const DEFAULT_JSON_LAYOUT_ENVS: &[&str] = &[DEFAULT_JSON_LAYOUT_ENV];
22
23pub trait Present: Serialize {
25 fn present(&self) -> Document;
27
28 fn message_kind(&self) -> MessageKind {
30 MessageKind::Success
31 }
32
33 fn exit_code(&self) -> u8 {
36 self.message_kind().default_exit_code()
37 }
38}
39
40#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
42pub enum MessageKind {
43 #[default]
45 Success,
46 Error,
48}
49
50impl MessageKind {
51 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum Stream {
64 None,
66 Stdout,
68 Stderr,
70}
71
72#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct Captured {
75 stream: Stream,
76 content: String,
77 exit_code: u8,
78}
79
80impl Captured {
81 #[must_use]
83 pub const fn stream(&self) -> Stream {
84 self.stream
85 }
86
87 #[must_use]
89 pub fn bytes(&self) -> &[u8] {
90 self.content.as_bytes()
91 }
92
93 #[must_use]
95 pub fn text(&self) -> &str {
96 &self.content
97 }
98
99 #[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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
108pub enum JsonLayout {
109 Compact,
111 #[default]
113 Pretty,
114 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
138pub struct View {
139 pub format: OutputFormat,
141 pub color: ColorMode,
143 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 #[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 #[must_use]
176 pub const fn json_layout(mut self, layout: JsonLayout) -> Self {
177 self.json_layout = Some(layout);
178 self
179 }
180
181 #[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 #[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 #[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 #[must_use]
219 pub const fn styles(mut self, styles: RenderOptions) -> Self {
220 self.styles = styles;
221 self
222 }
223
224 #[must_use]
226 pub const fn quiet(mut self, quiet: bool) -> Self {
227 self.quiet = quiet;
228 self
229 }
230
231 #[must_use]
233 pub const fn width(mut self, width: u16) -> Self {
234 self.width = Some(width);
235 self
236 }
237
238 #[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 #[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 #[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 #[must_use]
262 pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
263 self.fallback_width = width;
264 self
265 }
266
267 #[must_use]
269 pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
270 self.automatic_width_buffer
271 }
272
273 #[must_use]
275 pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
276 self.automatic_width_buffer_envs
277 }
278
279 #[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 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 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 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 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
371pub(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
378pub(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}