crates/gunnar-ui-web/src/fmt.rs
//! Turning gunnar's numbers into words.
//!
//! Deliberately free of `web-sys`: the clock arrives as a parameter, so every
//! function here is pure and runs under `cargo test` on the host. The console's
//! date arithmetic is the kind that is wrong for six months before anyone
//! notices, and a browser-only test would never be run.
/// A byte count, in the units an operator reads.
pub fn bytes(count: u64) -> String {
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
if count < 1024 {
return format!("{count} B");
}
let mut value = count as f64;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
format!("{value:.1} {}", UNITS[unit])
}
/// **Millicores, in the unit a person reads**: `4000` is `4.00 cores`.
///
/// gunnar sends CPU as thousandths of a core because that is what a cgroup
/// `cpu.max` quota is written in, and because a fractional plan (`0.5 vCPU`)
/// has no whole number to send. Nobody reads a box as having four thousand of
/// anything, so the division happens once, here, beside every other number the
/// console spells.
///
/// Two decimals and not one: the busy figure is a one-minute load average and
/// its interesting range on an idle appliance is the low hundredths — `0.01
/// cores` is a true and useful reading that `0.0 cores` would have erased.
pub fn cores(millicores: u64) -> String {
format!("{:.2} cores", millicores as f64 / 1000.0)
}
/// A count with thin spaces between thousands, so eight digits stay readable.
pub fn count(value: u64) -> String {
let digits = value.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i) % 3 == 0 {
out.push('\u{2009}');
}
out.push(c);
}
out
}
/// How long ago something happened, given the current time.
///
/// Both times are Unix milliseconds. A future timestamp reads as "just now"
/// rather than as a negative age: a clock a few seconds ahead is far more
/// likely than an event that has not happened.
pub fn since(then_unix_ms: i64, now_unix_ms: i64) -> String {
let seconds = (now_unix_ms - then_unix_ms) / 1000;
if seconds < 45 {
return "just now".to_owned();
}
let minutes = seconds / 60;
if minutes < 60 {
return format!("{minutes} min ago");
}
let hours = minutes / 60;
if hours < 24 {
return format!("{hours} h ago");
}
let days = hours / 24;
if days < 14 {
return format!("{days} d ago");
}
if days < 60 {
return format!("{} w ago", days / 7);
}
format!("{} mo ago", days / 30)
}
/// An uptime, in the largest two units that matter.
pub fn uptime(seconds: u64) -> String {
let days = seconds / 86_400;
let hours = (seconds % 86_400) / 3600;
let minutes = (seconds % 3600) / 60;
match (days, hours) {
(0, 0) => format!("{minutes} min"),
(0, _) => format!("{hours} h {minutes} min"),
_ => format!("{days} d {hours} h"),
}
}
/// The first twelve characters of an object id.
///
/// Long enough to be unambiguous in a repository an operator is looking at,
/// short enough that a table column does not scroll.
pub fn short_oid(oid: &str) -> String {
oid.chars().take(12).collect()
}
/// A reference name without its `refs/heads/` or `refs/tags/` prefix.
///
/// Anything else keeps its full name: an operator seeing `refs/notes/commits`
/// shortened to `commits` would have no way to tell what it was.
pub fn short_ref(name: &str) -> &str {
name.strip_prefix("refs/heads/")
.or_else(|| name.strip_prefix("refs/tags/"))
.unwrap_or(name)
}
/// The clock time of a Unix-millisecond stamp, in UTC.
///
/// Only hours and minutes: the feed is read in the moment, and the date is
/// carried by [`since`].
pub fn clock(unix_ms: i64) -> String {
let seconds = unix_ms.div_euclid(1000).rem_euclid(86_400);
format!("{:02}:{:02}", seconds / 3600, (seconds % 3600) / 60)
}
/// **The exact UTC time of a stamp, `YYYY-MM-DD HH:MM UTC`** — what a
/// "5 min ago" cell says on hover.
pub fn utc(unix_ms: i64) -> String {
format!("{} {} UTC", date(unix_ms), clock(unix_ms))
}
/// **The UTC calendar date of a Unix-millisecond stamp, `YYYY-MM-DD`.**
///
/// [`since`] answers *how long ago*, which is the right answer for a feed and
/// the wrong one for an axis: thirty bars labelled "3 d ago" through "30 d ago"
/// are unreadable, and they stop being true the moment the tab is left open
/// overnight. The activity chart needs the day itself.
///
/// # Why the arithmetic is written out
///
/// There is no date crate in this console and this function is why there does
/// not need to be one: `chrono` and `time` are both tens of kilobytes of wasm
/// for a calendar the whole console uses in one place, and the bundle is
/// against a hard ceiling (`.github/wasm-baseline`).
///
/// The algorithm is Howard Hinnant's `civil_from_days`, which is exact for
/// every date in the proleptic Gregorian calendar — leap years, century years,
/// and the 400-year rule — with no table and no branch per month. It works by
/// shifting the epoch to 1 March 0000 so that the leap day lands at the END of
/// the year, which is what makes the month arithmetic a single multiply.
///
/// `div_euclid`, not `/`: integer division rounds towards zero, so a pre-1970
/// stamp would floor to the day AFTER the one it is in and every date before
/// the epoch would be a day late. Not a real input on a server that did not
/// exist in 1969 — and exactly the kind of thing that is wrong for six months
/// before anybody notices, which is what this module's doc comment is about.
/// **A deadline**: the absolute date and how far away it is —
/// `2026-10-28 (in 30 days)`. Never "just now" for a date in the future
/// (owner, 2026-09-28: a purge 30 days out read "Purges: just now", because
/// [`since`] reads any future stamp as now). Past: `2026-09-01 (due)`.
pub fn deadline(at_unix_ms: i64, now_unix_ms: i64) -> String {
let left = at_unix_ms - now_unix_ms;
const HOUR: i64 = 3_600_000;
const DAY: i64 = 86_400_000;
let rel = if left <= 0 {
"due".to_owned()
} else if left < DAY {
let h = (left + HOUR - 1) / HOUR;
format!("in {h} hour{}", if h == 1 { "" } else { "s" })
} else {
let d = (left + DAY - 1) / DAY;
format!("in {d} day{}", if d == 1 { "" } else { "s" })
};
format!("{} ({rel})", date(at_unix_ms))
}
pub fn date(unix_ms: i64) -> String {
let days = unix_ms.div_euclid(86_400_000);
// 719 468 = days from 0000-03-01 to 1970-01-01.
let z = days + 719_468;
let era = z.div_euclid(146_097); // 146 097 days = 400 years, exactly.
let doe = z - era * 146_097; // day of era, [0, 146 096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365], from 1 March
let mp = (5 * doy + 2) / 153; // month, [0, 11], with March = 0
let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { year + 1 } else { year };
format!("{year:04}-{month:02}-{day:02}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byte_counts_change_unit_at_the_right_place() {
assert_eq!(bytes(0), "0 B");
assert_eq!(bytes(1023), "1023 B");
assert_eq!(bytes(1024), "1.0 KiB");
assert_eq!(bytes(1536), "1.5 KiB");
assert_eq!(bytes(1024 * 1024 * 3), "3.0 MiB");
}
#[test]
fn a_very_large_count_does_not_run_out_of_units() {
assert!(bytes(u64::MAX).ends_with("PiB"));
}
#[test]
fn counts_are_grouped_from_the_right() {
assert_eq!(count(7), "7");
assert_eq!(count(1234), "1\u{2009}234");
assert_eq!(count(1_234_567), "1\u{2009}234\u{2009}567");
}
#[test]
fn ages_read_in_the_largest_useful_unit() {
let now = 1_700_000_000_000;
assert_eq!(since(now - 10_000, now), "just now");
assert_eq!(since(now - 300_000, now), "5 min ago");
assert_eq!(since(now - 3 * 3_600_000, now), "3 h ago");
assert_eq!(since(now - 2 * 86_400_000, now), "2 d ago");
assert_eq!(since(now - 21 * 86_400_000, now), "3 w ago");
}
#[test]
fn a_clock_a_little_ahead_does_not_produce_a_negative_age() {
let now = 1_700_000_000_000;
assert_eq!(since(now + 5_000, now), "just now");
}
#[test]
fn uptime_drops_the_unit_below_the_one_that_matters() {
assert_eq!(uptime(90), "1 min");
assert_eq!(uptime(3 * 3600 + 120), "3 h 2 min");
assert_eq!(uptime(50 * 3600), "2 d 2 h");
}
#[test]
fn a_branch_loses_its_prefix_and_a_note_does_not() {
assert_eq!(short_ref("refs/heads/main"), "main");
assert_eq!(short_ref("refs/tags/v1.1.3"), "v1.1.3");
assert_eq!(
short_ref("refs/notes/commits"),
"refs/notes/commits",
"shortening this would hide what kind of reference it is"
);
assert_eq!(short_ref("HEAD"), "HEAD");
}
#[test]
fn an_object_id_is_shortened_but_stays_recognisable() {
assert_eq!(
short_oid("0123456789abcdef0123456789abcdef"),
"0123456789ab"
);
assert_eq!(short_oid("abc"), "abc");
}
#[test]
fn the_clock_reads_in_utc() {
// 1970-01-01T00:00:00Z plus 13h 45m.
assert_eq!(clock((13 * 3600 + 45 * 60) * 1000), "13:45");
assert_eq!(clock(0), "00:00");
}
/// ★ **The calendar, on the days that break a naive one.**
///
/// A date arithmetic is wrong for months before anybody notices, so this
/// checks the cases that separate a correct implementation from one that
/// works all year until it does not: the epoch, the first leap day the
/// algorithm's shifted year meets, a CENTURY year that is not a leap year
/// (1900) beside one that is (2000), the end of February in both, and a
/// stamp mid-day — which must give the same date as its own midnight,
/// because the axis is days and not hours.
///
/// The values are Howard Hinnant's `civil_from_days`, verified against a
/// real calendar day by day; a table of expectations copied from the code
/// would prove only that the code is the code.
#[test]
fn a_date_is_the_utc_calendar_day_and_the_leap_rules_are_right() {
const DAY: i64 = 86_400_000;
assert_eq!(date(0), "1970-01-01");
assert_eq!(date(DAY), "1970-01-02");
// 1970 was not a leap year: day 59 is 1 March, not 29 February.
assert_eq!(date(58 * DAY), "1970-02-28");
assert_eq!(date(59 * DAY), "1970-03-01");
// 1972 was. 29 February exists and 1 March is the day after it.
assert_eq!(date(789 * DAY), "1972-02-29");
assert_eq!(date(790 * DAY), "1972-03-01");
// **2000 is a leap year and 1900 is not** — the rule a "% 4" calendar
// gets right for a century and then gets wrong.
assert_eq!(date(11_016 * DAY), "2000-02-29");
assert_eq!(date(11_017 * DAY), "2000-03-01");
assert_eq!(date(-25_509 * DAY), "1900-02-28");
assert_eq!(
date(-25_508 * DAY),
"1900-03-01",
"1900 is divisible by 4 and is NOT a leap year; a calendar that gave it a 29 \
February would put every date before it one day out"
);
// The time of day does not move the date: an axis of days must not
// shift at 14:00.
assert_eq!(date(20_699 * DAY), "2026-09-03");
assert_eq!(date(20_699 * DAY + 14 * 3_600_000), "2026-09-03");
assert_eq!(date(20_699 * DAY + DAY - 1), "2026-09-03");
// Before the epoch, floored to the day it is IN. `/` would round
// towards zero here and make every pre-1970 date a day late.
assert_eq!(date(-1), "1969-12-31");
assert_eq!(date(-DAY), "1969-12-31");
}
}
/// **The clone line, from what the server said and who is looking.**
///
/// `git_url` is the operator's `GUNNAR_UI_GIT_URL` (`ssh://git.example.test:2222`
/// or an `https://` base); `user` is the session's name, empty for a visitor.
/// The result is a line that can be pasted: for SSH the user goes in front of
/// the host (`ssh://alice@git.example.test:2222/ns/name`), for HTTPS it does
/// not (a browser-style URL carries no user). `None` when the operator
/// configured no base — a placeholder host is a line nobody can paste.
pub fn clone_line(
git_url: Option<&str>,
user: &str,
namespace: &str,
name: &str,
) -> Option<String> {
let base = git_url?.trim_end_matches('/');
let path = format!("{namespace}/{name}");
if let Some(rest) = base.strip_prefix("ssh://") {
let host = rest.trim_start_matches('/');
if user.is_empty() {
Some(format!("ssh://{host}/{path}"))
} else {
Some(format!("ssh://{user}@{host}/{path}"))
}
} else {
Some(format!("{base}/{path}.git"))
}
}
#[cfg(test)]
mod clone_line_tests {
use super::clone_line;
#[test]
fn the_clone_line_carries_the_user_for_ssh_and_not_for_https() {
assert_eq!(
clone_line(Some("ssh://git.example.test:2222"), "alice", "team", "repo"),
Some("ssh://alice@git.example.test:2222/team/repo".to_owned())
);
assert_eq!(
clone_line(Some("ssh://git.example.test:2222/"), "", "team", "repo"),
Some("ssh://git.example.test:2222/team/repo".to_owned())
);
assert_eq!(
clone_line(Some("https://git.example.test"), "alice", "team", "repo"),
Some("https://git.example.test/team/repo.git".to_owned())
);
// No base: no line, never a placeholder host.
assert_eq!(clone_line(None, "alice", "team", "repo"), None);
}
}
#[cfg(test)]
mod deadline_tests {
use super::*;
/// ★ A purge 30 days out is a date and "in 30 days", never "just now".
#[test]
fn a_future_purge_is_a_date_and_a_count_of_days() {
let now = 1_790_000_000_000; // 2026-09-21 (a Monday)
let purge = now + 30 * 86_400_000 + 5_000;
let said = deadline(purge, now);
assert!(said.ends_with("(in 31 days)") || said.ends_with("(in 30 days)"), "{said}");
assert!(said.starts_with(&date(purge)), "{said}");
assert!(!said.contains("just now"));
assert!(deadline(now + 2 * 3_600_000, now).ends_with("(in 2 hours)"));
assert!(deadline(now - 1, now).ends_with("(due)"));
}
}