| 1 | import type { Io } from "./io.js"; |
| 2 | |
| 3 | export interface Colors { |
| 4 | enabled: boolean; |
| 5 | bold(s: string): string; |
| 6 | dim(s: string): string; |
| 7 | red(s: string): string; |
| 8 | green(s: string): string; |
| 9 | yellow(s: string): string; |
| 10 | cyan(s: string): string; |
| 11 | magenta(s: string): string; |
| 12 | } |
| 13 | |
| 14 | export function colors(io: Io): Colors { |
| 15 | const on = !!io.stdout.isTTY && io.env.NO_COLOR === undefined && io.env.TERM !== "dumb"; |
| 16 | const wrap = (open: number, close: number) => (s: string) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : s); |
| 17 | return { enabled: on, bold: wrap(1, 22), dim: wrap(2, 22), red: wrap(31, 39), green: wrap(32, 39), yellow: wrap(33, 39), cyan: wrap(36, 39), magenta: wrap(35, 39) }; |
| 18 | } |
| 19 | |
| 20 | const ANSI = /\x1b\[[0-9;]*m/g; |
| 21 | const width = (s: string) => [...s.replace(ANSI, "")].length; |
| 22 | |
| 23 | /** Left-aligned columns separated by two spaces; the last column is not padded. */ |
| 24 | export function table(rows: string[][]): string { |
| 25 | if (!rows.length) return ""; |
| 26 | const cols = Math.max(...rows.map((r) => r.length)); |
| 27 | const widths = Array.from({ length: cols }, (_, i) => Math.max(...rows.map((r) => width(r[i] ?? "")))); |
| 28 | return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell + " ".repeat(widths[i]! - width(cell)))).join(" ").trimEnd()).join("\n") + "\n"; |
| 29 | } |
| 30 | |
| 31 | export function json(io: Io, v: unknown): void { |
| 32 | io.stdout.write(JSON.stringify(v, null, 2) + "\n"); |
| 33 | } |
| 34 | |
| 35 | export function truncate(s: string | null | undefined, n: number): string { |
| 36 | const t = (s ?? "").replace(/\s+/g, " ").trim(); |
| 37 | return t.length > n ? `${t.slice(0, n - 1)}…` : t; |
| 38 | } |
| 39 | |
| 40 | export function ago(iso: string | null | undefined, now = Date.now()): string { |
| 41 | if (!iso) return ""; |
| 42 | const t = Date.parse(iso); |
| 43 | if (Number.isNaN(t)) return iso; |
| 44 | const s = Math.max(0, Math.round((now - t) / 1000)); |
| 45 | if (s < 60) return `${s}s ago`; |
| 46 | if (s < 3600) return `${Math.floor(s / 60)}m ago`; |
| 47 | if (s < 86400) return `${Math.floor(s / 3600)}h ago`; |
| 48 | if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`; |
| 49 | return iso.slice(0, 10); |
| 50 | } |
| 51 | |
| 52 | /** Colors a status/conclusion word. */ |
| 53 | export function statusText(c: Colors, status: string | null | undefined, conclusion?: string | null): string { |
| 54 | if (status && status !== "completed") return c.yellow(status.replace(/_/g, " ")); |
| 55 | const v = conclusion ?? status ?? "unknown"; |
| 56 | if (v === "success" || v === "merged" || v === "open" || v === "clean") return c.green(v); |
| 57 | if (v === "failure" || v === "timed_out" || v === "cancelled" || v === "action_required") return c.red(v.replace(/_/g, " ")); |
| 58 | if (v === "closed" || v === "skipped" || v === "neutral") return c.dim(v); |
| 59 | return c.yellow(v.replace(/_/g, " ")); |
| 60 | } |
| 61 | |
| 62 | export function symbol(c: Colors, status: string | null | undefined, conclusion?: string | null): string { |
| 63 | if (status && status !== "completed") return c.yellow("*"); |
| 64 | if (conclusion === "success") return c.green("✓"); |
| 65 | if (conclusion === "skipped" || conclusion === "neutral") return c.dim("-"); |
| 66 | return c.red("X"); |
| 67 | } |