use std::io::{self, Write};
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
#[clap(rename_all = "lower")]
pub enum OutputFormat {
#[default]
Text,
Json,
}
impl OutputFormat {
pub fn is_json(self) -> bool {
matches!(self, Self::Json)
}
pub fn is_text(self) -> bool {
matches!(self, Self::Text)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Envelope<T: Serialize> {
pub schema_version: String,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub warnings: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<EnvelopeError>,
}
impl<T: Serialize> Envelope<T> {
pub fn success(schema_version: impl Into<String>, data: T) -> Self {
Self {
schema_version: schema_version.into(),
ok: true,
data: Some(data),
warnings: Vec::new(),
error: None,
}
}
pub fn failure(schema_version: impl Into<String>, error: EnvelopeError) -> Self {
Self {
schema_version: schema_version.into(),
ok: false,
data: None,
warnings: Vec::new(),
error: Some(error),
}
}
pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
self.warnings.push(warning.into());
self
}
pub fn with_warnings<I, S>(mut self, warnings: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.warnings.extend(warnings.into_iter().map(|w| w.into()));
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct EnvelopeError {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
impl EnvelopeError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
hint: None,
details: None,
}
}
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
}
pub fn schema_version_for(binary: &str, command: &str, version: u32) -> String {
format!("cli.{binary}.{command}.v{version}")
}
pub mod exit {
pub const SUCCESS: i32 = 0;
pub const RUNTIME: i32 = 1;
pub const USAGE: i32 = 64;
pub const DATA: i32 = 65;
pub const UNAVAILABLE: i32 = 69;
pub const SOFTWARE: i32 = 70;
}
pub fn emit_parse_error(binary: &str, format: OutputFormat, code: &str, message: &str) -> i32 {
emit_parse_error_to(
&mut io::stdout().lock(),
&mut io::stderr().lock(),
binary,
format,
code,
message,
)
}
pub fn emit_parse_error_to<W1: Write, W2: Write>(
stdout: &mut W1,
stderr: &mut W2,
binary: &str,
format: OutputFormat,
code: &str,
message: &str,
) -> i32 {
match format {
OutputFormat::Json => {
let envelope: Envelope<()> = Envelope::failure(
schema_version_for(binary, "error", 1),
EnvelopeError::new(code, message),
);
let serialized =
serde_json::to_string(&envelope).unwrap_or_else(|_| String::from("{\"ok\":false}"));
let _ = writeln!(stdout, "{serialized}");
}
OutputFormat::Text => {
let _ = writeln!(stderr, "error: {message}");
}
}
exit::USAGE
}
#[cfg(test)]
mod tests {
use super::*;
use clap::ValueEnum;
use pretty_assertions::assert_eq;
#[test]
fn output_format_round_trips_through_value_enum() {
let text = OutputFormat::from_str("text", false).expect("text variant");
let json = OutputFormat::from_str("json", false).expect("json variant");
assert_eq!(text, OutputFormat::Text);
assert_eq!(json, OutputFormat::Json);
assert!(json.is_json());
assert!(text.is_text());
assert_eq!(OutputFormat::default(), OutputFormat::Text);
}
#[test]
fn envelope_success_serializes_snake_case() {
#[derive(Serialize)]
struct Payload {
item_count: u32,
}
let envelope = Envelope::success(
schema_version_for("cli-template", "status", 1),
Payload { item_count: 3 },
);
let json = serde_json::to_string(&envelope).expect("serialize envelope");
assert_eq!(
json,
"{\"schema_version\":\"cli.cli-template.status.v1\",\"ok\":true,\"data\":{\"item_count\":3}}"
);
}
#[test]
fn envelope_success_includes_warnings_when_present() {
let envelope: Envelope<()> = Envelope {
schema_version: schema_version_for("memo-cli", "apply", 1),
ok: true,
data: None,
warnings: Vec::new(),
error: None,
}
.with_warning("entry-42 skipped: missing body");
let json = serde_json::to_string(&envelope).expect("serialize envelope");
assert_eq!(
json,
"{\"schema_version\":\"cli.memo-cli.apply.v1\",\"ok\":true,\"warnings\":[\"entry-42 skipped: missing body\"]}"
);
}
#[test]
fn envelope_failure_serializes_error_only() {
let envelope: Envelope<()> = Envelope::failure(
schema_version_for("cli-template", "error", 1),
EnvelopeError::new("parse-error", "missing required argument <name>")
.with_hint("see --help"),
);
let json = serde_json::to_string(&envelope).expect("serialize envelope");
assert_eq!(
json,
"{\"schema_version\":\"cli.cli-template.error.v1\",\"ok\":false,\"error\":{\"code\":\"parse-error\",\"message\":\"missing required argument <name>\",\"hint\":\"see --help\"}}"
);
}
#[test]
fn exit_constants_match_bsd_sysexits() {
assert_eq!(exit::SUCCESS, 0);
assert_eq!(exit::RUNTIME, 1);
assert_eq!(exit::USAGE, 64);
assert_eq!(exit::DATA, 65);
assert_eq!(exit::UNAVAILABLE, 69);
assert_eq!(exit::SOFTWARE, 70);
}
#[test]
fn schema_version_for_builds_canonical_string() {
assert_eq!(
schema_version_for("memo-cli", "list", 1),
"cli.memo-cli.list.v1"
);
assert_eq!(
schema_version_for("cli-template", "status", 2),
"cli.cli-template.status.v2"
);
}
}