use crate::error::SubXError;
use serde::Serialize;
use std::io::{self, Write};
use std::sync::OnceLock;
pub const SCHEMA_VERSION: &str = "1.0";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum OutputMode {
#[default]
Text,
Json,
}
impl OutputMode {
pub fn from_token(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"text" => Some(OutputMode::Text),
"json" => Some(OutputMode::Json),
_ => None,
}
}
pub fn is_json(self) -> bool {
matches!(self, OutputMode::Json)
}
}
static ACTIVE_MODE: OnceLock<OutputMode> = OnceLock::new();
static QUIET: OnceLock<bool> = OnceLock::new();
pub fn install_active_mode(mode: OutputMode, quiet: bool) {
let _ = ACTIVE_MODE.set(mode);
let _ = QUIET.set(quiet);
}
pub fn active_mode() -> OutputMode {
ACTIVE_MODE.get().copied().unwrap_or(OutputMode::Text)
}
pub fn is_quiet() -> bool {
QUIET.get().copied().unwrap_or(false)
}
#[derive(Debug, Serialize)]
pub struct Envelope<'a, T: Serialize> {
pub schema_version: &'static str,
pub command: &'a str,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorEnvelope>,
#[serde(skip_serializing_if = "Option::is_none")]
pub warnings: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
pub struct ErrorEnvelope {
pub category: String,
pub code: String,
pub exit_code: i32,
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 ErrorEnvelope {
pub fn from_error(err: &SubXError) -> Self {
Self {
category: err.category().to_string(),
code: err.machine_code().to_string(),
exit_code: err.exit_code(),
message: err.user_friendly_message(),
hint: err.hint().map(str::to_string),
details: None,
}
}
pub fn argument_parsing(message: String, exit_code: i32) -> Self {
Self {
category: "argument_parsing".to_string(),
code: "E_ARGUMENT_PARSING".to_string(),
message,
exit_code,
hint: None,
details: None,
}
}
}
pub trait OutputRenderer {
fn render_success<T: Serialize>(&self, command: &str, data: T) -> io::Result<()>;
fn render_error(&self, command: &str, err: &SubXError) -> io::Result<()>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct TextRenderer;
impl OutputRenderer for TextRenderer {
fn render_success<T: Serialize>(&self, _command: &str, _data: T) -> io::Result<()> {
Ok(())
}
fn render_error(&self, _command: &str, _err: &SubXError) -> io::Result<()> {
Ok(())
}
}
pub struct JsonRenderer<W: Write> {
writer: std::cell::RefCell<W>,
}
impl JsonRenderer<io::Stdout> {
pub fn stdout() -> Self {
Self {
writer: std::cell::RefCell::new(io::stdout()),
}
}
}
impl<W: Write> JsonRenderer<W> {
pub fn new(writer: W) -> Self {
Self {
writer: std::cell::RefCell::new(writer),
}
}
fn write_envelope<T: Serialize>(&self, envelope: &Envelope<'_, T>) -> io::Result<()> {
let mut w = self.writer.borrow_mut();
serde_json::to_writer(&mut *w, envelope).map_err(io::Error::other)?;
w.write_all(b"\n")?;
w.flush()?;
Ok(())
}
}
impl<W: Write> OutputRenderer for JsonRenderer<W> {
fn render_success<T: Serialize>(&self, command: &str, data: T) -> io::Result<()> {
let envelope = Envelope::<T> {
schema_version: SCHEMA_VERSION,
command,
status: "ok",
data: Some(data),
error: None,
warnings: None,
};
self.write_envelope(&envelope)
}
fn render_error(&self, command: &str, err: &SubXError) -> io::Result<()> {
let envelope = Envelope::<serde_json::Value> {
schema_version: SCHEMA_VERSION,
command,
status: "error",
data: None,
error: Some(ErrorEnvelope::from_error(err)),
warnings: None,
};
self.write_envelope(&envelope)
}
}
pub fn emit_success<T: Serialize>(mode: OutputMode, command: &str, data: T) {
match mode {
OutputMode::Text => {}
OutputMode::Json => {
let _ = JsonRenderer::stdout().render_success(command, data);
}
}
}
pub fn emit_success_with_warnings<T: Serialize>(
mode: OutputMode,
command: &str,
data: T,
warnings: Vec<String>,
) {
match mode {
OutputMode::Text => {}
OutputMode::Json => {
let warnings = if warnings.is_empty() {
None
} else {
Some(warnings)
};
let envelope = Envelope::<T> {
schema_version: SCHEMA_VERSION,
command,
status: "ok",
data: Some(data),
error: None,
warnings,
};
let _ = JsonRenderer::stdout().write_envelope(&envelope);
}
}
}
pub fn emit_error(mode: OutputMode, command: &str, err: &SubXError) {
match mode {
OutputMode::Text => {}
OutputMode::Json => {
let _ = JsonRenderer::stdout().render_error(command, err);
}
}
}
pub fn emit_argument_parsing_error(command: Option<&str>, message: String, exit_code: i32) {
let envelope = Envelope::<serde_json::Value> {
schema_version: SCHEMA_VERSION,
command: command.unwrap_or(""),
status: "error",
data: None,
error: Some(ErrorEnvelope::argument_parsing(message, exit_code)),
warnings: None,
};
let renderer = JsonRenderer::stdout();
let _ = renderer.write_envelope(&envelope);
}
pub fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
i += 2;
while i < bytes.len() {
let b = bytes[i];
i += 1;
if (0x40..=0x7e).contains(&b) {
break;
}
}
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
#[cfg(test)]
pub fn assert_json_stdout_clean(stdout: &[u8]) -> Result<serde_json::Value, String> {
if stdout.is_empty() {
return Err("stdout was empty".to_string());
}
if !stdout.ends_with(b"\n") {
return Err("stdout did not end with newline".to_string());
}
if stdout.contains(&0x1b) {
return Err("stdout contained ANSI escape sequence".to_string());
}
let body = &stdout[..stdout.len() - 1];
if body.contains(&b'\n') {
return Err("stdout contained more than one line".to_string());
}
serde_json::from_slice(body).map_err(|e| format!("stdout did not parse as JSON: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Serialize)]
struct Sample {
value: u32,
}
#[test]
fn output_mode_from_token_is_case_insensitive() {
assert_eq!(OutputMode::from_token("json"), Some(OutputMode::Json));
assert_eq!(OutputMode::from_token("JSON"), Some(OutputMode::Json));
assert_eq!(OutputMode::from_token(" Text "), Some(OutputMode::Text));
assert_eq!(OutputMode::from_token("yaml"), None);
}
#[test]
fn json_renderer_emits_single_document_with_newline() {
let mut buf = Vec::new();
let renderer = JsonRenderer::new(&mut buf);
renderer
.render_success("match", Sample { value: 42 })
.expect("write");
drop(renderer);
let parsed = assert_json_stdout_clean(&buf).expect("clean JSON");
assert_eq!(parsed["schema_version"], SCHEMA_VERSION);
assert_eq!(parsed["command"], "match");
assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["data"]["value"], 42);
assert!(parsed.get("error").is_none(), "error must be omitted on ok");
}
#[test]
fn json_renderer_omits_data_on_error() {
let mut buf = Vec::new();
let renderer = JsonRenderer::new(&mut buf);
let err = SubXError::config("bad");
renderer.render_error("convert", &err).expect("write");
drop(renderer);
let parsed = assert_json_stdout_clean(&buf).expect("clean JSON");
assert_eq!(parsed["status"], "error");
assert!(
parsed.get("data").is_none(),
"data must be omitted on error"
);
assert_eq!(parsed["error"]["category"], "config");
assert_eq!(parsed["error"]["code"], "E_CONFIG");
assert_eq!(parsed["error"]["exit_code"], 2);
}
#[test]
fn argument_parsing_envelope_shape() {
let env = ErrorEnvelope::argument_parsing("unknown flag --foo".into(), 2);
assert_eq!(env.category, "argument_parsing");
assert_eq!(env.code, "E_ARGUMENT_PARSING");
assert_eq!(env.exit_code, 2);
}
#[test]
fn strip_ansi_removes_csi_sequences() {
let input = "\x1b[31m\x1b[1mfailed\x1b[0m";
assert_eq!(strip_ansi(input), "failed");
assert_eq!(strip_ansi("plain"), "plain");
}
#[test]
fn text_renderer_is_noop() {
let r = TextRenderer;
r.render_success("x", Sample { value: 1 }).unwrap();
r.render_error("x", &SubXError::config("y")).unwrap();
}
}