use crate::error::BirdError;
use clap::ValueEnum;
use owo_colors::OwoColorize;
use std::io::IsTerminal;
#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
Jsonl,
Ndjson,
}
impl OutputFormat {
pub fn is_json(self) -> bool {
matches!(
self,
OutputFormat::Json | OutputFormat::Jsonl | OutputFormat::Ndjson
)
}
}
#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
pub enum ColorMode {
Auto,
Always,
Never,
}
#[derive(Clone, Debug)]
pub struct OutputConfig {
pub format: OutputFormat,
pub use_color: bool,
pub quiet: bool,
pub raw: bool,
}
impl OutputConfig {
pub fn suppress_diag(&self) -> bool {
self.quiet || self.format.is_json()
}
pub fn is_raw_text(&self) -> bool {
self.raw && self.format == OutputFormat::Text
}
pub fn print_envelope(
&self,
out: &mut dyn std::io::Write,
env: &serde_json::Value,
) -> std::io::Result<()> {
let line = serde_json::to_string(env).map_err(std::io::Error::other)?;
writeln!(out, "{}", line)
}
pub fn print_message(&self, out: &mut dyn std::io::Write, msg: &str) -> std::io::Result<()> {
writeln!(out, "{}", msg)
}
pub fn print_response_json(
&self,
out: &mut dyn std::io::Write,
value: &serde_json::Value,
) -> std::io::Result<()> {
debug_assert!(
!matches!(self.format, OutputFormat::Text),
"print_response_json must not be called in Text mode"
);
match self.format {
OutputFormat::Json | OutputFormat::Jsonl | OutputFormat::Ndjson => {
let line = serde_json::to_string(value).map_err(std::io::Error::other)?;
writeln!(out, "{}", line)
}
OutputFormat::Text => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"print_response_json called in Text mode",
)),
}
}
pub fn print_error(
&self,
stderr: &mut dyn std::io::Write,
err: &BirdError,
) -> std::io::Result<()> {
if self.format.is_json() {
let mut json = serde_json::json!({
"error": err.error_id(),
"kind": err.kind(),
"message": sanitize_for_stderr(err.message(), 1000),
"exit_code": err.exit_code(),
"meta": {},
});
if let Some(cmd) = err.command() {
json["command"] = serde_json::Value::String(cmd.to_string());
}
if let Some(status) = err.status() {
json["status"] = serde_json::json!(status);
}
let line = serde_json::to_string(&json).unwrap_or_else(|_| {
String::from(
r#"{"error":"serialization-failed","kind":"general","message":"failed to serialize error envelope","exit_code":1}"#,
)
});
writeln!(stderr, "{}", line)
} else {
let prefix = match err {
BirdError::Usage { .. } => "usage error: ".to_string(),
BirdError::Auth { .. } => "auth failed: ".to_string(),
BirdError::Config { .. } => "config failed: ".to_string(),
BirdError::General {
command: Some(name),
..
} => format!("{} failed: ", name),
BirdError::General { command: None, .. } => "error: ".to_string(),
};
writeln!(
stderr,
"{}{}",
error(&prefix, self.use_color),
err.message()
)
}
}
pub fn print_diag(&self, stderr: &mut dyn std::io::Write, msg: &str) -> std::io::Result<()> {
if self.suppress_diag() {
return Ok(());
}
writeln!(stderr, "{}", msg)
}
}
const _: fn() = || {
fn _assert_send_sync_clone<T: Send + Sync + Clone>() {}
_assert_send_sync_clone::<OutputConfig>();
};
pub fn use_color_auto() -> bool {
let stderr_tty = std::io::stderr().is_terminal();
let no_color_env = std::env::var("NO_COLOR").is_ok();
let term_dumb = std::env::var("TERM").as_deref() == Ok("dumb");
stderr_tty && !no_color_env && !term_dumb
}
pub fn resolve_color(mode: ColorMode) -> bool {
match mode {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => use_color_auto(),
}
}
pub fn section(s: &str, use_color: bool) -> String {
if use_color {
s.bold().white().to_string()
} else {
s.to_string()
}
}
pub fn command(s: &str, use_color: bool) -> String {
if use_color {
s.bold().cyan().to_string()
} else {
s.to_string()
}
}
pub fn muted(s: &str, use_color: bool) -> String {
if use_color {
s.bright_black().to_string()
} else {
s.to_string()
}
}
pub fn error(s: &str, use_color: bool) -> String {
if use_color {
s.red().to_string()
} else {
s.to_string()
}
}
pub fn success(s: &str, use_color: bool) -> String {
if use_color {
s.green().to_string()
} else {
s.to_string()
}
}
pub fn strip_ansi_lines(s: &str) -> std::borrow::Cow<'_, str> {
if !s.contains('\x1b') {
return std::borrow::Cow::Borrowed(s);
}
std::borrow::Cow::Owned(
s.lines()
.filter(|line| !line.contains('\x1b'))
.collect::<Vec<_>>()
.join("\n"),
)
}
pub fn sanitize_for_stderr(s: &str, max_chars: usize) -> String {
s.chars()
.take(max_chars)
.map(|c| if c.is_control() { '?' } else { c })
.collect()
}
pub fn emoji_available(use_emoji: bool) -> &'static str {
if use_emoji { "✅ " } else { "" }
}
pub fn emoji_unavailable(use_emoji: bool) -> &'static str {
if use_emoji { "❌ " } else { "" }
}
pub fn success_envelope_string(
data: &serde_json::Value,
meta: &serde_json::Value,
) -> Result<String, serde_json::Error> {
let env = serde_json::json!({
"data": data,
"meta": meta,
});
serde_json::to_string(&env)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_ansi_lines_clean_json() {
let input = "{\"data\":{\"id\":\"1\"}}\n";
assert_eq!(strip_ansi_lines(input), input);
}
#[test]
fn strip_ansi_lines_removes_colored_error() {
let input = "{\"data\":{\"id\":\"1\"}}\n\x1b[31mError: request failed\x1b[0m";
assert_eq!(strip_ansi_lines(input), "{\"data\":{\"id\":\"1\"}}");
}
#[test]
fn strip_ansi_lines_preserves_all_clean() {
let input = "line one\nline two\nline three";
assert_eq!(strip_ansi_lines(input), input);
}
#[test]
fn strip_ansi_lines_empty() {
assert_eq!(strip_ansi_lines(""), "");
}
#[test]
fn sanitize_normal_text() {
assert_eq!(sanitize_for_stderr("hello world", 100), "hello world");
}
#[test]
fn sanitize_strips_escape() {
assert_eq!(
sanitize_for_stderr("a\x1b[31mred\x1b[0m", 100),
"a?[31mred?[0m"
);
}
#[test]
fn sanitize_strips_bel() {
assert_eq!(sanitize_for_stderr("a\x07b", 100), "a?b");
}
#[test]
fn sanitize_strips_newlines() {
assert_eq!(sanitize_for_stderr("line1\nline2", 100), "line1?line2");
}
#[test]
fn sanitize_truncates() {
assert_eq!(sanitize_for_stderr("abcdef", 3), "abc");
}
#[test]
fn sanitize_empty() {
assert_eq!(sanitize_for_stderr("", 100), "");
}
#[test]
fn sanitize_at_exact_limit() {
assert_eq!(sanitize_for_stderr("abc", 3), "abc");
}
#[test]
fn output_format_is_json_classification() {
assert!(OutputFormat::Json.is_json());
assert!(OutputFormat::Jsonl.is_json());
assert!(OutputFormat::Ndjson.is_json());
assert!(!OutputFormat::Text.is_json());
}
#[test]
fn success_envelope_has_data_and_meta_keys() {
let data = serde_json::json!({"id": "abc"});
let meta = serde_json::json!({});
let s = success_envelope_string(&data, &meta).expect("serialize");
let parsed: serde_json::Value = serde_json::from_str(&s).expect("parse");
assert!(parsed.get("data").is_some(), "envelope must have data");
assert!(parsed.get("meta").is_some(), "envelope must have meta");
}
}
#[cfg(test)]
mod method_tests {
use super::*;
use serde_json::json;
fn text_cfg() -> OutputConfig {
OutputConfig {
format: OutputFormat::Text,
use_color: false,
quiet: false,
raw: false,
}
}
fn json_cfg() -> OutputConfig {
OutputConfig {
format: OutputFormat::Json,
use_color: false,
quiet: false,
raw: false,
}
}
#[test]
fn print_message_writes_line() {
let cfg = text_cfg();
let mut buf = Vec::new();
cfg.print_message(&mut buf, "hello").unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "hello\n");
}
#[test]
fn print_envelope_writes_single_json_line() {
let cfg = json_cfg();
let mut buf = Vec::new();
let env = json!({"status": "ok", "data": null, "errors": [], "meta": {}});
cfg.print_envelope(&mut buf, &env).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.ends_with('\n'));
let parsed: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(parsed["status"], "ok");
}
#[test]
fn print_response_json_jsonl_writes_compact_line() {
let cfg = OutputConfig {
format: OutputFormat::Jsonl,
use_color: false,
quiet: false,
raw: false,
};
let mut buf = Vec::new();
cfg.print_response_json(&mut buf, &json!({"id":"1","name":"a"}))
.unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.ends_with('\n'));
assert!(!s.trim().contains('\n'));
}
#[test]
#[should_panic(expected = "print_response_json must not be called in Text mode")]
fn print_response_json_text_mode_panics_in_debug() {
let cfg = text_cfg();
let mut buf = Vec::new();
let _ = cfg.print_response_json(&mut buf, &json!({}));
}
#[test]
fn print_diag_suppresses_when_quiet() {
let cfg = OutputConfig {
format: OutputFormat::Text,
use_color: false,
quiet: true,
raw: false,
};
let mut buf = Vec::new();
cfg.print_diag(&mut buf, "diag").unwrap();
assert!(buf.is_empty(), "diag should be suppressed when quiet");
}
#[test]
fn print_diag_writes_when_not_quiet() {
let cfg = text_cfg();
let mut buf = Vec::new();
cfg.print_diag(&mut buf, "diag").unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "diag\n");
}
#[test]
fn print_error_json_writes_envelope() {
let cfg = json_cfg();
let mut buf = Vec::new();
let err = BirdError::config("missing");
cfg.print_error(&mut buf, &err).unwrap();
let s = String::from_utf8(buf).unwrap();
let val: serde_json::Value =
serde_json::from_str(s.trim()).expect("error envelope is JSON");
let obj = val.as_object().expect("envelope is object");
assert!(
obj.contains_key("error") && obj.contains_key("kind"),
"envelope carries error id and kind: {:?}",
obj.keys().collect::<Vec<_>>()
);
assert_eq!(obj["kind"], "config");
}
#[test]
fn print_message_preserves_embedded_newlines() {
let cfg = text_cfg();
let mut buf = Vec::new();
cfg.print_message(&mut buf, "line one\nline two\nline three")
.unwrap();
let s = String::from_utf8(buf).unwrap();
assert_eq!(s, "line one\nline two\nline three\n");
assert_eq!(s.matches('\n').count(), 3);
}
#[test]
fn print_envelope_nested_object_serializes_compactly() {
let cfg = json_cfg();
let mut buf = Vec::new();
let env = json!({
"data": {
"user": {"id": "1", "name": "alice"},
"items": [1, 2, 3],
},
"meta": {"count": 3, "nested": {"deep": true}},
});
cfg.print_envelope(&mut buf, &env).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.ends_with('\n'), "envelope ends with newline");
assert_eq!(s.matches('\n').count(), 1);
let parsed: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(parsed["data"]["user"]["name"], "alice");
assert_eq!(parsed["meta"]["nested"]["deep"], true);
}
#[test]
fn print_error_text_color_emits_ansi() {
let cfg = OutputConfig {
format: OutputFormat::Text,
use_color: true,
quiet: false,
raw: false,
};
let mut buf = Vec::new();
let err = BirdError::config("missing");
cfg.print_error(&mut buf, &err).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains('\x1b'),
"use_color=true should embed ANSI escape, got: {:?}",
s
);
let plain = text_cfg();
let mut plain_buf = Vec::new();
plain.print_error(&mut plain_buf, &err).unwrap();
let plain_s = String::from_utf8(plain_buf).unwrap();
assert!(
!plain_s.contains('\x1b'),
"use_color=false must not embed ANSI escape, got: {:?}",
plain_s
);
}
#[test]
fn print_message_empty_writes_single_newline() {
let cfg = text_cfg();
let mut buf = Vec::new();
cfg.print_message(&mut buf, "").unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "\n");
}
#[test]
fn print_message_large_input_round_trips() {
let cfg = text_cfg();
let big = "x".repeat(64 * 1024);
let mut buf = Vec::new();
cfg.print_message(&mut buf, &big).unwrap();
let s = String::from_utf8(buf).unwrap();
assert_eq!(s.len(), big.len() + 1, "preserve content + 1 newline");
assert!(s.starts_with("xxxx"));
assert!(s.ends_with("x\n"));
}
}