Skip to main content

kasl/libs/
output.rs

1//! How kasl prints: for a person, for a log or a pipe, or for a program.
2//!
3//! Three readers, chosen once per run and read everywhere through [`style`]:
4//!
5//! - **A person at a terminal** - the default: framed tables cut to the
6//!   terminal's width, emoji marks on messages, colour where a terminal shows it.
7//! - **A log or a pipe** - `--plain`: the same lines without decoration. No
8//!   colour, no emoji (a warning says `warning:`), no blank padding, tables as
9//!   tab-separated lines with a header row, cells never cut or wrapped, no
10//!   spinner. For grep and for CI logs, where a cut-off cell is a lost fact.
11//! - **A program** - `--json` on a command that prints data: stdout carries one
12//!   JSON document and nothing else; every message goes to stderr, plain.
13//!
14//! Diagnostics - warnings and errors - go to stderr whoever reads, so a
15//! warning never lands between the rows a pipe is reading.
16//!
17//! Colour is one decision for the whole binary, made by [`colour`], and handed
18//! to the crates that paint (`console` for the spinner and the prompts,
19//! `tracing` for debug logs) instead of letting each decide: `console` ignores
20//! `NO_COLOR` on Windows and lets `CLICOLOR_FORCE` beat it, and `tracing`
21//! paints into a pipe. clap colours its own help and errors with the same
22//! precedence (see [`clap_colour`]).
23
24use 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/// `--json`, for a command that prints data.
35///
36/// Flattened into each reading command rather than declared once for all, so
37/// the flag appears only where it does something: a command with nothing to
38/// print as data refuses `--json` as an unknown argument instead of answering
39/// a script with text it cannot parse.
40#[derive(Debug, Args, Clone, Copy, Default)]
41pub struct Format {
42    /// Print JSON instead of text: one document on stdout, for scripts and status bars
43    #[arg(long)]
44    pub json: bool,
45}
46
47/// What a message is, which decides its mark and its stream.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Kind {
50    /// Output in its own right: a heading, a line of a listing.
51    Print,
52    Success,
53    Info,
54    Warning,
55    Error,
56}
57
58/// How this run prints; see the module header.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct Style {
61    /// Stdout carries one JSON document and nothing else.
62    pub json: bool,
63    /// No colour, no emoji, no frames, no truncation, no animation.
64    pub plain: bool,
65    /// Whether ANSI colour may be written to stdout.
66    pub colour_stdout: bool,
67    /// Whether ANSI colour may be written to stderr.
68    pub colour_stderr: bool,
69}
70
71static STYLE: OnceLock<Style> = OnceLock::new();
72
73impl Style {
74    /// The style for these flags, against this process's streams and environment.
75    ///
76    /// `--json` implies `--plain`: what still reaches stderr in JSON mode is
77    /// read by the same program that reads the document.
78    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    /// The mark a message of this kind opens with.
90    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    /// Whether a message of this kind goes to stderr.
104    pub fn to_stderr(self, kind: Kind) -> bool {
105        self.json || matches!(kind, Kind::Warning | Kind::Error)
106    }
107
108    /// Whether a spinner may draw: a person is watching, and asked for decoration.
109    pub fn animate(self) -> bool {
110        !self.plain && std::io::stderr().is_terminal()
111    }
112}
113
114/// Fixes this run's style; the first call wins, later ones return it unchanged.
115///
116/// Called once, right after the command line is parsed, before anything
117/// prints. Hands the colour decision to the `console` crate, which paints the
118/// spinner and the interactive prompts.
119pub 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
126/// This run's style; the default for a person when nothing set one (tests, the daemon).
127pub fn style() -> Style {
128    *STYLE.get_or_init(|| Style::detect(false, false))
129}
130
131/// Whether to write ANSI colour to a stream.
132///
133/// The order is anstream's, so kasl and clap's own help agree: `--plain`
134/// first, then a non-empty `NO_COLOR` (https://no-color.org), then
135/// `CLICOLOR_FORCE` other than `0`, then `CLICOLOR=0`, then whether the
136/// stream is a terminal.
137pub 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
151/// clap's colour for its own help and usage errors.
152///
153/// clap prints those before kasl has parsed anything, so `--plain` is looked
154/// for in the raw arguments (up to a `--`, after which nothing is a flag).
155/// Everything else - `NO_COLOR`, `CLICOLOR*`, the terminal - clap's anstream
156/// already decides in the same order as [`colour`].
157pub 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
162/// Prints one message: its mark, on its stream, padded with blank lines when asked.
163///
164/// What the `msg_*` macros call outside debug mode.
165pub fn emit(kind: Kind, message: &dyn Display, padded: bool) {
166    let style = style();
167    let text = format!("{}{}", style.prefix(kind), message);
168    // A blank line is decoration, and a log or a pipe reads it as an empty record.
169    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
177/// Prints the error a command failed with, the last thing a failed run says.
178///
179/// The whole chain on one line (`outer: cause: cause`), so it greps as one record.
180pub fn report_error(error: &anyhow::Error) {
181    eprintln!("{}{:#}", style().prefix(Kind::Error), error);
182}
183
184/// Prints `value` as the run's one JSON document on stdout.
185pub fn print_json(value: &impl Serialize) -> Result<()> {
186    println!("{}", serde_json::to_string_pretty(value)?);
187    Ok(())
188}
189
190/// Prints a table under its titles, framed for a person, or as tab-separated
191/// lines for `--plain`.
192///
193/// The titles come apart from the table because prettytable cannot hand them
194/// back once set. The plain form keeps them as a header row - a file of
195/// tab-separated lines that names its columns is one a spreadsheet or
196/// `column -t` reads as it is - and drops the empty rows tables use as visual
197/// separators. Tabs and line breaks inside a cell become spaces, so one row
198/// stays one line.
199pub 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
211/// The lines `--plain` prints for a table.
212pub 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
223/// `text` cut to `width` columns for a person; whole for `--plain`.
224pub fn fit(text: &str, width: usize) -> String {
225    if style().plain { text.to_string() } else { truncate_to_width(text, width) }
226}
227
228/// `text` wrapped to `width` columns for a person; on one line for `--plain`.
229pub 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
237/// Quiets the panic `println!` raises when the reader of stdout has gone.
238///
239/// `kasl inbox --plain | head -3` closes the pipe after three lines, and the
240/// next `println!` panics on it - "failed printing to stdout", os error 232
241/// on Windows, a broken pipe elsewhere - turning a command that did its work
242/// into code 101. git and coreutils go quiet and exit 0 there, and so does
243/// kasl: once, in the panic hook, rather than at every print.
244pub 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
260/// Whether a panic is the standard library failing to print into a closed pipe.
261pub 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
265/// Whether a write failed because the reader of the pipe has gone.
266pub fn is_a_closed_pipe_error(error: &std::io::Error) -> bool {
267    // 232 and 109 are Windows' "pipe is being closed" and "pipe has been ended".
268    error.kind() == std::io::ErrorKind::BrokenPipe || matches!(error.raw_os_error(), Some(232 | 109))
269}
270
271/// Serde helpers for the JSON documents: one spelling for every moment.
272///
273/// Moments are the machine's local time as `YYYY-MM-DDTHH:MM:SS` - whole
274/// seconds, no offset - which is how kasl stores them and what the readers on
275/// the same machine (a status bar, rigger, the MCP server) compare against.
276pub 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    /// A local moment.
283    pub fn local<S: Serializer>(at: &NaiveDateTime, serializer: S) -> Result<S::Ok, S::Error> {
284        serializer.collect_str(&at.format(FORMAT))
285    }
286
287    /// A local moment that may be absent.
288    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    /// A UTC moment, written in local time like every other.
296    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        // NO_COLOR over CLICOLOR_FORCE: anstream's order, so clap's help agrees.
323        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        // no-color.org: "when present and not an empty string".
329        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}