semora-cli 1.0.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! Printing helpers: short text by default, JSON with `--json`.

use anstyle::{AnsiColor, Color, RgbColor, Style};
use serde::Serialize;
use time::OffsetDateTime;

pub fn json<T: Serialize>(v: &T) {
    println!("{}", serde_json::to_string_pretty(v).expect("serializable"));
}

pub fn warn(msg: impl AsRef<str>) {
    let warn = err().warn;
    anstream::eprintln!("{warn}warning:{warn:#} {}", msg.as_ref());
}

/// The line a failed command ends with.
pub fn error(msg: impl AsRef<str>) {
    let error = err().error;
    anstream::eprintln!("{error}error:{error:#} {}", msg.as_ref());
}

// Colour in the terminal carries meaning only, and each use comes with a word, so the meaning
// survives without colour. Ordinary text stays in the default foreground; green and blue are
// not used. anstream drops every style when a stream is not a terminal or NO_COLOR is set, so
// piped output and `--json` stay plain for a script or an agent.

/// A link: underlined, in the default foreground.
pub const LINK: Style = Style::new().underline();
/// Secondary text, such as a line that only says the CLI is waiting.
pub const DIM: Style = Style::new().dimmed();

/// The styles of the words that carry a meaning. Each is bold.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Styles {
    /// The brand, and the one thing to check or act on (a sign-in code).
    pub brand: Style,
    /// Something was done: "Pushed", "Signed in".
    pub done: Style,
    pub warn: Style,
    pub error: Style,
}

/// How much colour the terminal takes, best first.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColourLevel {
    /// 24-bit colour: the brand's own colours, in the shades for a dark or a light terminal.
    True {
        light: bool,
    },
    /// The 16 ANSI colours, which follow the terminal's theme.
    Ansi16,
    Plain,
}

/// The level for a stream. `colour` is whether the stream takes colour at all (a terminal,
/// and no `NO_COLOR`). `COLORTERM` of `truecolor` or `24bit` means 24-bit colour, the same
/// test as `anstyle_query::truecolor`. The terminal counts as light only when `COLORFGBG`
/// is set and its last field, the background, is 7 or 15.
pub fn colour_level(colour: bool, colorterm: Option<&str>, colorfgbg: Option<&str>) -> ColourLevel {
    if !colour {
        return ColourLevel::Plain;
    }
    if !matches!(colorterm, Some("truecolor" | "24bit")) {
        return ColourLevel::Ansi16;
    }
    let background = colorfgbg.and_then(|v| v.rsplit(';').next()).map(str::trim);
    ColourLevel::True {
        light: matches!(background, Some("7" | "15")),
    }
}

/// The styles at a level: brand, done, warning, error.
pub fn styles(level: ColourLevel) -> Styles {
    let rgb = |r, g, b| Color::Rgb(RgbColor(r, g, b));
    let [brand, done, warn, error] = match level {
        ColourLevel::Plain => {
            let plain = Style::new();
            return Styles {
                brand: plain,
                done: plain,
                warn: plain,
                error: plain,
            };
        }
        ColourLevel::Ansi16 => [
            Color::Ansi(AnsiColor::Magenta),
            Color::Ansi(AnsiColor::Cyan),
            Color::Ansi(AnsiColor::Yellow),
            Color::Ansi(AnsiColor::Red),
        ],
        // mulberry-300, verdigris-300, gold-300, rubric-300.
        ColourLevel::True { light: false } => [
            rgb(217, 139, 191),
            rgb(111, 194, 184),
            rgb(219, 174, 94),
            rgb(254, 149, 127),
        ],
        // mulberry-700, verdigris-700, gold-700, rubric-600.
        ColourLevel::True { light: true } => [
            rgb(123, 45, 94),
            rgb(31, 111, 106),
            rgb(111, 79, 5),
            rgb(179, 64, 42),
        ],
    };
    let bold = |c| Style::new().bold().fg_color(Some(c));
    Styles {
        brand: bold(brand),
        done: bold(done),
        warn: bold(warn),
        error: bold(error),
    }
}

/// The brand's mark in the terminal: the asterism, three notes left on a page.
pub const GLYPH: &str = "\u{2042}";

/// The banner line, `⁂ semora · Your projects, considered.`: the glyph in the brand colour,
/// the name in the default foreground, the tagline in the done colour. Neither is bold.
pub fn banner(styles: Styles) -> String {
    let plain = |s: Style| Style::new().fg_color(s.get_fg_color());
    let (brand, done) = (plain(styles.brand), plain(styles.done));
    format!("{brand}{GLYPH}{brand:#} semora {DIM}·{DIM:#} {done}Your projects, considered.{done:#}")
}

fn styles_of(choice: anstream::ColorChoice) -> Styles {
    let var = |name: &str| std::env::var(name).ok();
    styles(colour_level(
        choice != anstream::ColorChoice::Never,
        var("COLORTERM").as_deref(),
        var("COLORFGBG").as_deref(),
    ))
}

