Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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)"));
    }
}