steid

@jamesgill /

1//! Turning values into the words a page shows them as.
2//!
3//! Times, sizes, and the one form helper that reads a submitted field. Out of `browse`
4//! because six modules imported `ago` and `timestamp` from there — settings, tokens,
5//! webhooks, issues, pull requests and profiles, none of which browse anything.
6//!
7//! No date library. [`civil_from_days`] is the whole of the calendar arithmetic Steid
8//! needs, and it is thirty lines.
9
10use std::time::{SystemTime, UNIX_EPOCH};
11
12/// A file size, rounded for reading rather than for accounting.
13pub(super) fn size_of(bytes: u64) -> String {
14 const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
15
16 if bytes < 1024 {
17 return format!("{bytes} B");
18 }
19
20 let mut value = bytes as f64 / 1024.0;
21 let mut unit = UNITS[0];
22
23 for next in &UNITS[1..] {
24 if value < 1024.0 {
25 break;
26 }
27
28 value /= 1024.0;
29 unit = next;
30 }
31
32 format!("{value:.1} {unit}")
33}
34
35/// How long ago something happened, in words.
36///
37/// A commit's timestamp comes from whoever made it, so it can sit in the future — a
38/// skewed clock, or a rewritten history. That reads as "just now" rather than as a
39/// negative duration.
40pub(super) fn ago(time: SystemTime) -> String {
41 let Ok(elapsed) = SystemTime::now().duration_since(time) else {
42 return "just now".to_owned();
43 };
44
45 let seconds = elapsed.as_secs();
46
47 let (count, unit) = match seconds {
48 0..=59 => return "just now".to_owned(),
49 60..=3599 => (seconds / 60, "minute"),
50 3600..=86_399 => (seconds / 3600, "hour"),
51 86_400..=2_591_999 => (seconds / 86_400, "day"),
52 2_592_000..=31_535_999 => (seconds / 2_592_000, "month"),
53 _ => (seconds / 31_536_000, "year"),
54 };
55
56 if count == 1 {
57 format!("1 {unit} ago")
58 } else {
59 format!("{count} {unit}s ago")
60 }
61}
62
63/// The exact time, for the tooltip behind [`ago`].
64pub(super) fn timestamp(time: SystemTime) -> String {
65 let seconds = time
66 .duration_since(UNIX_EPOCH)
67 .map(|since| since.as_secs() as i64)
68 .unwrap_or(0);
69
70 let (year, month, day) = civil_from_days(seconds.div_euclid(86_400));
71 let rest = seconds.rem_euclid(86_400);
72
73 format!(
74 "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
75 rest / 3600,
76 (rest % 3600) / 60
77 )
78}
79
80/// Days since the epoch to a calendar date, by Howard Hinnant's `civil_from_days`.
81///
82/// Written out rather than taken as a dependency: this is the whole of the date
83/// handling Steid needs, and a date library is a large thing to add for one function.
84pub(super) fn civil_from_days(days: i64) -> (i64, u32, u32) {
85 // Shift the epoch to 0000-03-01, which puts the leap day at the end of the year.
86 let shifted = days + 719_468;
87 let era = shifted.div_euclid(146_097);
88 let day_of_era = shifted.rem_euclid(146_097);
89
90 let year_of_era =
91 (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
92 let year = year_of_era + era * 400;
93 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
94
95 let shifted_month = (5 * day_of_year + 2) / 153;
96 let day = (day_of_year - (153 * shifted_month + 2) / 5 + 1) as u32;
97 let month = if shifted_month < 10 {
98 shifted_month + 3
99 } else {
100 shifted_month - 9
101 } as u32;
102
103 (if month <= 2 { year + 1 } else { year }, month, day)
104}
105
106/// A submitted field, or `None` when it was left blank.
107///
108/// Blank input means unset, which is what the domain stores — and on an edit form it
109/// means "clear this", which is the same thing. Written out identically in the profile
110/// settings, the repository settings and the new-repository form before it landed here.
111pub(super) fn optional(value: &str) -> Option<String> {
112 Some(value.trim().to_owned()).filter(|value| !value.is_empty())
113}
114
115#[cfg(test)]
116mod tests {
117 use std::time::Duration;
118
119 use super::*;
120 #[test]
121 fn sizes_read_as_sizes() {
122 assert_eq!(size_of(0), "0 B");
123 assert_eq!(size_of(999), "999 B");
124 assert_eq!(size_of(1024), "1.0 KB");
125 assert_eq!(size_of(1_048_576), "1.0 MB");
126 assert_eq!(size_of(1_572_864), "1.5 MB");
127 }
128
129 #[test]
130 fn elapsed_time_reads_as_words() {
131 let now = SystemTime::now();
132 let since = |seconds| ago(now - Duration::from_secs(seconds));
133
134 assert_eq!(since(5), "just now");
135 assert_eq!(since(60), "1 minute ago");
136 assert_eq!(since(7200), "2 hours ago");
137 assert_eq!(since(86_400 * 3), "3 days ago");
138 assert_eq!(since(86_400 * 400), "1 year ago");
139 }
140
141 #[test]
142 fn a_commit_from_the_future_reads_as_now_rather_than_as_a_negative() {
143 // A commit carries whoever made it's clock, so this happens.
144 assert_eq!(
145 ago(SystemTime::now() + Duration::from_secs(3600)),
146 "just now"
147 );
148 }
149
150 #[test]
151 fn timestamps_are_utc_calendar_dates() {
152 assert_eq!(
153 timestamp(UNIX_EPOCH + Duration::from_secs(0)),
154 "1970-01-01 00:00 UTC"
155 );
156 // 2026-08-29T12:34:00Z
157 assert_eq!(
158 timestamp(UNIX_EPOCH + Duration::from_secs(1_788_006_840)),
159 "2026-08-29 12:34 UTC"
160 );
161 // A leap day, which is what the calendar arithmetic exists to get right.
162 assert_eq!(
163 timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)),
164 "2024-02-29 00:00 UTC"
165 );
166 }
167}