14.2 KiB · text History d1406da
//! Terminal presentation: colour, status vocabulary, tables, times.
//!
//! Colour is ANSI written by hand rather than pulled in from a crate —
//! there are six of them and one rule (off unless stdout is a TTY and
//! `NO_COLOR` is unset), which is less code than configuring a library.

use crate::api::models::*;
use chrono::{DateTime, Utc};
use serde::Serialize;
use std::io::IsTerminal;
use std::sync::OnceLock;

// ── colour ───────────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tone {
    Good,
    Bad,
    Busy,
    Muted,
    Accent,
    Plain,
}

impl Tone {
    fn ansi(self) -> &'static str {
        match self {
            Tone::Good => "\x1b[32m",
            Tone::Bad => "\x1b[31m",
            Tone::Busy => "\x1b[33m",
            Tone::Muted => "\x1b[90m",
            Tone::Accent => "\x1b[36m",
            Tone::Plain => "",
        }
    }
}

fn color_enabled() -> bool {
    static ENABLED: OnceLock<bool> = OnceLock::new();
    *ENABLED
        .get_or_init(|| std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal())
}

pub fn paint(text: &str, tone: Tone) -> String {
    if tone == Tone::Plain || !color_enabled() {
        return text.to_string();
    }
    format!("{}{text}\x1b[0m", tone.ansi())
}

pub fn bold(text: &str) -> String {
    if color_enabled() {
        format!("\x1b[1m{text}\x1b[0m")
    } else {
        text.to_string()
    }
}

// ── CI status vocabulary ─────────────────────────────────────────────

/// `conclusion` once the thing has finished, else its raw `status`.
///
/// Status and conclusion are separate columns on the server: a run that
/// finished is `status: "success" | "failure"`, but a job carries
/// `status: "completed"` plus a `conclusion`. Callers pass whichever they
/// hold and let this collapse them.
pub fn effective_status<'a>(status: &'a str, conclusion: Option<&'a str>) -> &'a str {
    if status == "completed" || status == "finished" {
        conclusion.unwrap_or(status)
    } else {
        status
    }
}

/// Two names here aren't server statuses: `awaiting_approval` on a run
/// ([`Run::display_status`] — the server says `queued`) and `superseded`,
/// a job cancelled because a newer one in its concurrency group replaced
/// it ([`Job::display_status`]). Superseded is dim, not red: the newer
/// run decides, as its commit status (`cancelled`) says.
pub fn status_tone(status: &str) -> Tone {
    match status {
        "success" => Tone::Good,
        "failure" | "cancelled" | "timed_out" => Tone::Bad,
        "queued" | "running" | "pending" | "blocked" | "waiting" | "awaiting_approval" => {
            Tone::Busy
        }
        "skipped" | "superseded" => Tone::Muted,
        _ => Tone::Muted,
    }
}

/// A one-character stand-in for the app's status icon.
pub fn status_mark(status: &str) -> &'static str {
    match status {
        "success" => "✔",
        "failure" | "timed_out" => "✘",
        "cancelled" | "superseded" => "⊘",
        "running" => "▶",
        "queued" | "pending" | "blocked" | "waiting" => "…",
        "awaiting_approval" => "⚑",
        "skipped" => "–",
        _ => "?",
    }
}

/// A status as words: `awaiting_approval` reads "needs approval",
/// `timed_out` "timed out".
pub fn status_label(status: &str) -> String {
    match status {
        "awaiting_approval" => "needs approval".to_string(),
        other => other.replace('_', " "),
    }
}

/// Not finished: running, or not started yet for one reason or another —
/// blocked on its needs, waiting on a concurrency group or for approval.
/// Anything the server invents later that isn't a known ending counts as
/// unfinished too, so a watch keeps watching rather than calling it done.
pub fn finished(status: &str) -> bool {
    matches!(
        status,
        "success" | "failure" | "cancelled" | "skipped" | "timed_out" | "superseded"
    )
}

/// What started a run, as a person would say it. Unknown events — the
/// next one the server learns — fall through as their raw name with
/// underscores spaced, which is legible if not pretty.
pub fn trigger_label(trigger: &str) -> String {
    match trigger {
        "pull_request" => "pull request".to_string(),
        "pull_request_target" => "pull request (target)".to_string(),
        "workflow_dispatch" => "manual".to_string(),
        "schedule" => "scheduled".to_string(),
        "create" => "branch/tag created".to_string(),
        "delete" => "branch/tag deleted".to_string(),
        "workflow_run" => "after a workflow".to_string(),
        other => other.replace('_', " "),
    }
}

pub fn status_cell(status: &str, conclusion: Option<&str>) -> String {
    status_badge(effective_status(status, conclusion))
}

