//! 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
}
}
pub fn status_tone(status: &str) -> Tone {
match status {
"success" => Tone::Good,
"failure" | "cancelled" | "timed_out" => Tone::Bad,
"queued" | "running" | "pending" | "blocked" => Tone::Busy,
_ => 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" => "⊘",
"running" => "▶",
"queued" | "pending" | "blocked" => "…",
"skipped" => "–",
_ => "?",
}
}
pub fn status_cell(status: &str, conclusion: Option<&str>) -> String {
let s = effective_status(status, conclusion);
paint(&format!("{} {s}", status_mark(s)), status_tone(s))
}
/// 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") | Some("blocked") | Some("pending") => 2,
Some("cancelled") => 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 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.
10.6 KiB · text
History
1294137