use std::io::{self, IsTerminal, Write};
use std::process::ExitCode;
use anstream::AutoStream;
use serde::Serialize;
use crate::color::ColorMode;
use crate::document::{Document, Text};
use crate::format::OutputFormat;
use crate::model::{Envelope, ErrorBody};
use crate::render::{
DEFAULT_COLUMN_BUFFER_ENVS, DEFAULT_FALLBACK_WIDTH, DEFAULT_MINIMUM_AUTOMATIC_WIDTH, EnvChoice,
RenderOptions, env_choice, process_env,
};
pub const DEFAULT_JSON_LAYOUT_ENV: &str = "CTL_CORE_JSON_LAYOUT";
pub const DEFAULT_JSON_LAYOUT_ENVS: &[&str] = &[DEFAULT_JSON_LAYOUT_ENV];
pub trait Present: Serialize {
fn present(&self) -> Document;
fn message_kind(&self) -> MessageKind {
MessageKind::Success
}
fn exit_code(&self) -> u8 {
self.message_kind().default_exit_code()
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum MessageKind {
#[default]
Success,
Error,
}
impl MessageKind {
#[must_use]
pub const fn default_exit_code(self) -> u8 {
match self {
Self::Success => 0,
Self::Error => 1,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Stream {
None,
Stdout,
Stderr,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Captured {
stream: Stream,
content: String,
exit_code: u8,
}
impl Captured {
#[must_use]
pub const fn stream(&self) -> Stream {
self.stream
}
#[must_use]
pub fn bytes(&self) -> &[u8] {
self.content.as_bytes()
}
#[must_use]
pub fn text(&self) -> &str {
&self.content
}
#[must_use = "return it from main, or the command exits 0"]
pub fn exit_code(&self) -> ExitCode {
ExitCode::from(self.exit_code)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum JsonLayout {
Compact,
#[default]
Pretty,
PrettyOnTerminal,
}
impl EnvChoice for JsonLayout {
const VALUES: &'static [(&'static str, Self)] = &[
("pretty", Self::Pretty),
("compact", Self::Compact),
("pretty-on-terminal", Self::PrettyOnTerminal),
];
}
impl JsonLayout {
fn pretty(self) -> bool {
match self {
Self::Compact => false,
Self::Pretty => true,
Self::PrettyOnTerminal => io::stdout().is_terminal(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct View {
pub format: OutputFormat,
pub color: ColorMode,
pub quiet: bool,
width: Option<u16>,
automatic_width_buffer: Option<u16>,
automatic_width_buffer_envs: &'static [&'static str],
minimum_automatic_width: u16,
fallback_width: Option<u16>,
json_layout: Option<JsonLayout>,
json_layout_envs: &'static [&'static str],
styles: RenderOptions,
}
impl View {
#[must_use]
pub const fn new(format: OutputFormat, color: ColorMode) -> Self {
Self {
format,
color,
quiet: false,
width: None,
automatic_width_buffer: None,
automatic_width_buffer_envs: DEFAULT_COLUMN_BUFFER_ENVS,
minimum_automatic_width: DEFAULT_MINIMUM_AUTOMATIC_WIDTH,
fallback_width: Some(DEFAULT_FALLBACK_WIDTH),
json_layout: None,
json_layout_envs: DEFAULT_JSON_LAYOUT_ENVS,
styles: RenderOptions::new(color),
}
}
#[must_use]
pub const fn json_layout(mut self, layout: JsonLayout) -> Self {
self.json_layout = Some(layout);
self
}
#[must_use]
pub const fn json_layout_envs(mut self, names: &'static [&'static str]) -> Self {
self.json_layout_envs = names;
self
}
#[must_use]
pub fn layout(self) -> JsonLayout {
self.layout_with(process_env)
}
pub(crate) fn layout_with(self, value: impl Fn(&str) -> Option<String>) -> JsonLayout {
self.json_layout
.or_else(|| env_choice(self.json_layout_envs, value))
.unwrap_or_default()
}
#[cfg(feature = "app")]
pub(crate) fn env_warnings(self, value: impl Fn(&str) -> Option<String>) -> Vec<String> {
let mut warnings = self.styles.style_env_warnings(&value);
if self.json_layout.is_none() {
warnings.extend(crate::render::env_choice_warnings::<JsonLayout>(
self.json_layout_envs,
&value,
));
}
warnings
}
#[must_use]
pub const fn styles(mut self, styles: RenderOptions) -> Self {
self.styles = styles;
self
}
#[must_use]
pub const fn quiet(mut self, quiet: bool) -> Self {
self.quiet = quiet;
self
}
#[must_use]
pub const fn width(mut self, width: u16) -> Self {
self.width = Some(width);
self
}
#[must_use]
pub const fn automatic_width_buffer(mut self, columns: u16) -> Self {
self.automatic_width_buffer = Some(columns);
self
}
#[must_use]
pub const fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
self.automatic_width_buffer_envs = names;
self
}
#[must_use]
pub const fn minimum_automatic_width(mut self, columns: u16) -> Self {
self.minimum_automatic_width = columns;
self
}
#[must_use]
pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
self.fallback_width = width;
self
}
#[must_use]
pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
self.automatic_width_buffer
}
#[must_use]
pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
self.automatic_width_buffer_envs
}
#[must_use]
pub const fn automatic_width_minimum(self) -> u16 {
self.minimum_automatic_width
}
pub(crate) fn render_options(self) -> RenderOptions {
let mut options = RenderOptions::new(self.color)
.automatic_width_buffer_envs(self.automatic_width_buffer_envs)
.minimum_automatic_width(self.minimum_automatic_width)
.fallback_width(self.fallback_width)
.record_style(self.styles.record())
.list_style(self.styles.list())
.row_separation(self.styles.separation());
if let Some(width) = self.width {
options = options.width(width);
}
if let Some(buffer) = self.automatic_width_buffer {
options = options.automatic_width_buffer(buffer);
}
options
}
fn json(self, value: &impl Serialize) -> io::Result<String> {
let mut content = if self.layout().pretty() {
serde_json::to_string_pretty(value)?
} else {
serde_json::to_string(value)?
};
content.push('\n');
Ok(content)
}
pub fn capture(self, value: &impl Present) -> io::Result<Captured> {
let kind = value.message_kind();
let exit_code = value.exit_code();
if self.format.is_json() {
return Ok(Captured {
stream: Stream::Stdout,
content: self.json(value)?,
exit_code,
});
}
if self.quiet && kind == MessageKind::Success {
return Ok(Captured {
stream: Stream::None,
content: String::new(),
exit_code,
});
}
let content = value.present().render(self.render_options());
Ok(Captured {
stream: match kind {
MessageKind::Success => Stream::Stdout,
MessageKind::Error => Stream::Stderr,
},
content,
exit_code,
})
}
pub fn show(self, value: &impl Present) -> io::Result<ExitCode> {
let captured = self.capture(value)?;
match captured.stream() {
Stream::None => {}
Stream::Stdout => write_stdout(captured.bytes(), self.color)?,
Stream::Stderr => write_stderr(captured.bytes(), self.color)?,
}
Ok(captured.exit_code())
}
pub fn emit_err(self, bin: &str, message: &str) -> io::Result<ExitCode> {
if self.format.is_json() {
let envelope = Envelope::<()>::err(ErrorBody::new(bin, message));
write_stdout(self.json(&envelope)?.as_bytes(), ColorMode::Never)?;
return Ok(ExitCode::FAILURE);
}
let document =
Document::new().paragraph(Text::new().error(bin).then(": ").then(message.to_owned()));
write_stderr(
document.render(self.render_options()).as_bytes(),
self.color,
)?;
Ok(ExitCode::FAILURE)
}
}
pub(crate) fn write_stdout(bytes: &[u8], color: ColorMode) -> io::Result<()> {
let mut stream = AutoStream::new(io::stdout().lock(), color.choice());
stream.write_all(bytes)?;
stream.flush()
}
pub(crate) fn write_stderr(bytes: &[u8], color: ColorMode) -> io::Result<()> {
let mut stream = AutoStream::new(io::stderr().lock(), color.choice());
stream.write_all(bytes)?;
stream.flush()
}
#[cfg(test)]
mod tests {
use super::View;
use crate::color::ColorMode;
use crate::format::OutputFormat;
use crate::model::{Envelope, ErrorBody};
use crate::view::JsonLayout;
#[test]
fn error_envelopes_follow_the_json_layout() {
let envelope = Envelope::<()>::err(ErrorBody::new("toy", "failed"));
let view = View::new(OutputFormat::Json, ColorMode::Never);
assert!(view.json(&envelope).unwrap().contains("\n "));
let compact = view
.json_layout(JsonLayout::Compact)
.json(&envelope)
.unwrap();
assert_eq!(compact.lines().count(), 1, "{compact}");
}
#[test]
fn the_environment_picks_the_json_layout_the_owner_left_open() {
let compact = |name: &str| (name == "CTL_CORE_JSON_LAYOUT").then(|| "compact".to_owned());
let view = View::new(OutputFormat::Json, ColorMode::Never);
assert_eq!(view.layout_with(compact), JsonLayout::Compact);
assert_eq!(
view.json_layout(JsonLayout::Pretty).layout_with(compact),
JsonLayout::Pretty
);
assert_eq!(
view.json_layout_envs(&[]).layout_with(compact),
JsonLayout::Pretty
);
}
#[cfg(feature = "app")]
#[test]
fn view_warnings_cover_styles_and_the_json_layout() {
let typos = |name: &str| match name {
"CTL_CORE_LIST_STYLE" => Some("boxes".to_owned()),
"CTL_CORE_JSON_LAYOUT" => Some("tight".to_owned()),
_ => None,
};
let view = View::new(OutputFormat::Pretty, ColorMode::Never);
assert_eq!(
view.env_warnings(typos),
[
"CTL_CORE_LIST_STYLE=boxes is ignored; use one of grid, header-rule, plain",
"CTL_CORE_JSON_LAYOUT=tight is ignored; use one of pretty, compact, pretty-on-terminal",
]
);
}
}