//! 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,
Warn,
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::Warn => "\x1b[1;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. A `warning` job
/// passed but wants a look — it failed under `continue-on-error`, or
/// reported `gitgud-conclusion: warning` — so it's amber, not red.
pub fn status_tone(status: &str) -> Tone {
match status {
"success" => Tone::Good,
"warning" => Tone::Warn,
"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" => "✔",
"warning" => "⚠",
"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" | "warning"
)
}
/// 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_eq!(status_tone("warning"), Tone::Warn);
assert_eq!(status_mark("warning"), "⚠");
assert_eq!(status_label("warning"), "warning");
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",
"warning",
] {
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…");
}
}
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
14.7 KiB · text
History
52481e7