| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | | use std::io::IsTerminal; |
| 5 | | use std::sync::OnceLock; |
| 6 | |
|
| 7 | | static PLAIN: OnceLock<bool> = OnceLock::new(); |
| 8 | |
|
| 9 | 53× | pub fn init(plain_flag: bool) { |
| 10 | 53× | let plain = plain_flag || std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) || !std::io::stdout().is_terminal(); |
| 11 | 53× | let _ = PLAIN.set(plain); |
| 12 | 53× | } |
| 13 | |
|
| 14 | 113× | fn plain() -> bool { |
| 15 | 113× | *PLAIN.get().unwrap_or(&true) |
| 16 | 113× | } |
| 17 | |
|
| 18 | 113× | fn paint(code: &str, s: &str) -> String { |
| 19 | 113× | if plain() { |
| 20 | 113× | s.to_string() |
| 21 | | } else { |
| 22 | 0× | format!("\x1b[{code}m{s}\x1b[0m") |
| 23 | | } |
| 24 | 113× | } |
| 25 | |
|
| 26 | 29× | pub fn ok(s: &str) -> String { |
| 27 | 29× | paint("32", s) |
| 28 | 29× | } |
| 29 | 8× | pub fn bad(s: &str) -> String { |
| 30 | 8× | paint("31", s) |
| 31 | 8× | } |
| 32 | 3× | pub fn warn(s: &str) -> String { |
| 33 | 3× | paint("35", s) |
| 34 | 3× | } |
| 35 | 61× | pub fn dim(s: &str) -> String { |
| 36 | 61× | paint("2", s) |
| 37 | 61× | } |
| 38 | 12× | pub fn strong(s: &str) -> String { |
| 39 | 12× | paint("1", s) |
| 40 | 12× | } |
| 41 | |
|
| 42 | 6× | pub fn kv(key: &str, value: &str) -> String { |
| 43 | 6× | format!("{} {value}", dim(&format!("{key:>16}"))) |
| 44 | 6× | } |
| 45 | |
|
| 46 | |
|
| 47 | 2× | pub fn status(s: &str) -> String { |
| 48 | 2× | match s { |
| 49 | 2× | "passed" | "success" => ok("✔"), |
| 50 | 2× | "failed" | "failure" | "error" => bad("✘"), |
| 51 | 2× | "running" | "pending" => warn("●"), |
| 52 | 2× | "queued" => dim("○"), |
| 53 | 0× | "skipped" | "neutral" => dim("–"), |
| 54 | 0× | "cancelled" => dim("⊘"), |
| 55 | 0× | _ => dim("?"), |
| 56 | | } |
| 57 | 2× | } |
| 58 | |
|
| 59 | |
|
| 60 | 10× | pub fn ago(ms: i64) -> String { |
| 61 | 10× | let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0); |
| 62 | 10× | let secs = (now - ms).max(0) / 1000; |
| 63 | 10× | let unit = |n: i64, w: &str| format!("{n} {w}{} ago", if n == 1 { "" } else { "s" }); |
| 64 | 10× | match secs { |
| 65 | 10× | 0..=44 => "just now".into(), |
| 66 | 0× | 45..=89 => "a minute ago".into(), |
| 67 | 0× | 90..=2_699 => unit((secs + 59) / 60, "minute"), |
| 68 | 0× | 2_700..=5_399 => "an hour ago".into(), |
| 69 | 0× | 5_400..=79_199 => unit((secs + 3_599) / 3_600, "hour"), |
| 70 | 0× | 79_200..=129_599 => "yesterday".into(), |
| 71 | 0× | _ => unit((secs + 86_399) / 86_400, "day"), |
| 72 | | } |
| 73 | 10× | } |
| 74 | |
|
| 75 | |
|
| 76 | 3× | pub fn date(ms: i64) -> String { |
| 77 | 3× | let secs = ms.div_euclid(1000); |
| 78 | 3× | let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); |
| 79 | | |
| 80 | 3× | let z = days + 719_468; |
| 81 | 3× | let era = z.div_euclid(146_097); |
| 82 | 3× | let doe = z - era * 146_097; |
| 83 | 3× | let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; |
| 84 | 3× | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
| 85 | 3× | let mp = (5 * doy + 2) / 153; |
| 86 | 3× | let d = doy - (153 * mp + 2) / 5 + 1; |
| 87 | 3× | let m = if mp < 10 { mp + 3 } else { mp - 9 }; |
| 88 | 3× | let y = yoe + era * 400 + i64::from(m <= 2); |
| 89 | 3× | format!("{y:04}-{m:02}-{d:02} {:02}:{:02}Z", rem / 3_600, rem % 3_600 / 60) |
| 90 | 3× | } |
| 91 | |
|
| 92 | | #[cfg(test)] |
| 93 | | mod date_tests { |
| 94 | | #[test] |
| 95 | 1× | fn dates_are_utc_civil_dates() { |
| 96 | 1× | assert_eq!(super::date(0), "1970-01-01 00:00Z"); |
| 97 | 1× | assert_eq!(super::date(1_792_229_400_000), "2026-10-17 09:30Z"); |
| 98 | 1× | assert_eq!(super::date(951_782_400_000), "2000-02-29 00:00Z"); |
| 99 | 1× | } |
| 100 | | } |