use std::io::{IsTerminal, Write};
use clap::ValueEnum;
use serde_json::Value;
use crate::cli::ColorChoice;
use crate::error::XurlError;
#[derive(Clone, Debug, ValueEnum, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
Jsonl,
Ndjson,
Yaml,
Csv,
Tsv,
}
impl OutputFormat {
#[must_use]
pub fn is_structured(&self) -> bool {
!matches!(self, OutputFormat::Text)
}
}
#[derive(Clone, Debug)]
pub struct OutputConfig {
pub format: OutputFormat,
pub quiet: bool,
pub no_color: bool,
pub use_color: bool,
pub verbose: bool,
pub raw: bool,
pub no_interactive: bool,
}
impl OutputConfig {
#[must_use]
pub fn new(format: OutputFormat, quiet: bool, verbose: bool, color: ColorChoice) -> Self {
Self::new_with_raw(format, quiet, verbose, color, false)
}
#[must_use]
pub fn new_with_raw(
format: OutputFormat,
quiet: bool,
verbose: bool,
color: ColorChoice,
raw: bool,
) -> Self {
let no_color_env = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
let use_color = if raw || no_color_env {
false
} else {
match color {
ColorChoice::Always => true,
ColorChoice::Never => false,
ColorChoice::Auto => std::io::stderr().is_terminal(),
}
};
Self {
format,
quiet,
no_color: !use_color,
use_color,
verbose,
raw,
no_interactive: false,
}
}
#[must_use]
pub fn with_no_interactive(mut self, no_interactive: bool) -> Self {
self.no_interactive = no_interactive;
self
}
#[must_use]
pub fn is_interactive_terminal(&self) -> bool {
!self.no_interactive && std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
pub fn print_error_envelope(
&self,
err: &mut dyn Write,
reason: &str,
exit_code: i32,
message: &str,
) {
let envelope = serde_json::json!({
"status": "error",
"reason": reason,
"exit_code": exit_code,
"message": message,
});
self.write_envelope_or_text_error(err, &envelope, message);
}
pub fn info(&self, err: &mut dyn Write, msg: &str) {
if self.quiet || self.format.is_structured() {
return;
}
let _ = writeln!(err, "{msg}");
}
pub fn status(&self, err: &mut dyn Write, msg: &str) {
if self.quiet || self.format.is_structured() {
return;
}
if self.no_color {
let _ = writeln!(err, "{msg}");
} else {
let _ = writeln!(err, "\x1b[32m{msg}\x1b[0m");
}
}
pub fn print_response(&self, out: &mut dyn Write, value: &serde_json::Value) {
match self.format {
OutputFormat::Json | OutputFormat::Jsonl | OutputFormat::Ndjson => {
let body = if self.raw || matches!(self.format, OutputFormat::Ndjson) {
serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
} else {
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
};
let _ = writeln!(out, "{body}");
}
OutputFormat::Yaml => {
let body = serde_yaml::to_string(value).unwrap_or_else(|_| value.to_string());
let _ = write!(out, "{body}");
}
OutputFormat::Csv => {
let _ = write_flattened(out, value, ',');
}
OutputFormat::Tsv => {
let _ = write_flattened(out, value, '\t');
}
OutputFormat::Text => {
if self.no_color {
let pretty =
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
let _ = writeln!(out, "{pretty}");
} else {
let _ = crate::api::response::format_response(out, value);
}
}
}
}
pub fn print_stream_line(&self, out: &mut dyn Write, line: &str) {
let _ = writeln!(out, "{line}");
}
pub fn print_error(&self, err: &mut dyn Write, error: &XurlError, exit_code: i32) {
let display = error.to_string();
let mut obj = serde_json::Map::new();
obj.insert("status".into(), Value::String("error".into()));
obj.insert("reason".into(), Value::String(error.kind().to_string()));
obj.insert("exit_code".into(), Value::from(exit_code));
if let XurlError::AuthMethodMismatch {
endpoint,
rendered_url,
method,
requested,
supported,
available_in_app,
app,
other_apps_with_creds,
} = error
{
obj.insert("endpoint".into(), Value::String(endpoint.clone()));
if let Some(url) = rendered_url {
obj.insert("rendered_url".into(), Value::String(url.clone()));
}
obj.insert("method".into(), Value::String(method.clone()));
obj.insert(
"requested".into(),
match requested {
Some(s) => Value::String(s.clone()),
None => Value::Null,
},
);
obj.insert(
"supported".into(),
Value::Array(supported.iter().cloned().map(Value::String).collect()),
);
if let Some(avail) = available_in_app {
obj.insert(
"available_in_app".into(),
Value::Array(avail.iter().cloned().map(Value::String).collect()),
);
}
if let Some(app_name) = app {
obj.insert("app".into(), Value::String(app_name.clone()));
}
if let Some(others) = other_apps_with_creds {
obj.insert(
"other_apps_with_creds".into(),
Value::Array(others.iter().cloned().map(Value::String).collect()),
);
}
}
obj.insert("message".into(), Value::String(display.clone()));
let envelope = Value::Object(obj);
self.write_envelope_or_text_error(err, &envelope, &display);
}
pub fn print_success(&self, out: &mut dyn Write, payload: &Value) {
if !self.format.is_structured() {
self.print_response(out, payload);
return;
}
let mut obj = serde_json::Map::new();
obj.insert("status".into(), Value::String("ok".into()));
if let Some(map) = payload.as_object() {
for (k, v) in map {
obj.insert(k.clone(), v.clone());
}
} else {
obj.insert("payload".into(), payload.clone());
}
let envelope = Value::Object(obj);
self.write_structured(out, &envelope);
}
pub fn print_dry_run(
&self,
out: &mut dyn Write,
would_succeed: bool,
exit_code: i32,
ctx: &Value,
) {
if !self.format.is_structured() {
self.print_response(out, ctx);
return;
}
let mut obj = serde_json::Map::new();
obj.insert("status".into(), Value::String("dry_run".into()));
obj.insert("would_succeed".into(), Value::Bool(would_succeed));
obj.insert("exit_code".into(), Value::from(exit_code));
if let Some(map) = ctx.as_object() {
for (k, v) in map {
obj.insert(k.clone(), v.clone());
}
}
let envelope = Value::Object(obj);
self.write_structured(out, &envelope);
}
pub fn print_confirmation_required(
&self,
err: &mut dyn Write,
ctx: &serde_json::Value,
exit_code: i32,
) {
let mut obj = if let serde_json::Value::Object(m) = ctx {
m.clone()
} else {
serde_json::Map::new()
};
obj.insert(
"status".to_string(),
serde_json::Value::String("error".to_string()),
);
obj.insert(
"reason".to_string(),
serde_json::Value::String("confirmation-required".to_string()),
);
obj.insert(
"exit_code".to_string(),
serde_json::Value::Number(serde_json::Number::from(exit_code)),
);
if self.format.is_structured() {
let value = serde_json::Value::Object(obj);
self.write_structured(err, &value);
} else {
let cmd = obj
.get("command")
.and_then(serde_json::Value::as_str)
.unwrap_or("operation");
let line = if self.no_color {
format!(
"Error: confirmation required for {cmd} — pass --force or run interactively"
)
} else {
format!(
"\x1b[31mError: confirmation required for {cmd} — pass --force or run interactively\x1b[0m"
)
};
let _ = writeln!(err, "{line}");
}
}
pub fn verbose(&self, err: &mut dyn Write, msg: &str) {
if !self.verbose || self.quiet || self.format.is_structured() {
return;
}
let _ = writeln!(err, "{msg}");
}
pub fn warning(&self, err: &mut dyn Write, msg: &str) {
if self.format.is_structured() {
return;
}
if self.no_color {
let _ = writeln!(err, "warning: {msg}");
} else {
let _ = writeln!(err, "\x1b[1;33mwarning:\x1b[0m {msg}");
}
}
pub fn progress(&self, err: &mut dyn Write, msg: &str) {
if self.quiet || self.format.is_structured() {
return;
}
if !std::io::stderr().is_terminal() {
return;
}
let _ = writeln!(err, "{msg}");
}
pub fn print_message(&self, out: &mut dyn Write, msg: &str) {
if self.format.is_structured() {
let clean = strip_ansi(msg);
let value = serde_json::json!({"message": clean});
self.write_structured(out, &value);
return;
}
if self.no_color {
let _ = writeln!(out, "{}", strip_ansi(msg));
} else {
let _ = writeln!(out, "{msg}");
}
}
fn write_structured(&self, w: &mut dyn Write, value: &Value) {
match self.format {
OutputFormat::Json | OutputFormat::Jsonl => {
let body = if self.raw {
serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
} else {
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
};
let _ = writeln!(w, "{body}");
}
OutputFormat::Ndjson => {
let body = serde_json::to_string(value).unwrap_or_else(|_| value.to_string());
let _ = writeln!(w, "{body}");
}
OutputFormat::Yaml => {
let body = serde_yaml::to_string(value).unwrap_or_else(|_| value.to_string());
let _ = write!(w, "{body}");
}
OutputFormat::Csv | OutputFormat::Tsv => {
let body = serde_json::to_string(value).unwrap_or_else(|_| value.to_string());
let _ = writeln!(w, "{body}");
}
OutputFormat::Text => {
let body =
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
let _ = writeln!(w, "{body}");
}
}
}
fn write_envelope_or_text_error(&self, err: &mut dyn Write, envelope: &Value, display: &str) {
if self.format.is_structured() {
self.write_structured(err, envelope);
} else if self.no_color {
let _ = writeln!(err, "Error: {display}");
} else {
let _ = writeln!(err, "\x1b[31mError: {display}\x1b[0m");
}
}
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
format: OutputFormat::Text,
quiet: false,
no_color: true,
use_color: false,
verbose: false,
raw: false,
no_interactive: false,
}
}
}
pub fn warn_stderr(msg: &str) {
eprintln!("warning: {msg}");
}
fn write_flattened(out: &mut dyn Write, value: &Value, sep: char) -> std::io::Result<()> {
let rows: Vec<&Value> = match value {
Value::Array(arr) => arr.iter().collect(),
other => vec![other],
};
let mut header: Vec<String> = Vec::new();
let mut all_objects = true;
for row in &rows {
if let Value::Object(map) = row {
for k in map.keys() {
if !header.iter().any(|h| h == k) {
header.push(k.clone());
}
}
} else {
all_objects = false;
}
}
if !all_objects || header.is_empty() {
writeln!(out, "value")?;
for row in &rows {
let cell = scalar_cell(row);
writeln!(out, "{}", delimited_escape(&cell, sep))?;
}
return Ok(());
}
let mut needs_warning = false;
let row_cells: Vec<Vec<String>> = rows
.iter()
.map(|row| {
header
.iter()
.map(|key| {
let cell =
row.as_object()
.and_then(|m| m.get(key))
.map_or_else(String::new, |v| {
if matches!(v, Value::Object(_) | Value::Array(_)) {
needs_warning = true;
serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
} else {
scalar_cell(v)
}
});
delimited_escape(&cell, sep)
})
.collect()
})
.collect();
let mut header_out: Vec<String> = header.iter().map(|h| delimited_escape(h, sep)).collect();
if needs_warning {
header_out.push(delimited_escape("_warning", sep));
}
writeln!(out, "{}", header_out.join(&sep.to_string()))?;
for cells in &row_cells {
let mut line = cells.clone();
if needs_warning {
line.push(delimited_escape("nested values JSON-stringified", sep));
}
writeln!(out, "{}", line.join(&sep.to_string()))?;
}
Ok(())
}
fn scalar_cell(v: &Value) -> String {
match v {
Value::Null => String::new(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => s.clone(),
Value::Object(_) | Value::Array(_) => {
serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
}
}
}
fn delimited_escape(cell: &str, sep: char) -> String {
if sep == '\t' {
return cell.replace(['\t', '\n', '\r'], " ");
}
if cell.contains(sep) || cell.contains('"') || cell.contains('\n') || cell.contains('\r') {
let escaped = cell.replace('"', "\"\"");
format!("\"{escaped}\"")
} else {
cell.to_string()
}
}
fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
for inner in chars.by_ref() {
if inner == 'm' {
break;
}
}
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_ansi_removes_color_codes() {
assert_eq!(strip_ansi("\x1b[32mhello\x1b[0m"), "hello");
assert_eq!(strip_ansi("\x1b[1;31mError\x1b[0m"), "Error");
assert_eq!(strip_ansi("no codes here"), "no codes here");
}
#[test]
fn test_xurl_error_kind_mapping() {
assert_eq!(XurlError::Auth("test".into()).kind(), "auth-required");
assert_eq!(XurlError::Http("test".into()).kind(), "network-error");
assert_eq!(XurlError::api(400, "test").kind(), "network-error");
assert_eq!(XurlError::api(401, "x").kind(), "auth-required");
assert_eq!(XurlError::api(404, "x").kind(), "not-found");
assert_eq!(XurlError::api(429, "x").kind(), "rate-limited");
assert_eq!(XurlError::validation("test").kind(), "validation");
assert_eq!(XurlError::Io("test".into()).kind(), "io");
assert_eq!(XurlError::Json("test".into()).kind(), "serialization");
assert_eq!(
XurlError::InvalidMethod("X".into()).kind(),
"invalid-method"
);
assert_eq!(XurlError::token_store("x").kind(), "token-store");
}
#[test]
fn test_output_config_json_format() {
let cfg = OutputConfig {
format: OutputFormat::Json,
quiet: false,
no_color: false,
use_color: true,
verbose: false,
raw: false,
no_interactive: false,
};
assert!(!cfg.quiet);
}
#[test]
fn test_with_no_interactive_threads_field() {
let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Never)
.with_no_interactive(true);
assert!(cfg.no_interactive);
assert!(!cfg.is_interactive_terminal());
}
#[test]
fn test_print_error_envelope_json_shape() {
let cfg = OutputConfig::new(OutputFormat::Json, false, false, ColorChoice::Never);
let mut buf: Vec<u8> = Vec::new();
cfg.print_error_envelope(&mut buf, "no-tty", 1, "stdin is not a terminal");
let s = String::from_utf8(buf).expect("utf8");
let v: serde_json::Value = serde_json::from_str(s.trim()).expect("valid json");
assert_eq!(v["status"], "error");
assert_eq!(v["reason"], "no-tty");
assert_eq!(v["exit_code"], 1);
assert_eq!(v["message"], "stdin is not a terminal");
}
#[test]
fn test_no_color_env_overrides_color_always() {
let prior = std::env::var_os("NO_COLOR");
unsafe {
std::env::set_var("NO_COLOR", "1");
}
let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Always);
assert!(!cfg.use_color, "NO_COLOR must defeat --color always");
assert!(cfg.no_color, "no_color mirrors !use_color");
unsafe {
match prior {
Some(v) => std::env::set_var("NO_COLOR", v),
None => std::env::remove_var("NO_COLOR"),
}
}
}
#[test]
fn test_color_never_disables_color() {
let prior = std::env::var_os("NO_COLOR");
unsafe {
std::env::remove_var("NO_COLOR");
}
let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Never);
assert!(!cfg.use_color);
unsafe {
if let Some(v) = prior {
std::env::set_var("NO_COLOR", v);
}
}
}
#[test]
fn test_color_always_enables_color_when_no_color_unset() {
let prior = std::env::var_os("NO_COLOR");
unsafe {
std::env::remove_var("NO_COLOR");
}
let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Always);
assert!(cfg.use_color, "--color always must enable color");
unsafe {
if let Some(v) = prior {
std::env::set_var("NO_COLOR", v);
}
}
}
#[test]
fn test_raw_forces_color_off() {
let cfg =
OutputConfig::new_with_raw(OutputFormat::Text, false, false, ColorChoice::Always, true);
assert!(!cfg.use_color, "--raw must force use_color = false");
assert!(cfg.raw);
}
#[test]
fn test_verbose_emits_under_text_when_verbose_flag_set() {
let cfg = OutputConfig::new(OutputFormat::Text, false, true, ColorChoice::Never);
let mut buf = Vec::new();
cfg.verbose(&mut buf, "hello");
assert_eq!(buf, b"hello\n");
}
#[test]
fn test_verbose_suppressed_under_json() {
let cfg = OutputConfig::new(OutputFormat::Json, false, true, ColorChoice::Never);
let mut buf = Vec::new();
cfg.verbose(&mut buf, "hello");
assert!(buf.is_empty(), "verbose must not leak under JSON");
}
#[test]
fn test_verbose_suppressed_under_quiet() {
let cfg = OutputConfig::new(OutputFormat::Text, true, true, ColorChoice::Never);
let mut buf = Vec::new();
cfg.verbose(&mut buf, "hello");
assert!(buf.is_empty());
}
#[test]
fn test_verbose_suppressed_when_flag_off() {
let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Never);
let mut buf = Vec::new();
cfg.verbose(&mut buf, "hello");
assert!(buf.is_empty());
}
#[test]
fn test_warning_emits_under_text() {
let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Never);
let mut buf = Vec::new();
cfg.warning(&mut buf, "rate limited");
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("warning: rate limited"));
}
#[test]
fn test_warning_suppressed_under_json() {
let cfg = OutputConfig::new(OutputFormat::Json, false, false, ColorChoice::Never);
let mut buf = Vec::new();
cfg.warning(&mut buf, "rate limited");
assert!(
buf.is_empty(),
"warnings on stderr must be suppressed under JSON modes"
);
}
}