1use chrono::{DateTime, Utc};
39
40const MINUTE: i64 = 60;
41const HOUR: i64 = 60 * MINUTE;
42const DAY: i64 = 24 * HOUR;
43const WEEK: i64 = 7 * DAY;
44const MONTH: i64 = 30 * DAY;
47const YEAR: i64 = 365 * DAY;
49
50const CHUNKS: &[(i64, &str)] = &[
51 (YEAR, "year"),
52 (MONTH, "month"),
53 (WEEK, "week"),
54 (DAY, "day"),
55 (HOUR, "hour"),
56 (MINUTE, "minute"),
57];
58
59fn format_unit(n: i64, unit: &str) -> String {
60 let plural = if n == 1 { "" } else { "s" };
61 format!("{n} {unit}{plural}")
62}
63
64fn build(seconds: i64, depth: usize) -> String {
65 if seconds <= 0 {
66 return "0 minutes".to_owned();
67 }
68 let depth = depth.clamp(1, CHUNKS.len());
69 let mut remaining = seconds;
70 let mut parts: Vec<String> = Vec::with_capacity(depth);
71 let mut started = false;
72 for (size, name) in CHUNKS {
73 if !started {
74 if remaining >= *size {
75 let n = remaining / size;
76 remaining -= n * size;
77 parts.push(format_unit(n, name));
78 started = true;
79 }
80 continue;
81 }
82 if parts.len() >= depth {
83 break;
84 }
85 if remaining >= *size {
86 let n = remaining / size;
87 remaining -= n * size;
88 parts.push(format_unit(n, name));
89 }
90 }
91 if parts.is_empty() {
92 return "0 minutes".to_owned();
94 }
95 parts.join(", ")
96}
97
98pub fn timesince(d: DateTime<Utc>, now: Option<DateTime<Utc>>, depth: usize) -> String {
105 let now = now.unwrap_or_else(Utc::now);
106 let delta = now.signed_duration_since(d).num_seconds();
107 build(delta, depth)
108}
109
110pub fn timeuntil(d: DateTime<Utc>, now: Option<DateTime<Utc>>, depth: usize) -> String {
116 let now = now.unwrap_or_else(Utc::now);
117 let delta = d.signed_duration_since(now).num_seconds();
118 build(delta, depth)
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use chrono::{Duration, TimeZone};
125
126 fn t(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> DateTime<Utc> {
127 Utc.with_ymd_and_hms(y, mo, d, h, mi, s).unwrap()
128 }
129
130 #[test]
131 fn timesince_basic_depth_two() {
132 let now = t(2026, 6, 5, 12, 0, 0);
133 let past = now - Duration::seconds(4 * DAY + 6 * HOUR);
134 assert_eq!(timesince(past, Some(now), 2), "4 days, 6 hours");
135 }
136
137 #[test]
138 fn timesince_depth_one_collapses() {
139 let now = t(2026, 6, 5, 12, 0, 0);
140 let past = now - Duration::seconds(4 * DAY + 6 * HOUR);
141 assert_eq!(timesince(past, Some(now), 1), "4 days");
142 }
143
144 #[test]
145 fn timesince_singular_pluralization() {
146 let now = t(2026, 6, 5, 12, 0, 0);
147 let past = now - Duration::seconds(DAY + HOUR);
148 assert_eq!(timesince(past, Some(now), 2), "1 day, 1 hour");
149 }
150
151 #[test]
152 fn timesince_skips_zero_intermediate_unit() {
153 let now = t(2026, 6, 5, 12, 0, 0);
156 let past = now - Duration::seconds(YEAR + 5 * HOUR);
157 assert_eq!(timesince(past, Some(now), 2), "1 year, 5 hours");
158 }
159
160 #[test]
161 fn timesince_future_returns_zero() {
162 let now = t(2026, 6, 5, 12, 0, 0);
163 let future = now + Duration::seconds(3 * DAY);
164 assert_eq!(timesince(future, Some(now), 2), "0 minutes");
165 }
166
167 #[test]
168 fn timesince_sub_minute_returns_zero() {
169 let now = t(2026, 6, 5, 12, 0, 0);
170 let past = now - Duration::seconds(30);
171 assert_eq!(timesince(past, Some(now), 2), "0 minutes");
172 }
173
174 #[test]
175 fn timeuntil_basic() {
176 let now = t(2026, 6, 5, 12, 0, 0);
177 let future = now + Duration::seconds(2 * HOUR + 30 * MINUTE);
178 assert_eq!(timeuntil(future, Some(now), 2), "2 hours, 30 minutes");
179 }
180
181 #[test]
182 fn timeuntil_past_returns_zero() {
183 let now = t(2026, 6, 5, 12, 0, 0);
184 let past = now - Duration::seconds(2 * HOUR);
185 assert_eq!(timeuntil(past, Some(now), 2), "0 minutes");
186 }
187
188 #[test]
189 fn timesince_depth_clamps_high() {
190 let now = t(2026, 6, 5, 12, 0, 0);
191 let past = now
192 - Duration::seconds(2 * YEAR + 3 * MONTH + 4 * WEEK + 5 * DAY + 6 * HOUR + 7 * MINUTE);
193 let s = timesince(past, Some(now), 99);
197 assert!(s.contains("year"));
198 assert!(s.contains("hour") || s.contains("minute"));
199 }
200
201 #[test]
202 fn timesince_depth_clamps_zero() {
203 let now = t(2026, 6, 5, 12, 0, 0);
205 let past = now - Duration::seconds(2 * DAY + 5 * HOUR);
206 assert_eq!(timesince(past, Some(now), 0), "2 days");
207 }
208
209 #[test]
210 fn timesince_weeks_bucket() {
211 let now = t(2026, 6, 5, 12, 0, 0);
212 let past = now - Duration::seconds(2 * WEEK + 3 * DAY);
213 assert_eq!(timesince(past, Some(now), 2), "2 weeks, 3 days");
214 }
215
216 #[test]
217 fn timesince_months_bucket() {
218 let now = t(2026, 6, 5, 12, 0, 0);
219 let past = now - Duration::seconds(2 * MONTH + 5 * DAY);
220 assert_eq!(timesince(past, Some(now), 2), "2 months, 5 days");
221 }
222
223 #[test]
224 fn timesince_now_none_uses_utc_now() {
225 let past = Utc::now() - Duration::seconds(2 * DAY + 5 * HOUR);
228 let s = timesince(past, None, 2);
229 assert!(s.contains("day"));
230 }
231}