use std::io::{self, Write};
use serde::Serialize;
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum OutputFormat {
Json,
Ndjson,
Table,
Csv,
Tsv,
}
impl OutputFormat {
pub(crate) fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"json" => Some(Self::Json),
"ndjson" => Some(Self::Ndjson),
"table" => Some(Self::Table),
"csv" => Some(Self::Csv),
"tsv" => Some(Self::Tsv),
_ => None,
}
}
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::Json => "json",
Self::Ndjson => "ndjson",
Self::Table => "table",
Self::Csv => "csv",
Self::Tsv => "tsv",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum ColorChoice {
#[default]
Auto,
Always,
Never,
}
impl ColorChoice {
pub(crate) fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"auto" => Some(Self::Auto),
"always" => Some(Self::Always),
"never" => Some(Self::Never),
_ => None,
}
}
pub(crate) fn resolve(self, stdout_is_tty: bool) -> bool {
match self {
Self::Always => true,
Self::Never => false,
Self::Auto => stdout_is_tty && std::env::var_os("NO_COLOR").is_none(),
}
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct OutputCtx {
pub format: OutputFormat,
pub color: bool,
pub quiet: bool,
pub no_stats: bool,
pub stdout_is_tty: bool,
pub explicit_format: bool,
}
impl Default for OutputCtx {
fn default() -> Self {
Self {
format: OutputFormat::Ndjson,
color: false,
quiet: false,
no_stats: false,
stdout_is_tty: false,
explicit_format: false,
}
}
}
pub(crate) fn warn_invalid_global_output(
output_format: Option<String>,
color: Option<String>,
) -> (Option<String>, Option<String>) {
let format = output_format.and_then(|s| match OutputFormat::parse(&s) {
Some(_) => Some(s),
None => {
eprintln!(
"warning: invalid global.output_format '{s}' \
(expected one of: json, ndjson, table, csv, tsv); \
falling back to default"
);
None
}
});
let color = color.and_then(|s| match ColorChoice::parse(&s) {
Some(_) => Some(s),
None => {
eprintln!(
"warning: invalid global.color '{s}' \
(expected one of: auto, always, never); \
falling back to default"
);
None
}
});
(format, color)
}
impl OutputCtx {
pub(crate) fn resolve(
flag_format: Option<OutputFormat>,
config_format: Option<&str>,
flag_color: Option<ColorChoice>,
config_color: Option<&str>,
quiet: bool,
no_stats: bool,
stdout_is_tty: bool,
) -> Self {
let explicit_format = flag_format.is_some()
|| config_format.is_some_and(|s| OutputFormat::parse(s).is_some());
let format = flag_format
.or_else(|| config_format.and_then(OutputFormat::parse))
.unwrap_or(if stdout_is_tty {
OutputFormat::Json
} else {
OutputFormat::Ndjson
});
let color_choice = flag_color
.or_else(|| config_color.and_then(ColorChoice::parse))
.unwrap_or_default();
let color = color_choice.resolve(stdout_is_tty);
Self {
format,
color,
quiet,
no_stats,
stdout_is_tty,
explicit_format,
}
}
pub(crate) fn show_stats(&self) -> bool {
!self.quiet && !self.no_stats
}
pub(crate) fn show_progress(&self) -> bool {
!self.quiet
}
pub(crate) fn pretty_json(&self) -> bool {
match self.format {
OutputFormat::Ndjson => false,
OutputFormat::Json => self.stdout_is_tty || !self.explicit_format,
_ => false,
}
}
}
pub(crate) trait Tabular {
fn headers() -> &'static [&'static str];
fn row(&self) -> Vec<String>;
}
pub(crate) fn render_json<T: Serialize>(value: &T, pretty: bool) {
let json = if pretty {
serde_json::to_string_pretty(value)
} else {
serde_json::to_string(value)
};
match json {
Ok(j) => println!("{j}"),
Err(e) => {
eprintln!("JSON serialization error: {e}");
std::process::exit(crate::exit_code::CONFIG_ERROR);
}
}
}
pub(crate) fn render_ndjson<T: Serialize>(value: &T) {
render_json(value, false);
}
pub(crate) fn render_table<T: Tabular>(rows: &[T]) {
let headers = T::headers();
if headers.is_empty() {
return;
}
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
let stringified: Vec<Vec<String>> = rows.iter().map(|r| r.row()).collect();
for row in &stringified {
for (i, cell) in row.iter().enumerate() {
if i < widths.len() && cell.len() > widths[i] {
widths[i] = cell.len();
}
}
}
let right_align: Vec<bool> = (0..widths.len())
.map(|i| {
!stringified.is_empty()
&& stringified
.iter()
.all(|r| r.get(i).is_some_and(|c| c.parse::<i64>().is_ok()))
})
.collect();
let stdout = io::stdout();
let mut out = stdout.lock();
let _ = write_row(&mut out, headers.iter().copied(), &widths, &right_align);
let dashes: Vec<String> = widths.iter().map(|w| "-".repeat(*w)).collect();
let _ = write_row(
&mut out,
dashes.iter().map(String::as_str),
&widths,
&right_align,
);
for row in &stringified {
let _ = write_row(
&mut out,
row.iter().map(String::as_str),
&widths,
&right_align,
);
}
}
fn write_row<'a, I, W>(
out: &mut W,
cells: I,
widths: &[usize],
right_align: &[bool],
) -> io::Result<()>
where
I: Iterator<Item = &'a str>,
W: Write,
{
let mut first = true;
for (i, cell) in cells.enumerate() {
if !first {
write!(out, " ")?;
}
first = false;
let w = widths.get(i).copied().unwrap_or(0);
if right_align.get(i).copied().unwrap_or(false) {
write!(out, "{cell:>w$}")?;
} else {
write!(out, "{cell:<w$}")?;
}
}
writeln!(out)
}
pub(crate) struct DelimitedWriter {
sep: char,
headers: &'static [&'static str],
wrote_header: bool,
}
impl DelimitedWriter {
pub(crate) fn new(sep: char, headers: &'static [&'static str]) -> Self {
Self {
sep,
headers,
wrote_header: false,
}
}
pub(crate) fn push(&mut self, cells: &[String]) {
let stdout = io::stdout();
let mut out = stdout.lock();
if !self.wrote_header {
let _ = write_delimited_row(&mut out, self.headers.iter().copied(), self.sep);
self.wrote_header = true;
}
let _ = write_delimited_row(&mut out, cells.iter().map(String::as_str), self.sep);
}
}
fn write_delimited_row<'a, I, W>(out: &mut W, cells: I, sep: char) -> io::Result<()>
where
I: Iterator<Item = &'a str>,
W: Write,
{
let mut first = true;
for cell in cells {
if !first {
write!(out, "{sep}")?;
}
first = false;
write!(out, "{}", escape_delimited(cell, sep))?;
}
writeln!(out)
}
pub(crate) fn escape_delimited(cell: &str, sep: char) -> String {
let needs_quote = cell
.chars()
.any(|c| c == sep || c == '"' || c == '\n' || c == '\r');
if !needs_quote {
return cell.to_string();
}
let mut buf = String::with_capacity(cell.len() + 2);
buf.push('"');
for c in cell.chars() {
if c == '"' {
buf.push('"');
}
buf.push(c);
}
buf.push('"');
buf
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct Painter {
enabled: bool,
}
impl Painter {
pub(crate) fn new(enabled: bool) -> Self {
Self { enabled }
}
pub(crate) fn paint(&self, code: &str, text: &str) -> String {
if self.enabled {
format!("\x1b[{code}m{text}\x1b[0m")
} else {
text.to_string()
}
}
pub(crate) fn bold(&self, s: &str) -> String {
self.paint("1", s)
}
pub(crate) fn dim(&self, s: &str) -> String {
self.paint("2", s)
}
pub(crate) fn red(&self, s: &str) -> String {
self.paint("31", s)
}
pub(crate) fn red_bold(&self, s: &str) -> String {
self.paint("1;31", s)
}
pub(crate) fn green(&self, s: &str) -> String {
self.paint("32", s)
}
pub(crate) fn green_bold(&self, s: &str) -> String {
self.paint("1;32", s)
}
pub(crate) fn yellow(&self, s: &str) -> String {
self.paint("33", s)
}
pub(crate) fn yellow_bold(&self, s: &str) -> String {
self.paint("1;33", s)
}
pub(crate) fn blue(&self, s: &str) -> String {
self.paint("34", s)
}
pub(crate) fn cyan(&self, s: &str) -> String {
self.paint("36", s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn output_format_parses_known_values() {
assert_eq!(OutputFormat::parse("json"), Some(OutputFormat::Json));
assert_eq!(OutputFormat::parse("NDJSON"), Some(OutputFormat::Ndjson));
assert_eq!(OutputFormat::parse("Table"), Some(OutputFormat::Table));
assert_eq!(OutputFormat::parse("csv"), Some(OutputFormat::Csv));
assert_eq!(OutputFormat::parse("tsv"), Some(OutputFormat::Tsv));
assert_eq!(OutputFormat::parse("xml"), None);
}
#[test]
fn color_choice_parses_known_values() {
assert_eq!(ColorChoice::parse("auto"), Some(ColorChoice::Auto));
assert_eq!(ColorChoice::parse("Always"), Some(ColorChoice::Always));
assert_eq!(ColorChoice::parse("NEVER"), Some(ColorChoice::Never));
assert_eq!(ColorChoice::parse("bold"), None);
}
#[test]
fn color_resolve_honors_no_color_only_under_auto() {
let prior = std::env::var_os("NO_COLOR");
unsafe {
std::env::set_var("NO_COLOR", "1");
}
assert!(!ColorChoice::Auto.resolve(true));
assert!(ColorChoice::Always.resolve(false));
assert!(!ColorChoice::Never.resolve(true));
match prior {
Some(v) => unsafe { std::env::set_var("NO_COLOR", v) },
None => unsafe { std::env::remove_var("NO_COLOR") },
}
}
#[test]
fn tty_default_falls_through_to_json_on_tty_ndjson_otherwise() {
let on_tty =
OutputCtx::resolve(None, None, None, None, false, false, true);
assert_eq!(on_tty.format, OutputFormat::Json);
assert!(!on_tty.explicit_format);
assert!(on_tty.pretty_json());
let piped =
OutputCtx::resolve(None, None, None, None, false, false, false);
assert_eq!(piped.format, OutputFormat::Ndjson);
assert!(!piped.explicit_format);
assert!(!piped.pretty_json());
}
#[test]
fn explicit_flag_beats_config_and_default() {
let ctx = OutputCtx::resolve(
Some(OutputFormat::Csv),
Some("table"),
None,
None,
false,
false,
true,
);
assert_eq!(ctx.format, OutputFormat::Csv);
assert!(ctx.explicit_format);
}
#[test]
fn config_fills_when_flag_unset() {
let ctx = OutputCtx::resolve(None, Some("ndjson"), None, None, false, false, true);
assert_eq!(ctx.format, OutputFormat::Ndjson);
assert!(ctx.explicit_format);
}
#[test]
fn quiet_disables_stats_and_progress() {
let ctx = OutputCtx::resolve(None, None, None, None, true, false, false);
assert!(!ctx.show_stats());
assert!(!ctx.show_progress());
}
#[test]
fn no_stats_keeps_progress_drops_stats() {
let ctx = OutputCtx::resolve(None, None, None, None, false, true, false);
assert!(!ctx.show_stats());
assert!(ctx.show_progress());
}
#[test]
fn escape_delimited_quotes_only_when_needed() {
assert_eq!(escape_delimited("hello", ','), "hello");
assert_eq!(escape_delimited("a,b", ','), "\"a,b\"");
assert_eq!(escape_delimited("a\tb", ','), "a\tb");
assert_eq!(escape_delimited("a\tb", '\t'), "\"a\tb\"");
assert_eq!(
escape_delimited("she said \"hi\"", ','),
"\"she said \"\"hi\"\"\""
);
assert_eq!(escape_delimited("line1\nline2", ','), "\"line1\nline2\"");
}
struct Row {
name: &'static str,
n: u32,
}
impl Tabular for Row {
fn headers() -> &'static [&'static str] {
&["NAME", "N"]
}
fn row(&self) -> Vec<String> {
vec![self.name.to_string(), self.n.to_string()]
}
}
#[test]
fn tabular_headers_and_row_shape() {
let r = Row { name: "rule", n: 3 };
assert_eq!(Row::headers(), &["NAME", "N"]);
assert_eq!(r.row(), vec!["rule".to_string(), "3".to_string()]);
}
#[test]
fn warn_invalid_global_output_keeps_recognized_values() {
let (f, c) = warn_invalid_global_output(Some("ndjson".into()), Some("always".into()));
assert_eq!(f.as_deref(), Some("ndjson"));
assert_eq!(c.as_deref(), Some("always"));
}
#[test]
fn warn_invalid_global_output_strips_unrecognized_format() {
let (f, c) = warn_invalid_global_output(Some("xml".into()), None);
assert!(f.is_none());
assert!(c.is_none());
}
#[test]
fn warn_invalid_global_output_strips_unrecognized_color() {
let (f, c) = warn_invalid_global_output(None, Some("rainbow".into()));
assert!(f.is_none());
assert!(c.is_none());
}
#[test]
fn warn_invalid_global_output_passes_through_none() {
let (f, c) = warn_invalid_global_output(None, None);
assert!(f.is_none());
assert!(c.is_none());
}
}