/// Mark and label in the status's colour, for an already-collapsed
/// status such as [`Run::display_status`].
pub fn status_badge(status: &str) -> String {
    paint(
        &format!("{} {}", status_mark(status), status_label(status)),
        status_tone(status),
    )
}

/// Issue / PR state, a different vocabulary from CI's — `open` is good
/// news here and `merged` is the happy ending.
pub fn state_cell(state: &str) -> String {
    let tone = match state {
        "open" => Tone::Good,
        "merged" => Tone::Accent,
        "closed" => Tone::Bad,
        _ => Tone::Muted,
    };
    paint(state, tone)
}

/// Worst news first, as the app's "CI status" repo sort does it.
///
/// Ordered by what wants a human, not by alphabet or severity in the
/// abstract: a failure is why you opened the client, something running is
/// worth watching, something queued is merely pending, and a cancelled run
/// was usually cancelled on purpose. Green and never-built sink.
pub fn ci_rank(status: Option<&str>) -> u8 {
    match status {
        Some("failure") | Some("timed_out") => 0,
        Some("running") => 1,
        Some("queued" | "blocked" | "waiting" | "awaiting_approval" | "pending") => 2,
        Some("cancelled" | "superseded") => 3,
        Some("success") => 4,
        _ => 5,
    }
}

// ── time ─────────────────────────────────────────────────────────────

/// "3m ago" for anything recent, the date once that stops being useful.
///
/// Timestamps are ISO-8601 UTC from one server with one formatter, so an
/// unparseable one is a bug rather than a locale — it prints raw instead
/// of vanishing, which is what makes it visible.
pub fn ago(raw: Option<&str>) -> String {
    let Some(raw) = raw else {
        return "—".to_string();
    };
    let Some(then) = parse_time(raw) else {
        return raw.to_string();
    };

    let secs = (Utc::now() - then).num_seconds();
    match secs {
        s if s < 0 => "just now".to_string(),
        s if s < 60 => format!("{s}s ago"),
        s if s < 3600 => format!("{}m ago", s / 60),
        s if s < 86_400 => format!("{}h ago", s / 3600),
        s if s < 86_400 * 30 => format!("{}d ago", s / 86_400),
        _ => then.format("%Y-%m-%d").to_string(),
    }
}

/// How long something took, from two timestamps.
pub fn duration(started: Option<&str>, completed: Option<&str>) -> Option<String> {
    let start = parse_time(started?)?;
    let end = completed.and_then(parse_time).unwrap_or_else(Utc::now);
    let secs = (end - start).num_seconds().max(0);
    Some(match secs {
        s if s < 60 => format!("{s}s"),
        s if s < 3600 => format!("{}m{:02}s", s / 60, s % 60),
        s => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
    })
}

fn parse_time(raw: &str) -> Option<DateTime<Utc>> {
    DateTime::parse_from_rfc3339(raw)
        .map(|t| t.with_timezone(&Utc))
        .ok()
        .or_else(|| {
            // The server also emits naive UTC ("2026-09-18T10:00:00"),
            // which RFC-3339 rejects for want of an offset.
            chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f")
                .ok()
                .map(|t| t.and_utc())
        })
}

pub fn bytes(n: i64) -> String {
    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
    let mut size = n as f64;
    let mut unit = 0;
    while size >= 1024.0 && unit < UNITS.len() - 1 {
        size /= 1024.0;
        unit += 1;
    }
    if unit == 0 {
        format!("{n} B")
    } else {
        format!("{size:.1} {}", UNITS[unit])
    }
}

// ── tables ───────────────────────────────────────────────────────────

/// A left-aligned table sized to its contents.
///
/// Widths count characters rather than bytes — a display-width crate
/// would be more correct for CJK and emoji, but the only wide characters
/// this prints are the status marks, which are all one column.
pub struct Table {
    headers: Vec<String>,
    rows: Vec<Vec<String>>,
}

impl Table {
    pub fn new<I: IntoIterator<Item = S>, S: Into<String>>(headers: I) -> Self {
        Self {
            headers: headers.into_iter().map(Into::into).collect(),
            rows: Vec::new(),
        }
    }

    pub fn row<I: IntoIterator<Item = S>, S: Into<String>>(&mut self, cells: I) {
        self.rows.push(cells.into_iter().map(Into::into).collect());
    }

    pub fn print(&self) {
        let cols = self.headers.len();
        let mut widths: Vec<usize> = self.headers.iter().map(|h| visible_len(h)).collect();
        for row in &self.rows {
            for (i, cell) in row.iter().enumerate().take(cols) {
                widths[i] = widths[i].max(visible_len(cell));
            }
        }

        println!("{}", bold(&join_padded(&self.headers, &widths)));
        for row in &self.rows {
            println!("{}", join_padded(row, &widths));
        }
    }
}

