Skip to main content

semora_cli/
output.rs

1//! Printing helpers: short text by default, JSON with `--json`.
2
3use anstyle::{AnsiColor, Color, RgbColor, Style};
4use serde::Serialize;
5use time::OffsetDateTime;
6
7pub fn json<T: Serialize>(v: &T) {
8    println!("{}", serde_json::to_string_pretty(v).expect("serializable"));
9}
10
11pub fn warn(msg: impl AsRef<str>) {
12    let warn = err().warn;
13    anstream::eprintln!("{warn}warning:{warn:#} {}", msg.as_ref());
14}
15
16/// The line a failed command ends with.
17pub fn error(msg: impl AsRef<str>) {
18    let error = err().error;
19    anstream::eprintln!("{error}error:{error:#} {}", msg.as_ref());
20}
21
22// Colour in the terminal carries meaning only, and each use comes with a word, so the meaning
23// survives without colour. Ordinary text stays in the default foreground; green and blue are
24// not used. anstream drops every style when a stream is not a terminal or NO_COLOR is set, so
25// piped output and `--json` stay plain for a script or an agent.
26
27/// A link: underlined, in the default foreground.
28pub const LINK: Style = Style::new().underline();
29/// Secondary text, such as a line that only says the CLI is waiting.
30pub const DIM: Style = Style::new().dimmed();
31
32/// The styles of the words that carry a meaning. Each is bold.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Styles {
35    /// The brand, and the one thing to check or act on (a sign-in code).
36    pub brand: Style,
37    /// Something was done: "Pushed", "Signed in".
38    pub done: Style,
39    pub warn: Style,
40    pub error: Style,
41}
42
43/// How much colour the terminal takes, best first.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ColourLevel {
46    /// 24-bit colour: the brand's own colours, in the shades for a dark or a light terminal.
47    True {
48        light: bool,
49    },
50    /// The 16 ANSI colours, which follow the terminal's theme.
51    Ansi16,
52    Plain,
53}
54
55/// The level for a stream. `colour` is whether the stream takes colour at all (a terminal,
56/// and no `NO_COLOR`). `COLORTERM` of `truecolor` or `24bit` means 24-bit colour, the same
57/// test as `anstyle_query::truecolor`. The terminal counts as light only when `COLORFGBG`
58/// is set and its last field, the background, is 7 or 15.
59pub fn colour_level(colour: bool, colorterm: Option<&str>, colorfgbg: Option<&str>) -> ColourLevel {
60    if !colour {
61        return ColourLevel::Plain;
62    }
63    if !matches!(colorterm, Some("truecolor" | "24bit")) {
64        return ColourLevel::Ansi16;
65    }
66    let background = colorfgbg.and_then(|v| v.rsplit(';').next()).map(str::trim);
67    ColourLevel::True {
68        light: matches!(background, Some("7" | "15")),
69    }
70}
71
72/// The styles at a level: brand, done, warning, error.
73pub fn styles(level: ColourLevel) -> Styles {
74    let rgb = |r, g, b| Color::Rgb(RgbColor(r, g, b));
75    let [brand, done, warn, error] = match level {
76        ColourLevel::Plain => {
77            let plain = Style::new();
78            return Styles {
79                brand: plain,
80                done: plain,
81                warn: plain,
82                error: plain,
83            };
84        }
85        ColourLevel::Ansi16 => [
86            Color::Ansi(AnsiColor::Magenta),
87            Color::Ansi(AnsiColor::Cyan),
88            Color::Ansi(AnsiColor::Yellow),
89            Color::Ansi(AnsiColor::Red),
90        ],
91        // mulberry-300, verdigris-300, gold-300, rubric-300.
92        ColourLevel::True { light: false } => [
93            rgb(217, 139, 191),
94            rgb(111, 194, 184),
95            rgb(219, 174, 94),
96            rgb(254, 149, 127),
97        ],
98        // mulberry-700, verdigris-700, gold-700, rubric-600.
99        ColourLevel::True { light: true } => [
100            rgb(123, 45, 94),
101            rgb(31, 111, 106),
102            rgb(111, 79, 5),
103            rgb(179, 64, 42),
104        ],
105    };
106    let bold = |c| Style::new().bold().fg_color(Some(c));
107    Styles {
108        brand: bold(brand),
109        done: bold(done),
110        warn: bold(warn),
111        error: bold(error),
112    }
113}
114
115/// The brand's mark in the terminal: the asterism, three notes left on a page.
116pub const GLYPH: &str = "\u{2042}";
117
118/// The banner line, `⁂ semora · Your projects, considered.`: the glyph in the brand colour,
119/// the name in the default foreground, the tagline in the done colour. Neither is bold.
120pub fn banner(styles: Styles) -> String {
121    let plain = |s: Style| Style::new().fg_color(s.get_fg_color());
122    let (brand, done) = (plain(styles.brand), plain(styles.done));
123    format!("{brand}{GLYPH}{brand:#} semora {DIM}·{DIM:#} {done}Your projects, considered.{done:#}")
124}
125
126fn styles_of(choice: anstream::ColorChoice) -> Styles {
127    let var = |name: &str| std::env::var(name).ok();
128    styles(colour_level(
129        choice != anstream::ColorChoice::Never,
130        var("COLORTERM").as_deref(),
131        var("COLORFGBG").as_deref(),
132    ))
133}
134
135/// The styles for what is printed on stdout.
136pub fn out() -> Styles {
137    styles_of(anstream::AutoStream::choice(&std::io::stdout()))
138}
139
140/// The styles for what is printed on stderr.
141pub fn err() -> Styles {
142    styles_of(anstream::AutoStream::choice(&std::io::stderr()))
143}
144
145/// `2026-09-25 14:03` in UTC.
146pub fn ts(t: OffsetDateTime) -> String {
147    let fmt = time::macros::format_description!("[year]-[month]-[day] [hour]:[minute]");
148    t.format(&fmt).unwrap_or_default()
149}
150
151pub fn size(bytes: u64) -> String {
152    match bytes {
153        b if b >= 1024 * 1024 => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)),
154        b if b >= 1024 => format!("{:.1} KB", b as f64 / 1024.0),
155        b => format!("{b} B"),
156    }
157}
158
159/// A person's name for printing on one line: control characters removed, square brackets
160/// turned into round ones (so a name can't end an event header like `[comment #12 from Ada]`
161/// early), cut at 80 characters.
162pub fn clean_name(name: &str) -> String {
163    name.chars()
164        .filter(|c| !c.is_control())
165        .map(|c| match c {
166            '[' => '(',
167            ']' => ')',
168            c => c,
169        })
170        .take(80)
171        .collect::<String>()
172        .trim()
173        .to_string()
174}
175
176#[cfg(test)]
177mod colour_tests {
178    use anstyle::{AnsiColor, Color, RgbColor, Style};
179
180    use super::{ColourLevel, banner, colour_level, styles};
181
182    #[test]
183    fn the_banner_is_plain_text_without_colour_and_coloured_by_meaning_with_it() {
184        assert_eq!(
185            banner(styles(ColourLevel::Plain))
186                .replace("\x1b[2m", "")
187                .replace("\x1b[0m", ""),
188            "\u{2042} semora · Your projects, considered."
189        );
190        assert_eq!(
191            banner(styles(ColourLevel::Ansi16)),
192            "\x1b[35m\u{2042}\x1b[0m semora \x1b[2m·\x1b[0m \x1b[36mYour projects, considered.\x1b[0m"
193        );
194        assert_eq!(
195            banner(styles(ColourLevel::True { light: false })),
196            "\x1b[38;2;217;139;191m\u{2042}\x1b[0m semora \x1b[2m·\x1b[0m \x1b[38;2;111;194;184mYour projects, considered.\x1b[0m"
197        );
198    }
199
200    #[test]
201    fn colour_is_truecolour_then_sixteen_then_none() {
202        use ColourLevel::{Ansi16, Plain, True};
203        // No colour wins over everything the terminal says about itself.
204        assert_eq!(colour_level(false, Some("truecolor"), Some("0;15")), Plain);
205        assert_eq!(colour_level(false, None, None), Plain);
206        // Colour without a truecolour claim is 16 colours, light or not.
207        assert_eq!(colour_level(true, None, None), Ansi16);
208        assert_eq!(colour_level(true, Some(""), Some("0;15")), Ansi16);
209        assert_eq!(colour_level(true, Some("256"), None), Ansi16);
210        for claim in ["truecolor", "24bit"] {
211            assert_eq!(
212                colour_level(true, Some(claim), None),
213                True { light: false },
214                "dark unless the terminal says otherwise"
215            );
216        }
217    }
218
219    #[test]
220    fn a_terminal_is_light_only_when_colorfgbg_names_a_light_background() {
221        let light = |fgbg| {
222            colour_level(true, Some("truecolor"), fgbg) == (ColourLevel::True { light: true })
223        };
224        assert!(light(Some("0;15")));
225        assert!(light(Some("0;7")));
226        assert!(
227            light(Some("0;default;15")),
228            "rxvt's three fields: the last is the background"
229        );
230        assert!(
231            !light(Some("15;0")),
232            "a light foreground on a dark background"
233        );
234        assert!(!light(Some("0;8")));
235        assert!(!light(Some("")));
236        assert!(!light(Some("default;default")));
237        assert!(!light(None));
238    }
239
240    #[test]
241    fn each_level_has_the_brand_s_colours_in_bold() {
242        let bold = |c| Style::new().bold().fg_color(Some(c));
243        let rgb = |r, g, b| bold(Color::Rgb(RgbColor(r, g, b)));
244        let ansi = |c| bold(Color::Ansi(c));
245        let all = |level| {
246            let s = styles(level);
247            [s.brand, s.done, s.warn, s.error]
248        };
249        assert_eq!(all(ColourLevel::Plain), [Style::new(); 4]);
250        assert_eq!(
251            all(ColourLevel::Ansi16),
252            [
253                ansi(AnsiColor::Magenta),
254                ansi(AnsiColor::Cyan),
255                ansi(AnsiColor::Yellow),
256                ansi(AnsiColor::Red)
257            ]
258        );
259        assert_eq!(
260            all(ColourLevel::True { light: false }),
261            [
262                rgb(217, 139, 191),
263                rgb(111, 194, 184),
264                rgb(219, 174, 94),
265                rgb(254, 149, 127)
266            ]
267        );
268        assert_eq!(
269            all(ColourLevel::True { light: true }),
270            [
271                rgb(123, 45, 94),
272                rgb(31, 111, 106),
273                rgb(111, 79, 5),
274                rgb(179, 64, 42)
275            ]
276        );
277    }
278}