1use anyhow::Result;
25use clap::Args;
26use prettytable::{Row, Table};
27use serde::Serialize;
28use std::fmt::Display;
29use std::io::IsTerminal;
30use std::sync::OnceLock;
31
32use crate::libs::formatter::{truncate_to_width, wrap_to_width};
33
34#[derive(Debug, Args, Clone, Copy, Default)]
41pub struct Format {
42 #[arg(long)]
44 pub json: bool,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Kind {
50 Print,
52 Success,
53 Info,
54 Warning,
55 Error,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct Style {
61 pub json: bool,
63 pub plain: bool,
65 pub colour_stdout: bool,
67 pub colour_stderr: bool,
69}
70
71static STYLE: OnceLock<Style> = OnceLock::new();
72
73impl Style {
74 pub fn detect(json: bool, plain: bool) -> Style {
79 let env = |name: &str| std::env::var(name).ok();
80 let plain = plain || json;
81 Style {
82 json,
83 plain,
84 colour_stdout: colour(plain, std::io::stdout().is_terminal(), &env),
85 colour_stderr: colour(plain, std::io::stderr().is_terminal(), &env),
86 }
87 }
88
89 pub fn prefix(self, kind: Kind) -> &'static str {
91 match (self.plain, kind) {
92 (_, Kind::Print) => "",
93 (false, Kind::Success) => "✅ ",
94 (false, Kind::Info) => "ℹ️ ",
95 (false, Kind::Warning) => "⚠️ ",
96 (false, Kind::Error) => "❌ ",
97 (true, Kind::Success | Kind::Info) => "",
98 (true, Kind::Warning) => "warning: ",
99 (true, Kind::Error) => "error: ",
100 }
101 }
102
103 pub fn to_stderr(self, kind: Kind) -> bool {
105 self.json || matches!(kind, Kind::Warning | Kind::Error)
106 }
107
108 pub fn animate(self) -> bool {
110 !self.plain && std::io::stderr().is_terminal()
111 }
112}
113
114pub fn init(json: bool, plain: bool) -> Style {
120 let style = *STYLE.get_or_init(|| Style::detect(json, plain));
121 console::set_colors_enabled(style.colour_stdout);
122 console::set_colors_enabled_stderr(style.colour_stderr);
123 style
124}
125
126pub fn style() -> Style {
128 *STYLE.get_or_init(|| Style::detect(false, false))
129}
130
131pub fn colour(plain: bool, terminal: bool, env: &dyn Fn(&str) -> Option<String>) -> bool {
138 let set = |name: &str| env(name).filter(|value| !value.is_empty());
139 if plain || set("NO_COLOR").is_some() {
140 return false;
141 }
142 if set("CLICOLOR_FORCE").is_some_and(|value| value != "0") {
143 return true;
144 }
145 if set("CLICOLOR").is_some_and(|value| value == "0") {
146 return false;
147 }
148 terminal
149}
150
151pub fn clap_colour(args: &[String]) -> clap::ColorChoice {
158 let plain = args.iter().skip(1).take_while(|arg| *arg != "--").any(|arg| arg == "--plain");
159 if plain { clap::ColorChoice::Never } else { clap::ColorChoice::Auto }
160}
161
162pub fn emit(kind: Kind, message: &dyn Display, padded: bool) {
166 let style = style();
167 let text = format!("{}{}", style.prefix(kind), message);
168 let text = if padded && !style.plain { format!("\n{text}\n") } else { text };
170 if style.to_stderr(kind) {
171 eprintln!("{text}");
172 } else {
173 println!("{text}");
174 }
175}
176
177pub fn report_error(error: &anyhow::Error) {
181 eprintln!("{}{:#}", style().prefix(Kind::Error), error);
182}
183
184pub fn print_json(value: &impl Serialize) -> Result<()> {
186 println!("{}", serde_json::to_string_pretty(value)?);
187 Ok(())
188}
189
190pub fn print_table(titles: Row, mut table: Table) {
200 if !style().plain {
201 table.set_format(*prettytable::format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
202 table.set_titles(titles);
203 table.printstd();
204 return;
205 }
206 for line in plain_lines(&titles, &table) {
207 println!("{line}");
208 }
209}
210
211pub fn plain_lines(titles: &Row, table: &Table) -> Vec<String> {
213 let line = |row: &Row| {
214 row.iter()
215 .map(|cell| cell.get_content().replace(['\t', '\n', '\r'], " "))
216 .collect::<Vec<_>>()
217 .join("\t")
218 };
219 let rows = table.row_iter().filter(|row| row.iter().any(|cell| !cell.get_content().is_empty())).map(line);
220 std::iter::once(line(titles)).chain(rows).collect()
221}
222
223pub fn fit(text: &str, width: usize) -> String {
225 if style().plain { text.to_string() } else { truncate_to_width(text, width) }
226}
227
228pub fn wrap(text: &str, width: usize) -> String {
230 if style().plain {
231 text.split_whitespace().collect::<Vec<_>>().join(" ")
232 } else {
233 wrap_to_width(text, width)
234 }
235}
236
237pub fn quiet_on_a_closed_pipe() {
245 let report = std::panic::take_hook();
246 std::panic::set_hook(Box::new(move |info| {
247 let message = info
248 .payload()
249 .downcast_ref::<String>()
250 .map(String::as_str)
251 .or_else(|| info.payload().downcast_ref::<&str>().copied())
252 .unwrap_or_default();
253 if is_a_closed_pipe(message) {
254 std::process::exit(0);
255 }
256 report(info);
257 }));
258}
259
260pub fn is_a_closed_pipe(message: &str) -> bool {
262 message.starts_with("failed printing to std") && (message.contains("Broken pipe") || message.contains("os error 232") || message.contains("os error 109"))
263}
264
265pub fn is_a_closed_pipe_error(error: &std::io::Error) -> bool {
267 error.kind() == std::io::ErrorKind::BrokenPipe || matches!(error.raw_os_error(), Some(232 | 109))
269}
270
271pub mod moment {
277 use chrono::{DateTime, Local, NaiveDateTime, Utc};
278 use serde::Serializer;
279
280 const FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
281
282 pub fn local<S: Serializer>(at: &NaiveDateTime, serializer: S) -> Result<S::Ok, S::Error> {
284 serializer.collect_str(&at.format(FORMAT))
285 }
286
287 pub fn local_opt<S: Serializer>(at: &Option<NaiveDateTime>, serializer: S) -> Result<S::Ok, S::Error> {
289 match at {
290 Some(at) => local(at, serializer),
291 None => serializer.serialize_none(),
292 }
293 }
294
295 pub fn utc_opt<S: Serializer>(at: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error> {
297 match at {
298 Some(at) => local(&at.with_timezone(&Local).naive_local(), serializer),
299 None => serializer.serialize_none(),
300 }
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 fn env_of(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
309 move |name| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| value.to_string())
310 }
311
312 #[test]
313 fn a_terminal_gets_colour_and_a_pipe_does_not() {
314 assert!(colour(false, true, &env_of(&[])));
315 assert!(!colour(false, false, &env_of(&[])));
316 }
317
318 #[test]
319 fn plain_and_no_color_beat_everything() {
320 assert!(!colour(true, true, &env_of(&[("CLICOLOR_FORCE", "1")])));
321 assert!(!colour(false, true, &env_of(&[("NO_COLOR", "1")])));
322 assert!(!colour(false, false, &env_of(&[("NO_COLOR", "1"), ("CLICOLOR_FORCE", "1")])));
324 }
325
326 #[test]
327 fn an_empty_no_color_is_not_set() {
328 assert!(colour(false, true, &env_of(&[("NO_COLOR", "")])));
330 }
331
332 #[test]
333 fn clicolor_force_paints_a_pipe_and_clicolor_zero_stops_a_terminal() {
334 assert!(colour(false, false, &env_of(&[("CLICOLOR_FORCE", "1")])));
335 assert!(!colour(false, false, &env_of(&[("CLICOLOR_FORCE", "0")])));
336 assert!(!colour(false, true, &env_of(&[("CLICOLOR", "0")])));
337 }
338
339 #[test]
340 fn plain_marks_are_words_and_diagnostics_go_to_stderr() {
341 let person = Style {
342 json: false,
343 plain: false,
344 colour_stdout: true,
345 colour_stderr: true,
346 };
347 let pipe = Style { plain: true, ..person };
348 let program = Style {
349 json: true,
350 plain: true,
351 ..person
352 };
353
354 assert_eq!(person.prefix(Kind::Error), "❌ ");
355 assert_eq!(pipe.prefix(Kind::Error), "error: ");
356 assert_eq!(pipe.prefix(Kind::Warning), "warning: ");
357 assert_eq!(pipe.prefix(Kind::Success), "");
358
359 assert!(person.to_stderr(Kind::Warning) && person.to_stderr(Kind::Error));
360 assert!(!person.to_stderr(Kind::Info) && !pipe.to_stderr(Kind::Print));
361 assert!(program.to_stderr(Kind::Print), "JSON mode keeps stdout for the document");
362 }
363
364 #[test]
365 fn clap_sees_plain_only_before_a_double_dash() {
366 let args = |list: &[&str]| list.iter().map(|arg| arg.to_string()).collect::<Vec<_>>();
367 assert_eq!(clap_colour(&args(&["kasl", "inbox", "--plain"])), clap::ColorChoice::Never);
368 assert_eq!(clap_colour(&args(&["kasl", "task", "add", "--", "--plain"])), clap::ColorChoice::Auto);
369 assert_eq!(clap_colour(&args(&["kasl", "inbox"])), clap::ColorChoice::Auto);
370 }
371
372 #[test]
373 fn a_plain_table_is_tab_separated_with_its_header_and_no_separator_rows() {
374 let mut table = Table::new();
375 table.add_row(prettytable::row!["1", "two\twords"]);
376 table.add_empty_row();
377 table.add_row(prettytable::row!["TOTAL", "line\nbreak"]);
378 let titles = prettytable::row!["ID", "NAME"];
379 assert_eq!(plain_lines(&titles, &table), vec!["ID\tNAME", "1\ttwo words", "TOTAL\tline break"]);
380 }
381
382 #[test]
383 fn a_closed_pipe_is_recognised_on_every_platform() {
384 assert!(is_a_closed_pipe("failed printing to stdout: Broken pipe (os error 32)"));
385 assert!(is_a_closed_pipe("failed printing to stdout: The pipe is being closed. (os error 232)"));
386 assert!(!is_a_closed_pipe("index out of bounds"));
387 }
388}