Skip to main content

runnerd_core/api/
units.rs

1//! How every client writes times and CPU use: durations, ages, CPU time, and cores in use. One rounding policy each,
2//! so the dashboard, the TUI, `runnerctl` and the daemon's own commands print the same figure for the same value.
3//! A limit (a cap, a reservation) is in CPUs to a tenth rounded up, by [`cpus`](super::cpus); use is in cores to a
4//! hundredth rounded up, here. Pure functions of their arguments; the caller passes the current time.
5
6/// Seconds as a stopwatch writes them, its width holding as it counts: `45s`, `1m 12s`, `2h 05m`, `3d 4h`.
7pub fn duration(seconds: u64) -> String {
8    match seconds {
9        0..60 => format!("{seconds}s"),
10        60..3600 => format!("{}m {:02}s", seconds / 60, seconds % 60),
11        3600..86_400 => format!("{}h {:02}m", seconds / 3600, seconds % 3600 / 60),
12        _ => format!("{}d {}h", seconds / 86_400, seconds % 86_400 / 3600),
13    }
14}
15
16/// How long ago `at` was: `just now` under ten seconds, then as [`duration`] writes it, `1h 05m ago`.
17pub fn ago(at: u64, now: u64) -> String {
18    match now.saturating_sub(at) {
19        0..10 => "just now".to_owned(),
20        seconds => format!("{} ago", duration(seconds)),
21    }
22}
23
24/// CPU time given in milliseconds: `4.6s` to a tenth under a minute, then as [`duration`] writes it.
25pub fn cpu_time(milliseconds: u64) -> String {
26    if milliseconds < 60_000 {
27        let tenths = (milliseconds + 50) / 100;
28        format!("{}.{}s", tenths / 10, tenths % 10)
29    } else {
30        duration(milliseconds / 1000)
31    }
32}
33
34/// Millicores in use as cores, to a hundredth rounded up so use never reads as none: `0.25`, `1.5`, `12`, `0.01`.
35pub fn cores_used(millicores: u64) -> String {
36    let hundredths = millicores.div_ceil(10);
37    let (whole, part) = (hundredths / 100, hundredths % 100);
38    if part == 0 {
39        whole.to_string()
40    } else if part.is_multiple_of(10) {
41        format!("{whole}.{}", part / 10)
42    } else {
43        format!("{whole}.{part:02}")
44    }
45}
46
47/// Millicores in use as [`cores_used`] writes them, with the unit: `1.5 cores`, `1 core`.
48pub fn in_cores(millicores: u64) -> String {
49    match cores_used(millicores).as_str() {
50        "1" => "1 core".to_owned(),
51        number => format!("{number} cores"),
52    }
53}
54
55/// Days from 1970-01-01 to a date of the proleptic Gregorian calendar, for years from 1970 (Howard Hinnant's
56/// `days_from_civil`).
57pub(crate) fn days_from_civil(year: u64, month: u64, day: u64) -> u64 {
58    let year = if month <= 2 { year - 1 } else { year };
59    let era = year / 400;
60    let year_of_era = year - era * 400;
61    let month_index = (month + 9) % 12;
62    let day_of_year = (153 * month_index + 2) / 5 + day - 1;
63    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
64    era * 146_097 + day_of_era - 719_468
65}
66
67/// The date of a day count since 1970-01-01, as `(year, month, day)` (Howard Hinnant's `civil_from_days`, for days
68/// after the epoch).
69pub(crate) fn civil_from_days(days: u64) -> (u64, u64, u64) {
70    let z = days + 719_468;
71    let era = z / 146_097;
72    let day_of_era = z - era * 146_097;
73    let year_of_era =
74        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
75    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
76    let month_index = (5 * day_of_year + 2) / 153;
77    let day = day_of_year - (153 * month_index + 2) / 5 + 1;
78    let month = if month_index < 10 {
79        month_index + 3
80    } else {
81        month_index - 9
82    };
83    (year_of_era + era * 400 + u64::from(month <= 2), month, day)
84}
85
86/// A UTC day as a short label: `Oct 3`.
87pub(crate) fn short_date(at: u64) -> String {
88    const MONTHS: [&str; 12] = [
89        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
90    ];
91    let (_, month, day) = civil_from_days(at / 86_400);
92    let name = usize::try_from(month.saturating_sub(1))
93        .ok()
94        .and_then(|index| MONTHS.get(index))
95        .unwrap_or(&"");
96    format!("{name} {day}")
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn a_duration_reads_like_a_stopwatch_in_every_client() {
105        assert_eq!(duration(9), "9s");
106        assert_eq!(duration(72), "1m 12s");
107        assert_eq!(duration(3909), "1h 05m");
108        assert_eq!(duration(3 * 86_400 + 4 * 3600), "3d 4h");
109    }
110
111    #[test]
112    fn an_age_is_the_duration_since() {
113        assert_eq!(ago(100, 105), "just now");
114        assert_eq!(ago(0, 3909), "1h 05m ago");
115        assert_eq!(
116            ago(200, 100),
117            "just now",
118            "a time ahead of the clock is now"
119        );
120    }
121
122    #[test]
123    fn cpu_time_keeps_work_under_a_second() {
124        assert_eq!(cpu_time(4600), "4.6s");
125        assert_eq!(cpu_time(600), "0.6s");
126        assert_eq!(cpu_time(125_000), "2m 05s");
127    }
128
129    #[test]
130    fn cores_in_use_never_read_as_none() {
131        assert_eq!(cores_used(1), "0.01");
132        assert_eq!(cores_used(250), "0.25");
133        assert_eq!(cores_used(1500), "1.5");
134        assert_eq!(cores_used(12_000), "12");
135        assert_eq!(cores_used(0), "0");
136        assert_eq!(in_cores(1000), "1 core");
137        assert_eq!(in_cores(1250), "1.25 cores");
138    }
139}