fn join_padded(cells: &[String], widths: &[usize]) -> String {
    let mut out = String::new();
    for (i, cell) in cells.iter().enumerate() {
        let last = i + 1 == cells.len();
        out.push_str(cell);
        if !last {
            let pad = widths[i].saturating_sub(visible_len(cell)) + 2;
            out.push_str(&" ".repeat(pad));
        }
    }
    out.trim_end().to_string()
}

/// Character count with ANSI escapes discounted, so a coloured cell pads
/// to the same width as a plain one.
fn visible_len(s: &str) -> usize {
    let mut len = 0;
    let mut in_escape = false;
    for c in s.chars() {
        if in_escape {
            in_escape = c != 'm';
        } else if c == '\x1b' {
            in_escape = true;
        } else {
            len += 1;
        }
    }
    len
}

pub fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    let kept: String = s.chars().take(max.saturating_sub(1)).collect();
    format!("{}…", kept.trim_end())
}

/// Markdown bodies print verbatim, indented, so a body is visibly a
/// quoted thing rather than part of the CLI's own output.
pub fn indented(body: &str, prefix: &str) {
    for line in body.lines() {
        if line.is_empty() {
            println!();
        } else {
            println!("{prefix}{line}");
        }
    }
}

pub fn heading(text: &str) {
    println!("{}", bold(text));
}

pub fn note(text: &str) {
    println!("{}", paint(text, Tone::Muted));
}

pub fn print_json<T: Serialize>(value: &T) -> anyhow::Result<()> {
    println!("{}", serde_json::to_string_pretty(value)?);
    Ok(())
}

pub fn labels(labels: &[Label]) -> String {
    labels
        .iter()
        .map(|l| l.name.as_str())
        .collect::<Vec<_>>()
        .join(", ")
}

pub fn author_of(user: Option<&ApiUser>) -> String {
    user.map(|u| u.label().to_string())
        .unwrap_or_else(|| "someone".to_string())
}

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

    #[test]
    fn collapses_completed_into_conclusion() {
        assert_eq!(effective_status("completed", Some("failure")), "failure");
        assert_eq!(effective_status("running", None), "running");
        assert_eq!(effective_status("completed", None), "completed");
    }

    #[test]
    fn visible_len_ignores_ansi() {
        assert_eq!(visible_len("\x1b[32mok\x1b[0m"), 2);
        assert_eq!(visible_len("ok"), 2);
    }

    #[test]
    fn ci_rank_puts_failures_first() {
        let mut order = vec![Some("success"), Some("failure"), Some("running"), None];
        order.sort_by_key(|s| ci_rank(*s));
        assert_eq!(
            order,
            vec![Some("failure"), Some("running"), Some("success"), None]
        );
    }

    #[test]
    fn newer_statuses_have_their_own_vocabulary() {
        assert_eq!(status_tone("waiting"), Tone::Busy);
        assert_eq!(status_tone("awaiting_approval"), Tone::Busy);
        assert_eq!(status_tone("superseded"), Tone::Muted);
        assert_eq!(status_tone("skipped"), Tone::Muted);
        assert_ne!(status_mark("awaiting_approval"), status_mark("queued"));
        assert_eq!(status_label("awaiting_approval"), "needs approval");
        assert_eq!(status_label("timed_out"), "timed out");
        assert_eq!(ci_rank(Some("awaiting_approval")), ci_rank(Some("queued")));
    }

    #[test]
    fn only_known_endings_are_finished() {
        for done in ["success", "failure", "cancelled", "skipped", "superseded"] {
            assert!(finished(done), "{done}");
        }
        for busy in [
            "queued",
            "running",
            "blocked",
            "waiting",
            "awaiting_approval",
            "new_thing",
        ] {
            assert!(!finished(busy), "{busy}");
        }
    }

    #[test]
    fn triggers_read_as_words() {
        assert_eq!(trigger_label("schedule"), "scheduled");
        assert_eq!(trigger_label("workflow_dispatch"), "manual");
        assert_eq!(
            trigger_label("pull_request_target"),
            "pull request (target)"
        );
        assert_eq!(trigger_label("workflow_run"), "after a workflow");
        assert_eq!(trigger_label("push"), "push");
        assert_eq!(trigger_label("merge_group"), "merge group");
    }

    #[test]
    fn truncates_with_ellipsis() {
        assert_eq!(truncate("hello", 10), "hello");
        assert_eq!(truncate("hello world", 8), "hello w…");
    }
}