yandex-tracker-cli 2.0.1

Token-efficient Yandex Tracker CLI for humans and AI agents
Documentation
//! Colour and emphasis.
//!
//! Three rules hold here, and they are the reason this is a module rather than
//! scattered `\x1b[` literals:
//!
//! 1. **Styling never changes the data.** The same fields, in the same order,
//!    with the same words, whether or not anything is painted. Only the escape
//!    codes differ, so a pipe and a terminal disagree about nothing that matters.
//! 2. **Machine output is never styled.** Not stripped afterwards — never
//!    produced. Snapshot tests then pin the real bytes a caller receives.
//! 3. **Untrusted text is never given our chrome.** Descriptions and comments
//!    are dimmed and nothing more. Painting them like tool output would let an
//!    issue's text impersonate the tool talking, which is exactly the confusion
//!    the fence exists to prevent (ADR 1).

use anstyle::{AnsiColor, Color, Style};

/// The palette. Small on purpose: a listing that uses six colours communicates
/// less than one that uses two.
#[derive(Debug, Clone, Copy)]
pub struct Palette;

impl Palette {
    /// Identifiers a caller will type back: issue keys, queue keys, profile names.
    #[must_use]
    pub fn key() -> Style {
        Style::new().bold()
    }

    /// Field labels and other scaffolding.
    #[must_use]
    pub fn label() -> Style {
        Style::new().dimmed()
    }

    /// Something worked.
    #[must_use]
    pub fn ok() -> Style {
        Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)))
    }

    /// Something needs attention but is not broken.
    #[must_use]
    pub fn warn() -> Style {
        Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow)))
    }

    /// Something is broken.
    #[must_use]
    pub fn bad() -> Style {
        Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red)))
    }

    /// A link worth following.
    #[must_use]
    pub fn url() -> Style {
        Style::new()
            .fg_color(Some(Color::Ansi(AnsiColor::Cyan)))
            .underline()
    }

    /// A heading in a block of prose.
    #[must_use]
    pub fn heading() -> Style {
        Style::new().bold().underline()
    }

    /// Text somebody else wrote.
    #[must_use]
    pub fn untrusted() -> Style {
        Style::new().dimmed()
    }
}

/// Applies the palette, or does not.
#[derive(Debug, Clone, Copy)]
pub struct Painter {
    enabled: bool,
}

impl Painter {
    /// A painter that styles.
    #[must_use]
    pub fn colour() -> Self {
        Self { enabled: true }
    }

    /// A painter that leaves text exactly as it is.
    #[must_use]
    pub fn plain() -> Self {
        Self { enabled: false }
    }

    /// Style for a terminal, plain for anything else.
    #[must_use]
    pub fn for_stream(is_terminal: bool) -> Self {
        Self {
            enabled: is_terminal,
        }
    }

    /// Wrap `text` in `style`, or return it unchanged.
    #[must_use]
    pub fn paint(self, text: &str, style: Style) -> String {
        if !self.enabled {
            return text.to_owned();
        }
        format!("{style}{text}{style:#}")
    }

    /// A URL a terminal can open with a click.
    ///
    /// Terminals guess at bare URLs, and guess wrong once anything is painted
    /// next to them; an OSC 8 hyperlink says where the link is. The visible text
    /// is the URL itself either way, so nothing is hidden behind a label.
    #[must_use]
    pub fn link(self, url: &str) -> String {
        if !self.enabled {
            return url.to_owned();
        }
        format!(
            "\u{1b}]8;;{url}\u{1b}\\{}\u{1b}]8;;\u{1b}\\",
            self.paint(url, Palette::url())
        )
    }

    /// Pad to `width` **before** styling.
    ///
    /// Escape codes have no width but plenty of bytes, so padding a styled
    /// string with `{:<12}` misaligns every column after it.
    #[must_use]
    pub fn paint_padded(self, text: &str, width: usize, style: Style) -> String {
        let visible = text.chars().count();
        let padding = width.saturating_sub(visible);
        format!("{}{}", self.paint(text, style), " ".repeat(padding))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_plain_painter_changes_nothing() {
        assert_eq!(Painter::plain().paint("PROJ-1", Palette::key()), "PROJ-1");
    }

    #[test]
    fn a_colour_painter_wraps_and_resets() {
        let painted = Painter::colour().paint("PROJ-1", Palette::key());
        assert!(painted.starts_with('\u{1b}'));
        assert!(painted.contains("PROJ-1"));
        assert!(painted.ends_with("\u{1b}[0m"));
    }

    /// Columns must line up whether or not anything is painted: escape codes
    /// carry bytes but no width.
    #[test]
    fn padding_counts_visible_characters_only() {
        let plain = Painter::plain().paint_padded("PROJ-1", 12, Palette::key());
        let coloured = Painter::colour().paint_padded("PROJ-1", 12, Palette::key());

        assert_eq!(plain, "PROJ-1      ");
        assert!(coloured.ends_with("      "));
        assert_eq!(
            coloured.matches(' ').count(),
            plain.matches(' ').count(),
            "same visible width in both modes"
        );
    }

    /// A link in a pipe is the bare URL, and in a terminal still shows the URL.
    #[test]
    fn a_link_is_the_url_whether_or_not_it_is_clickable() {
        let url = "https://ya.ru/device";
        assert_eq!(Painter::plain().link(url), url);

        let linked = Painter::colour().link(url);
        assert!(linked.starts_with("\u{1b}]8;;https://ya.ru/device\u{1b}\\"));
        assert!(linked.ends_with("\u{1b}]8;;\u{1b}\\"));
        assert_eq!(linked.matches(url).count(), 2, "target and visible text");
    }

    #[test]
    fn text_longer_than_the_column_is_not_truncated_by_padding() {
        assert_eq!(
            Painter::plain().paint_padded("very-long-key", 4, Palette::key()),
            "very-long-key"
        );
    }
}