turnkey_tk 0.4.1

A CLI for machines to use Turnkey for git, ssh, and credential management
// The output layer renders classified error codes.
#![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-readable text.
    Human,
    /// One JSON object per line.
    Json,
}

impl MessageFormat {
    pub fn is_json(self) -> bool {
        matches!(self, MessageFormat::Json)
    }
}

#[derive(Clone, Debug, ValueEnum)]
pub enum ColorChoice {
    /// Color when stderr is a terminal.
    Auto,
    /// Always color.
    Always,
    /// Never color.
    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());
    }
}