/// The styles for what is printed on stdout.
pub fn out() -> Styles {
    styles_of(anstream::AutoStream::choice(&std::io::stdout()))
}

/// The styles for what is printed on stderr.
pub fn err() -> Styles {
    styles_of(anstream::AutoStream::choice(&std::io::stderr()))
}

/// `2026-09-25 14:03` in UTC.
pub fn ts(t: OffsetDateTime) -> String {
    let fmt = time::macros::format_description!("[year]-[month]-[day] [hour]:[minute]");
    t.format(&fmt).unwrap_or_default()
}

pub fn size(bytes: u64) -> String {
    match bytes {
        b if b >= 1024 * 1024 => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)),
        b if b >= 1024 => format!("{:.1} KB", b as f64 / 1024.0),
        b => format!("{b} B"),
    }
}

/// A person's name for printing on one line: control characters removed, square brackets
/// turned into round ones (so a name can't end an event header like `[comment #12 from Ada]`
/// early), cut at 80 characters.
pub fn clean_name(name: &str) -> String {
    name.chars()
        .filter(|c| !c.is_control())
        .map(|c| match c {
            '[' => '(',
            ']' => ')',
            c => c,
        })
        .take(80)
        .collect::<String>()
        .trim()
        .to_string()
}

#[cfg(test)]
mod colour_tests {
    use anstyle::{AnsiColor, Color, RgbColor, Style};

    use super::{ColourLevel, banner, colour_level, styles};

    #[test]
    fn the_banner_is_plain_text_without_colour_and_coloured_by_meaning_with_it() {
        assert_eq!(
            banner(styles(ColourLevel::Plain))
                .replace("\x1b[2m", "")
                .replace("\x1b[0m", ""),
            "\u{2042} semora · Your projects, considered."
        );
        assert_eq!(
            banner(styles(ColourLevel::Ansi16)),
            "\x1b[35m\u{2042}\x1b[0m semora \x1b[2m·\x1b[0m \x1b[36mYour projects, considered.\x1b[0m"
        );
        assert_eq!(
            banner(styles(ColourLevel::True { light: false })),
            "\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"
        );
    }

    #[test]
    fn colour_is_truecolour_then_sixteen_then_none() {
        use ColourLevel::{Ansi16, Plain, True};
        // No colour wins over everything the terminal says about itself.
        assert_eq!(colour_level(false, Some("truecolor"), Some("0;15")), Plain);
        assert_eq!(colour_level(false, None, None), Plain);
        // Colour without a truecolour claim is 16 colours, light or not.
        assert_eq!(colour_level(true, None, None), Ansi16);
        assert_eq!(colour_level(true, Some(""), Some("0;15")), Ansi16);
        assert_eq!(colour_level(true, Some("256"), None), Ansi16);
        for claim in ["truecolor", "24bit"] {
            assert_eq!(
                colour_level(true, Some(claim), None),
                True { light: false },
                "dark unless the terminal says otherwise"
            );
        }
    }

    #[test]
    fn a_terminal_is_light_only_when_colorfgbg_names_a_light_background() {
        let light = |fgbg| {
            colour_level(true, Some("truecolor"), fgbg) == (ColourLevel::True { light: true })
        };
        assert!(light(Some("0;15")));
        assert!(light(Some("0;7")));
        assert!(
            light(Some("0;default;15")),
            "rxvt's three fields: the last is the background"
        );
        assert!(
            !light(Some("15;0")),
            "a light foreground on a dark background"
        );
        assert!(!light(Some("0;8")));
        assert!(!light(Some("")));
        assert!(!light(Some("default;default")));
        assert!(!light(None));
    }

    #[test]
    fn each_level_has_the_brand_s_colours_in_bold() {
        let bold = |c| Style::new().bold().fg_color(Some(c));
        let rgb = |r, g, b| bold(Color::Rgb(RgbColor(r, g, b)));
        let ansi = |c| bold(Color::Ansi(c));
        let all = |level| {
            let s = styles(level);
            [s.brand, s.done, s.warn, s.error]
        };
        assert_eq!(all(ColourLevel::Plain), [Style::new(); 4]);
        assert_eq!(
            all(ColourLevel::Ansi16),
            [
                ansi(AnsiColor::Magenta),
                ansi(AnsiColor::Cyan),
                ansi(AnsiColor::Yellow),
                ansi(AnsiColor::Red)
            ]
        );
        assert_eq!(
            all(ColourLevel::True { light: false }),
            [
                rgb(217, 139, 191),
                rgb(111, 194, 184),
                rgb(219, 174, 94),
                rgb(254, 149, 127)
            ]
        );
        assert_eq!(
            all(ColourLevel::True { light: true }),
            [
                rgb(123, 45, 94),
                rgb(31, 111, 106),
                rgb(111, 79, 5),
                rgb(179, 64, 42)
            ]
        );
    }
}