#![allow(clippy::disallowed_types)]
use crate::errors::{Classification, ErrorCode, classify, error_details, render_error_chain};
use anstyle::{AnsiColor, Color, Style};
use anyhow::{Error, Result};
use clap::ValueEnum;
use serde::Serialize;
use serde_json::Value;
use std::fmt::{self, Display, Formatter};
use std::io::{self, IsTerminal, Stderr, Stdout, Write};
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum MessageFormat {
Human,
Json,
}
impl MessageFormat {
pub fn is_json(self) -> bool {
matches!(self, MessageFormat::Json)
}
}
#[derive(Clone, Debug, ValueEnum)]
pub enum ColorChoice {
Auto,
Always,
Never,
}
pub struct Shell<Out = Stdout, Err = Stderr> {
stdout: Out,
stderr: Err,
use_color: bool,
message_format: MessageFormat,
}
impl Shell {
pub fn standard(message_format: MessageFormat, color: ColorChoice) -> Self {
let use_color = match color {
ColorChoice::Auto => io::stderr().is_terminal(),
ColorChoice::Always => true,
ColorChoice::Never => false,
};
Self {
stdout: io::stdout(),
stderr: io::stderr(),
use_color,
message_format,
}
}
}
impl<W, W2> Shell<W, W2> {
pub fn message_format(&self) -> MessageFormat {
self.message_format
}
fn style(&self, color: AnsiColor) -> Style {
if self.use_color {
Style::new().bold().fg_color(Some(Color::Ansi(color)))
} else {
Style::new()
}
}
pub fn emit<M: Serialize + Display>(&mut self, message: &M) -> Result<()>
where
W: Write,
W2: Write,
{
match self.message_format {
MessageFormat::Human => {
let text = message.to_string();
if text.is_empty() {
return Ok(());
}
self.human().line(text)
}
MessageFormat::Json => {
writeln!(self.stdout, "{}", serde_json::to_string(message)?)?;
Ok(())
}
}
}
pub fn human(&mut self) -> Human<'_, W, W2>
where
W: Write,
W2: Write,
{
Human(self)
}
}
pub struct Human<'a, W: Write, W2: Write>(&'a mut Shell<W, W2>);
impl<W: Write, W2: Write> Human<'_, W, W2> {
fn line(&mut self, message: impl Display) -> Result<()> {
writeln!(self.0.stdout, "{message}")?;
Ok(())
}
pub fn error(&mut self, error: &Error) -> Result<()> {
let style = self.0.style(AnsiColor::Red);
writeln!(
self.0.stderr,
"{style}error{style:#}: {}",
render_error_chain(error)
)?;
Ok(())
}
}
pub struct Ctx<W, W2> {
shell: Shell<W, W2>,
non_interactive: bool,
}
pub type StdCtx = Ctx<Stdout, Stderr>;
impl<W: Write, W2: Write> Ctx<W, W2> {
pub fn new(shell: Shell<W, W2>, non_interactive: bool) -> Self {
let non_interactive = non_interactive || shell.message_format().is_json();
Self {
shell,
non_interactive,
}
}
pub fn shell(&mut self) -> &mut Shell<W, W2> {
&mut self.shell
}
pub fn is_non_interactive(&self) -> bool {
self.non_interactive
}
}
#[derive(Debug, thiserror::Error)]
#[error(
"{flag_hint} is required in non-interactive mode (set {flag_hint} or run in a TTY without \
--non-interactive / TK_NON_INTERACTIVE=true)"
)]
pub struct MissingRequiredInput {
flag_hint: &'static str,
}
impl MissingRequiredInput {
pub fn new(flag_hint: &'static str) -> Self {
Self { flag_hint }
}
}
#[derive(Serialize)]
pub struct ErrorMessage {
reason: &'static str,
code: ErrorCode,
#[serde(rename = "httpStatus", skip_serializing_if = "Option::is_none")]
http_status: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<Value>,
message: String,
}
impl ErrorMessage {
pub(crate) const RUNTIME_REASON: &'static str = "command_error";
pub(crate) const MISSING_INPUT_REASON: &'static str = "missing_required_input";
pub fn from_error(error: &Error) -> Self {
if error.downcast_ref::<MissingRequiredInput>().is_some() {
return Self {
reason: Self::MISSING_INPUT_REASON,
code: ErrorCode::MissingRequiredInput,
http_status: None,
details: None,
message: render_error_chain(error),
};
}
let Classification { code, http_status } = classify(error);
Self {
reason: Self::RUNTIME_REASON,
code,
http_status,
details: error_details(error),
message: render_error_chain(error),
}
}
pub fn usage_error(message: String) -> Self {
Self {
reason: Self::RUNTIME_REASON,
code: ErrorCode::UsageError,
http_status: None,
details: None,
message,
}
}
}
impl Display for ErrorMessage {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
use serde::Serialize;
type TestShell = Shell<Vec<u8>, Vec<u8>>;
impl<W: Default, W2: Default> Default for Shell<W, W2> {
fn default() -> Self {
Self {
stdout: Default::default(),
stderr: Default::default(),
use_color: false,
message_format: MessageFormat::Human,
}
}
}
impl TestShell {
fn with_json_formatter() -> Self {
Self {
message_format: MessageFormat::Json,
..Default::default()
}
}
fn with_human_formatter() -> Self {
Self {
message_format: MessageFormat::Human,
..Default::default()
}
}
fn into_stdout(self) -> Vec<u8> {
self.stdout
}
}
#[derive(Serialize)]
struct TestMessage {
value: &'static str,
}
impl Display for TestMessage {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "value: {}", self.value)
}
}
#[test]
fn shell_emit_json_writes_one_line() {
let mut shell = TestShell::with_json_formatter();
shell.emit(&TestMessage { value: "ok" }).unwrap();
assert_eq!(
shell.into_stdout(),
concat!(r#"{"value":"ok"}"#, "\n").as_bytes()
);
}
#[test]
fn shell_emit_human_uses_display() {
let mut shell = TestShell::with_human_formatter();
shell.emit(&TestMessage { value: "ok" }).unwrap();
assert_eq!(shell.into_stdout(), "value: ok\n".as_bytes());
}
#[derive(Serialize)]
struct MachineOnlyMessage {
value: &'static str,
}
impl Display for MachineOnlyMessage {
fn fmt(&self, _: &mut Formatter<'_>) -> fmt::Result {
Ok(())
}
}
#[test]
fn shell_emit_human_skips_empty_rendering() {
let mut shell = TestShell::with_human_formatter();
shell.emit(&MachineOnlyMessage { value: "ok" }).unwrap();
let output = String::from_utf8(shell.into_stdout()).unwrap();
assert_eq!(output, "");
}
#[test]
fn shell_emit_json_still_emits_message_with_empty_rendering() {
let mut shell = TestShell::with_json_formatter();
shell.emit(&MachineOnlyMessage { value: "ok" }).unwrap();
let output = String::from_utf8(shell.into_stdout()).unwrap();
assert_eq!(output, concat!(r#"{"value":"ok"}"#, "\n"));
}
fn emit_error_json(error: &Error) -> Value {
let mut shell = TestShell::with_json_formatter();
shell.emit(&ErrorMessage::from_error(error)).unwrap();
let line = String::from_utf8(shell.into_stdout()).unwrap();
assert_eq!(line.matches('\n').count(), 1, "expected one NDJSON line");
serde_json::from_str(line.trim_end()).expect("emitted line should be valid JSON")
}
#[test]
fn missing_required_input_keeps_its_reason_and_code() {
let error =
Error::new(MissingRequiredInput::new("--socket")).context("resolving required inputs");
let json = emit_error_json(&error);
assert_eq!(json["reason"], "missing_required_input");
assert_eq!(json["code"], "missing_required_input");
assert!(json.get("httpStatus").is_none());
assert_eq!(
json["message"],
"resolving required inputs: --socket is required in non-interactive mode (set \
--socket or run in a TTY without --non-interactive / TK_NON_INTERACTIVE=true)"
);
}
#[test]
fn unrecognized_error_falls_back_to_command_error() {
let error = anyhow!("some other failure").context("while doing a thing");
let json = emit_error_json(&error);
assert_eq!(json["reason"], "command_error");
assert_eq!(json["code"], "command_error");
assert!(json.get("httpStatus").is_none());
}
#[test]
fn message_renders_full_anyhow_chain() {
let error = anyhow!("base failure")
.context("middle context")
.context("top context");
let json = emit_error_json(&error);
assert_eq!(json["message"], "top context: middle context: base failure");
}
#[test]
fn ctx_reflects_explicit_non_interactive_flag() {
let ctx = Ctx::new(TestShell::with_human_formatter(), true);
assert!(ctx.is_non_interactive());
}
#[test]
fn ctx_forces_non_interactive_in_json_mode_regardless_of_flag() {
let ctx = Ctx::new(TestShell::with_json_formatter(), false);
assert!(ctx.is_non_interactive());
}
#[test]
fn ctx_is_interactive_when_flag_unset_and_format_is_human() {
let ctx = Ctx::new(TestShell::with_human_formatter(), false);
assert!(!ctx.is_non_interactive());
}
}