1use chrono::{DateTime, Local, NaiveDateTime};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4pub enum DateTimeType {
5 Naive(NaiveDateTime),
6 Local(DateTime<Local>),
7}
8
9impl DateTimeType {
10 /// Converts the [`DateTimeType`] to a [`NaiveDateTime`].
11 ///
12 /// If the [`DateTimeType`] is already a [`NaiveDateTime`], it will be returned as is.
13 /// If the [`DateTimeType`] is a [`DateTime<Local>`], it will be converted to a [`NaiveDateTime`].
14 pub fn to_naive(&self) -> NaiveDateTime {
15 match self {
16 DateTimeType::Naive(naive) => *naive,
17 DateTimeType::Local(local) => local.naive_local(),
18 }
19 }
20}
21
22pub struct FormatDistance {
23 date: DateTimeType,
24 base_date: DateTimeType,
25 include_seconds: bool,
26 add_suffix: bool,
27 hide_prefix: bool,
28}
29
30impl FormatDistance {
31 pub fn new(date: DateTimeType, base_date: DateTimeType) -> Self {
32 Self {
33 date,
34 base_date,
35 include_seconds: false,
36 add_suffix: false,
37 hide_prefix: false,
38 }
39 }
40
41 pub fn from_now(date: DateTimeType) -> Self {
42 Self::new(date, DateTimeType::Local(Local::now()))
43 }
44
45 pub fn to_string(self) -> String {
46 format_distance(
47 self.date,
48 self.base_date.to_naive(),
49 self.include_seconds,
50 self.add_suffix,
51 self.hide_prefix,
52 )
53 }
54
55 pub fn include_seconds(mut self, include_seconds: bool) -> Self {
56 self.include_seconds = include_seconds;
57 self
58 }
59
60 pub fn add_suffix(mut self, add_suffix: bool) -> Self {
61 self.add_suffix = add_suffix;
62 self
63 }
64
65 pub fn hide_prefix(mut self, hide_prefix: bool) -> Self {
66 self.hide_prefix = hide_prefix;
67 self
68 }
69}
70
71/// Calculates the distance in seconds between two [`NaiveDateTime`] objects.
72/// It returns a signed integer denoting the difference. If `date` is earlier than `base_date`, the returned value will be negative.
73///
74/// ## Arguments
75///
76/// * `date` - A [NaiveDateTime`] object representing the date of interest
77/// * `base_date` - A [NaiveDateTime`] object representing the base date against which the comparison is made
78fn distance_in_seconds(date: NaiveDateTime, base_date: NaiveDateTime) -> i64 {
79 let duration = date.signed_duration_since(base_date);
80 -duration.num_seconds()
81}
82
83/// Generates a string describing the time distance between two dates in a human-readable way.
84fn distance_string(
85 distance: i64,
86 include_seconds: bool,
87 add_suffix: bool,
88 hide_prefix: bool,
89) -> String {
90 let suffix = if distance < 0 { " from now" } else { " ago" };
91
92 let distance = distance.abs();
93
94 let minutes = distance / 60;
95 let hours = distance / 3_600;
96 let days = distance / 86_400;
97 let months = distance / 2_592_000;
98
99 let string = if distance < 5 && include_seconds {
100 if hide_prefix {
101 "5 seconds"
102 } else {
103 "less than 5 seconds"
104 }
105 .to_string()
106 } else if distance < 10 && include_seconds {
107 if hide_prefix {
108 "10 seconds"
109 } else {
110 "less than 10 seconds"
111 }
112 .to_string()
113 } else if distance < 20 && include_seconds {
114 if hide_prefix {
115 "20 seconds"
116 } else {
117 "less than 20 seconds"
118 }
119 .to_string()
120 } else if distance < 40 && include_seconds {
121 if hide_prefix {
122 "half a minute"
123 } else {
124 "half a minute"
125 }
126 .to_string()
127 } else if distance < 60 && include_seconds {
128 if hide_prefix {
129 "a minute"
130 } else {
131 "less than a minute"
132 }
133 .to_string()
134 } else if distance < 90 && include_seconds {
135 "1 minute".to_string()
136 } else if distance < 30 {
137 if hide_prefix {
138 "a minute"
139 } else {
140 "less than a minute"
141 }
142 .to_string()
143 } else if distance < 90 {
144 "1 minute".to_string()
145 } else if distance < 2_700 {
146 format!("{} minutes", minutes)
147 } else if distance < 5_400 {
148 if hide_prefix {
149 "1 hour"
150 } else {
151 "about 1 hour"
152 }
153 .to_string()
154 } else if distance < 86_400 {
155 if hide_prefix {
156 format!("{} hours", hours)
157 } else {
158 format!("about {} hours", hours)
159 }
160 .to_string()
161 } else if distance < 172_800 {
162 "1 day".to_string()
163 } else if distance < 2_592_000 {
164 format!("{} days", days)
165 } else if distance < 5_184_000 {
166 if hide_prefix {
167 "1 month"
168 } else {
169 "about 1 month"
170 }
171 .to_string()
172 } else if distance < 7_776_000 {
173 if hide_prefix {
174 "2 months"
175 } else {
176 "about 2 months"
177 }
178 .to_string()
179 } else if distance < 31_540_000 {
180 format!("{} months", months)
181 } else if distance < 39_425_000 {
182 if hide_prefix {
183 "1 year"
184 } else {
185 "about 1 year"
186 }
187 .to_string()
188 } else if distance < 55_195_000 {
189 if hide_prefix { "1 year" } else { "over 1 year" }.to_string()
190 } else if distance < 63_080_000 {
191 if hide_prefix {
192 "2 years"
193 } else {
194 "almost 2 years"
195 }
196 .to_string()
197 } else {
198 let years = distance / 31_536_000;
199 let remaining_months = (distance % 31_536_000) / 2_592_000;
200
201 if remaining_months < 3 {
202 if hide_prefix {
203 format!("{} years", years)
204 } else {
205 format!("about {} years", years)
206 }
207 .to_string()
208 } else if remaining_months < 9 {
209 if hide_prefix {
210 format!("{} years", years)
211 } else {
212 format!("over {} years", years)
213 }
214 .to_string()
215 } else {
216 if hide_prefix {
217 format!("{} years", years + 1)
218 } else {
219 format!("almost {} years", years + 1)
220 }
221 .to_string()
222 }
223 };
224
225 if add_suffix {
226 format!("{}{}", string, suffix)
227 } else {
228 string
229 }
230}
231
232/// Get the time difference between two dates into a relative human readable string.
233///
234/// For example, "less than a minute ago", "about 2 hours ago", "3 months from now", etc.
235///
236/// Use [`format_distance_from_now`] to compare a NaiveDateTime against now.
237pub fn format_distance(
238 date: DateTimeType,
239 base_date: NaiveDateTime,
240 include_seconds: bool,
241 add_suffix: bool,
242 hide_prefix: bool,
243) -> String {
244 let distance = distance_in_seconds(date.to_naive(), base_date);
245
246 distance_string(distance, include_seconds, add_suffix, hide_prefix)
247}
248
249/// Get the time difference between a date and now as relative human readable string.
250///
251/// For example, "less than a minute ago", "about 2 hours ago", "3 months from now", etc.
252pub fn format_distance_from_now(
253 datetime: DateTimeType,
254 include_seconds: bool,
255 add_suffix: bool,
256 hide_prefix: bool,
257) -> String {
258 let now = chrono::offset::Local::now().naive_local();
259
260 format_distance(datetime, now, include_seconds, add_suffix, hide_prefix)
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use chrono::NaiveDateTime;
267
268 #[test]
269 fn test_format_distance() {
270 let date = DateTimeType::Naive(
271 NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
272 );
273 let base_date = DateTimeType::Naive(
274 NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
275 );
276
277 assert_eq!(
278 "about 2 hours",
279 format_distance(date, base_date.to_naive(), false, false, false)
280 );
281 }
282
283 #[test]
284 fn test_format_distance_with_suffix() {
285 let date = DateTimeType::Naive(
286 NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
287 );
288 let base_date = DateTimeType::Naive(
289 NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
290 );
291
292 assert_eq!(
293 "about 2 hours from now",
294 format_distance(date, base_date.to_naive(), false, true, false)
295 );
296 }
297
298 #[test]
299 fn test_format_distance_from_hms() {
300 let date = DateTimeType::Naive(
301 NaiveDateTime::parse_from_str("1969-07-20T11:22:33Z", "%Y-%m-%dT%H:%M:%SZ")
302 .expect("Invalid NaiveDateTime for date"),
303 );
304 let base_date = DateTimeType::Naive(
305 NaiveDateTime::parse_from_str("2024-02-01T00:00:00Z", "%Y-%m-%dT%H:%M:%SZ")
306 .expect("Invalid NaiveDateTime for base_date"),
307 );
308
309 assert_eq!(
310 "over 54 years ago",
311 format_distance(date, base_date.to_naive(), false, true, false)
312 );
313 }
314
315 #[test]
316 fn test_format_distance_string() {
317 assert_eq!(
318 distance_string(3, false, false, false),
319 "less than a minute"
320 );
321 assert_eq!(
322 distance_string(7, false, false, false),
323 "less than a minute"
324 );
325 assert_eq!(
326 distance_string(13, false, false, false),
327 "less than a minute"
328 );
329 assert_eq!(
330 distance_string(21, false, false, false),
331 "less than a minute"
332 );
333 assert_eq!(distance_string(45, false, false, false), "1 minute");
334 assert_eq!(distance_string(61, false, false, false), "1 minute");
335 assert_eq!(distance_string(1920, false, false, false), "32 minutes");
336 assert_eq!(distance_string(3902, false, false, false), "about 1 hour");
337 assert_eq!(distance_string(18002, false, false, false), "about 5 hours");
338 assert_eq!(distance_string(86470, false, false, false), "1 day");
339 assert_eq!(distance_string(345880, false, false, false), "4 days");
340 assert_eq!(
341 distance_string(2764800, false, false, false),
342 "about 1 month"
343 );
344 assert_eq!(
345 distance_string(5184000, false, false, false),
346 "about 2 months"
347 );
348 assert_eq!(distance_string(10368000, false, false, false), "4 months");
349 assert_eq!(
350 distance_string(34694000, false, false, false),
351 "about 1 year"
352 );
353 assert_eq!(
354 distance_string(47310000, false, false, false),
355 "over 1 year"
356 );
357 assert_eq!(
358 distance_string(61503000, false, false, false),
359 "almost 2 years"
360 );
361 assert_eq!(
362 distance_string(160854000, false, false, false),
363 "about 5 years"
364 );
365 assert_eq!(
366 distance_string(236550000, false, false, false),
367 "over 7 years"
368 );
369 assert_eq!(
370 distance_string(249166000, false, false, false),
371 "almost 8 years"
372 );
373 }
374
375 #[test]
376 fn test_format_distance_string_include_seconds() {
377 assert_eq!(
378 distance_string(3, true, false, false),
379 "less than 5 seconds"
380 );
381 assert_eq!(
382 distance_string(7, true, false, false),
383 "less than 10 seconds"
384 );
385 assert_eq!(
386 distance_string(13, true, false, false),
387 "less than 20 seconds"
388 );
389 assert_eq!(distance_string(21, true, false, false), "half a minute");
390 assert_eq!(
391 distance_string(45, true, false, false),
392 "less than a minute"
393 );
394 assert_eq!(distance_string(61, true, false, false), "1 minute");
395 }
396}