use std::io::{self, IsTerminal, Write};
use std::process::ExitCode;
use serde::Serialize;
use crate::color::ColorMode;
use crate::document::{Document, Text};
use crate::format::OutputFormat;
use crate::model::{Envelope, ErrorBody};
use crate::render::{EnvChoice, RenderOptions, env_choice, process_env};
use crate::theme::Theme;
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 outcome(&self) -> Outcome {
Outcome::Success
}
fn exit_code(&self) -> u8 {
self.outcome().default_exit_code()
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Outcome {
#[default]
Success,
Error,
}
impl Outcome {
#[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 Destination {
None,
Stdout,
Stderr,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Captured {
stream: Destination,
content: String,
exit_code: u8,
}
impl Captured {
#[must_use]
pub const fn stream(&self) -> Destination {
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)]
#[non_exhaustive]
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 {
format: OutputFormat,
quiet: bool,
options: RenderOptions,
json_layout: Option<JsonLayout>,
json_layout_envs: &'static [&'static str],
}
impl View {
#[must_use]
pub const fn new(format: OutputFormat, color: ColorMode) -> Self {
Self {
format,
quiet: false,
options: RenderOptions::new(color),
json_layout: None,
json_layout_envs: DEFAULT_JSON_LAYOUT_ENVS,
}
}
#[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 resolved_json_layout(self) -> JsonLayout {
self.json_layout_with(process_env)
}
pub(crate) fn json_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.options.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.options = self.options.styles(styles);
self
}
#[must_use]
pub const fn theme(mut self, theme: Theme) -> Self {
self.options = self.options.theme(theme);
self
}
#[must_use]
pub const fn color(mut self, color: ColorMode) -> Self {
self.options = self.options.color(color);
self
}
#[must_use]
pub const fn quiet(mut self, quiet: bool) -> Self {
self.quiet = quiet;
self
}
#[must_use]
pub const fn format(self) -> OutputFormat {
self.format
}
#[must_use]
pub const fn is_quiet(self) -> bool {
self.quiet
}
#[cfg(feature = "app")]
pub(crate) const fn in_format(mut self, format: OutputFormat) -> Self {
self.format = format;
self
}
#[must_use]
pub const fn width(mut self, width: u16) -> Self {
self.options = self.options.width(width);
self
}
#[must_use]
pub const fn automatic_width_buffer(mut self, cells: u16) -> Self {
self.options = self.options.automatic_width_buffer(cells);
self
}
#[must_use]
pub const fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
self.options = self.options.automatic_width_buffer_envs(names);
self
}
#[must_use]
pub const fn minimum_automatic_width(mut self, cells: u16) -> Self {
self.options = self.options.minimum_automatic_width(cells);
self
}
#[must_use]
pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
self.options = self.options.fallback_width(width);
self
}
#[must_use]
pub const fn render_options(self) -> RenderOptions {
self.options
}
fn json(self, value: &impl Serialize) -> io::Result<String> {
let mut content = if self.resolved_json_layout().pretty() {
serde_json::to_string_pretty(value)?
} else {
serde_json::to_string(value)?
};
content.push('\n');
Ok(content)
}
fn pretty(self, stream: ctl_term::Stream, document: &Document) -> String {
self.options.render_for(stream, document)
}
pub fn capture(self, value: &impl Present) -> io::Result<Captured> {
let outcome = value.outcome();
let exit_code = value.exit_code();
if self.format.is_json() {
return Ok(Captured {
stream: Destination::Stdout,
content: self.json(value)?,
exit_code,
});
}
if self.quiet && outcome == Outcome::Success {
return Ok(Captured {
stream: Destination::None,
content: String::new(),
exit_code,
});
}
let (stream, target) = match outcome {
Outcome::Success => (Destination::Stdout, ctl_term::Stream::Stdout),
Outcome::Error => (Destination::Stderr, ctl_term::Stream::Stderr),
};
Ok(Captured {
stream,
content: self.pretty(target, &value.present()),
exit_code,
})
}
pub fn show(self, value: &impl Present) -> io::Result<ExitCode> {
let captured = self.capture(value)?;
match captured.stream() {
Destination::None => {}
Destination::Stdout => write_stdout(captured.bytes())?,
Destination::Stderr => write_stderr(captured.bytes())?,
}
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())?;
return Ok(ExitCode::FAILURE);
}
let document =
Document::new().paragraph(Text::new().error(bin).then(": ").then(message.to_owned()));
write_stderr(self.pretty(ctl_term::Stream::Stderr, &document).as_bytes())?;
Ok(ExitCode::FAILURE)
}
}
pub(crate) fn write_stdout(bytes: &[u8]) -> io::Result<()> {
let mut stream = io::stdout().lock();
stream.write_all(bytes)?;
stream.flush()
}
pub(crate) fn write_stderr(bytes: &[u8]) -> io::Result<()> {
let mut stream = io::stderr().lock();
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.json_layout_with(compact), JsonLayout::Compact);
assert_eq!(
view.json_layout(JsonLayout::Pretty)
.json_layout_with(compact),
JsonLayout::Pretty
);
assert_eq!(
view.json_layout_envs(&[]).json_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",
]
);
}
}