use anyhow::Result;
use clap::Args;
use prettytable::{Row, Table};
use serde::Serialize;
use std::fmt::Display;
use std::io::IsTerminal;
use std::sync::OnceLock;
use crate::libs::formatter::{truncate_to_width, wrap_to_width};
#[derive(Debug, Args, Clone, Copy, Default)]
pub struct Format {
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Print,
Success,
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Style {
pub json: bool,
pub plain: bool,
pub colour_stdout: bool,
pub colour_stderr: bool,
}
static STYLE: OnceLock<Style> = OnceLock::new();
impl Style {
pub fn detect(json: bool, plain: bool) -> Style {
let env = |name: &str| std::env::var(name).ok();
let plain = plain || json;
Style {
json,
plain,
colour_stdout: colour(plain, std::io::stdout().is_terminal(), &env),
colour_stderr: colour(plain, std::io::stderr().is_terminal(), &env),
}
}
pub fn prefix(self, kind: Kind) -> &'static str {
match (self.plain, kind) {
(_, Kind::Print) => "",
(false, Kind::Success) => "✅ ",
(false, Kind::Info) => "ℹ️ ",
(false, Kind::Warning) => "⚠️ ",
(false, Kind::Error) => "❌ ",
(true, Kind::Success | Kind::Info) => "",
(true, Kind::Warning) => "warning: ",
(true, Kind::Error) => "error: ",
}
}
pub fn to_stderr(self, kind: Kind) -> bool {
self.json || matches!(kind, Kind::Warning | Kind::Error)
}
pub fn animate(self) -> bool {
!self.plain && std::io::stderr().is_terminal()
}
}
pub fn init(json: bool, plain: bool) -> Style {
let style = *STYLE.get_or_init(|| Style::detect(json, plain));
console::set_colors_enabled(style.colour_stdout);
console::set_colors_enabled_stderr(style.colour_stderr);
style
}
pub fn style() -> Style {
*STYLE.get_or_init(|| Style::detect(false, false))
}
pub fn colour(plain: bool, terminal: bool, env: &dyn Fn(&str) -> Option<String>) -> bool {
let set = |name: &str| env(name).filter(|value| !value.is_empty());
if plain || set("NO_COLOR").is_some() {
return false;
}
if set("CLICOLOR_FORCE").is_some_and(|value| value != "0") {
return true;
}
if set("CLICOLOR").is_some_and(|value| value == "0") {
return false;
}
terminal
}
pub fn clap_colour(args: &[String]) -> clap::ColorChoice {
let plain = args.iter().skip(1).take_while(|arg| *arg != "--").any(|arg| arg == "--plain");
if plain { clap::ColorChoice::Never } else { clap::ColorChoice::Auto }
}
pub fn emit(kind: Kind, message: &dyn Display, padded: bool) {
let style = style();
let text = format!("{}{}", style.prefix(kind), message);
let text = if padded && !style.plain { format!("\n{text}\n") } else { text };
if style.to_stderr(kind) {
eprintln!("{text}");
} else {
println!("{text}");
}
}
pub fn report_error(error: &anyhow::Error) {
eprintln!("{}{:#}", style().prefix(Kind::Error), error);
}
pub fn print_json(value: &impl Serialize) -> Result<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
pub fn print_table(titles: Row, mut table: Table) {
if !style().plain {
table.set_format(*prettytable::format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
table.set_titles(titles);
table.printstd();
return;
}
for line in plain_lines(&titles, &table) {
println!("{line}");
}
}
pub fn plain_lines(titles: &Row, table: &Table) -> Vec<String> {
let line = |row: &Row| {
row.iter()
.map(|cell| cell.get_content().replace(['\t', '\n', '\r'], " "))
.collect::<Vec<_>>()
.join("\t")
};
let rows = table.row_iter().filter(|row| row.iter().any(|cell| !cell.get_content().is_empty())).map(line);
std::iter::once(line(titles)).chain(rows).collect()
}
pub fn fit(text: &str, width: usize) -> String {
if style().plain { text.to_string() } else { truncate_to_width(text, width) }
}
pub fn wrap(text: &str, width: usize) -> String {
if style().plain {
text.split_whitespace().collect::<Vec<_>>().join(" ")
} else {
wrap_to_width(text, width)
}
}
pub fn quiet_on_a_closed_pipe() {
let report = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let message = info
.payload()
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| info.payload().downcast_ref::<&str>().copied())
.unwrap_or_default();
if is_a_closed_pipe(message) {
std::process::exit(0);
}
report(info);
}));
}
pub fn is_a_closed_pipe(message: &str) -> bool {
message.starts_with("failed printing to std") && (message.contains("Broken pipe") || message.contains("os error 232") || message.contains("os error 109"))
}
pub fn is_a_closed_pipe_error(error: &std::io::Error) -> bool {
error.kind() == std::io::ErrorKind::BrokenPipe || matches!(error.raw_os_error(), Some(232 | 109))
}
pub mod moment {
use chrono::{DateTime, Local, NaiveDateTime, Utc};
use serde::Serializer;
const FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
pub fn local<S: Serializer>(at: &NaiveDateTime, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(&at.format(FORMAT))
}
pub fn local_opt<S: Serializer>(at: &Option<NaiveDateTime>, serializer: S) -> Result<S::Ok, S::Error> {
match at {
Some(at) => local(at, serializer),
None => serializer.serialize_none(),
}
}
pub fn utc_opt<S: Serializer>(at: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error> {
match at {
Some(at) => local(&at.with_timezone(&Local).naive_local(), serializer),
None => serializer.serialize_none(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env_of(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
move |name| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| value.to_string())
}
#[test]
fn a_terminal_gets_colour_and_a_pipe_does_not() {
assert!(colour(false, true, &env_of(&[])));
assert!(!colour(false, false, &env_of(&[])));
}
#[test]
fn plain_and_no_color_beat_everything() {
assert!(!colour(true, true, &env_of(&[("CLICOLOR_FORCE", "1")])));
assert!(!colour(false, true, &env_of(&[("NO_COLOR", "1")])));
assert!(!colour(false, false, &env_of(&[("NO_COLOR", "1"), ("CLICOLOR_FORCE", "1")])));
}
#[test]
fn an_empty_no_color_is_not_set() {
assert!(colour(false, true, &env_of(&[("NO_COLOR", "")])));
}
#[test]
fn clicolor_force_paints_a_pipe_and_clicolor_zero_stops_a_terminal() {
assert!(colour(false, false, &env_of(&[("CLICOLOR_FORCE", "1")])));
assert!(!colour(false, false, &env_of(&[("CLICOLOR_FORCE", "0")])));
assert!(!colour(false, true, &env_of(&[("CLICOLOR", "0")])));
}
#[test]
fn plain_marks_are_words_and_diagnostics_go_to_stderr() {
let person = Style {
json: false,
plain: false,
colour_stdout: true,
colour_stderr: true,
};
let pipe = Style { plain: true, ..person };
let program = Style {
json: true,
plain: true,
..person
};
assert_eq!(person.prefix(Kind::Error), "❌ ");
assert_eq!(pipe.prefix(Kind::Error), "error: ");
assert_eq!(pipe.prefix(Kind::Warning), "warning: ");
assert_eq!(pipe.prefix(Kind::Success), "");
assert!(person.to_stderr(Kind::Warning) && person.to_stderr(Kind::Error));
assert!(!person.to_stderr(Kind::Info) && !pipe.to_stderr(Kind::Print));
assert!(program.to_stderr(Kind::Print), "JSON mode keeps stdout for the document");
}
#[test]
fn clap_sees_plain_only_before_a_double_dash() {
let args = |list: &[&str]| list.iter().map(|arg| arg.to_string()).collect::<Vec<_>>();
assert_eq!(clap_colour(&args(&["kasl", "inbox", "--plain"])), clap::ColorChoice::Never);
assert_eq!(clap_colour(&args(&["kasl", "task", "add", "--", "--plain"])), clap::ColorChoice::Auto);
assert_eq!(clap_colour(&args(&["kasl", "inbox"])), clap::ColorChoice::Auto);
}
#[test]
fn a_plain_table_is_tab_separated_with_its_header_and_no_separator_rows() {
let mut table = Table::new();
table.add_row(prettytable::row!["1", "two\twords"]);
table.add_empty_row();
table.add_row(prettytable::row!["TOTAL", "line\nbreak"]);
let titles = prettytable::row!["ID", "NAME"];
assert_eq!(plain_lines(&titles, &table), vec!["ID\tNAME", "1\ttwo words", "TOTAL\tline break"]);
}
#[test]
fn a_closed_pipe_is_recognised_on_every_platform() {
assert!(is_a_closed_pipe("failed printing to stdout: Broken pipe (os error 32)"));
assert!(is_a_closed_pipe("failed printing to stdout: The pipe is being closed. (os error 232)"));
assert!(!is_a_closed_pipe("index out of bounds"));
}
}