1use time::{OffsetDateTime, UtcOffset};
2
3/// The formatting style for a timestamp.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum TimestampFormat {
6 /// Formats the timestamp as an absolute time, e.g. "2021-12-31 3:00AM".
7 Absolute,
8 /// Formats the timestamp as an absolute time.
9 /// If the message is from today or yesterday the date will be replaced with "Today at x" or "Yesterday at x" respectively.
10 /// E.g. "Today at 12:00 PM", "Yesterday at 11:00 AM", "2021-12-31 3:00AM".
11 EnhancedAbsolute,
12 /// Formats the timestamp as an absolute time, using month name, day of month, year. e.g. "Feb. 24, 2024".
13 MediumAbsolute,
14 /// Formats the timestamp as a relative time, e.g. "just now", "1 minute ago", "2 hours ago", "2 months ago".
15 Relative,
16}
17
18/// Formats a timestamp, which respects the user's date and time preferences/custom format.
19pub fn format_localized_timestamp(
20 timestamp: OffsetDateTime,
21 reference: OffsetDateTime,
22 timezone: UtcOffset,
23 format: TimestampFormat,
24) -> String {
25 let timestamp_local = timestamp.to_offset(timezone);
26 let reference_local = reference.to_offset(timezone);
27 format_local_timestamp(timestamp_local, reference_local, format)
28}
29
30/// Formats a timestamp, which respects the user's date and time preferences/custom format.
31pub fn format_local_timestamp(
32 timestamp: OffsetDateTime,
33 reference: OffsetDateTime,
34 format: TimestampFormat,
35) -> String {
36 match format {
37 TimestampFormat::Absolute => format_absolute_timestamp(timestamp, reference, false),
38 TimestampFormat::EnhancedAbsolute => format_absolute_timestamp(timestamp, reference, true),
39 TimestampFormat::MediumAbsolute => format_absolute_timestamp_medium(timestamp, reference),
40 TimestampFormat::Relative => format_relative_time(timestamp, reference)
41 .unwrap_or_else(|| format_relative_date(timestamp, reference)),
42 }
43}
44
45/// Formats the date component of a timestamp
46pub fn format_date(
47 timestamp: OffsetDateTime,
48 reference: OffsetDateTime,
49 enhanced_formatting: bool,
50) -> String {
51 format_absolute_date(timestamp, reference, enhanced_formatting)
52}
53
54/// Formats the time component of a timestamp
55pub fn format_time(timestamp: OffsetDateTime) -> String {
56 format_absolute_time(timestamp)
57}
58
59/// Formats the date component of a timestamp in medium style
60pub fn format_date_medium(
61 timestamp: OffsetDateTime,
62 reference: OffsetDateTime,
63 enhanced_formatting: bool,
64) -> String {
65 format_absolute_date_medium(timestamp, reference, enhanced_formatting)
66}
67
68fn format_absolute_date(
69 timestamp: OffsetDateTime,
70 reference: OffsetDateTime,
71 #[allow(unused_variables)] enhanced_date_formatting: bool,
72) -> String {
73 #[cfg(target_os = "macos")]
74 {
75 if !enhanced_date_formatting {
76 return macos::format_date(×tamp);
77 }
78
79 let timestamp_date = timestamp.date();
80 let reference_date = reference.date();
81 if timestamp_date == reference_date {
82 "Today".to_string()
83 } else if reference_date.previous_day() == Some(timestamp_date) {
84 "Yesterday".to_string()
85 } else {
86 macos::format_date(×tamp)
87 }
88 }
89 #[cfg(not(target_os = "macos"))]
90 {
91 // todo(linux) respect user's date/time preferences
92 // todo(windows) respect user's date/time preferences
93 let current_locale = CURRENT_LOCALE
94 .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
95 format_timestamp_naive_date(
96 timestamp,
97 reference,
98 is_12_hour_time_by_locale(current_locale.as_str()),
99 )
100 }
101}
102
103fn format_absolute_time(timestamp: OffsetDateTime) -> String {
104 #[cfg(target_os = "macos")]
105 {
106 macos::format_time(×tamp)
107 }
108 #[cfg(not(target_os = "macos"))]
109 {
110 // todo(linux) respect user's date/time preferences
111 // todo(windows) respect user's date/time preferences
112 let current_locale = CURRENT_LOCALE
113 .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
114 format_timestamp_naive_time(
115 timestamp,
116 is_12_hour_time_by_locale(current_locale.as_str()),
117 )
118 }
119}
120
121fn format_absolute_timestamp(
122 timestamp: OffsetDateTime,
123 reference: OffsetDateTime,
124 #[allow(unused_variables)] enhanced_date_formatting: bool,
125) -> String {
126 #[cfg(target_os = "macos")]
127 {
128 if !enhanced_date_formatting {
129 return format!(
130 "{} {}",
131 format_absolute_date(timestamp, reference, enhanced_date_formatting),
132 format_absolute_time(timestamp)
133 );
134 }
135
136 let timestamp_date = timestamp.date();
137 let reference_date = reference.date();
138 if timestamp_date == reference_date {
139 format!("Today at {}", format_absolute_time(timestamp))
140 } else if reference_date.previous_day() == Some(timestamp_date) {
141 format!("Yesterday at {}", format_absolute_time(timestamp))
142 } else {
143 format!(
144 "{} {}",
145 format_absolute_date(timestamp, reference, enhanced_date_formatting),
146 format_absolute_time(timestamp)
147 )
148 }
149 }
150 #[cfg(not(target_os = "macos"))]
151 {
152 // todo(linux) respect user's date/time preferences
153 // todo(windows) respect user's date/time preferences
154 format_timestamp_fallback(timestamp, reference)
155 }
156}
157
158fn format_absolute_date_medium(
159 timestamp: OffsetDateTime,
160 reference: OffsetDateTime,
161 enhanced_formatting: bool,
162) -> String {
163 #[cfg(target_os = "macos")]
164 {
165 if !enhanced_formatting {
166 return macos::format_date_medium(×tamp);
167 }
168
169 let timestamp_date = timestamp.date();
170 let reference_date = reference.date();
171 if timestamp_date == reference_date {
172 "Today".to_string()
173 } else if reference_date.previous_day() == Some(timestamp_date) {
174 "Yesterday".to_string()
175 } else {
176 macos::format_date_medium(×tamp)
177 }
178 }
179 #[cfg(not(target_os = "macos"))]
180 {
181 // todo(linux) respect user's date/time preferences
182 // todo(windows) respect user's date/time preferences
183 let current_locale = CURRENT_LOCALE
184 .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
185 if !enhanced_formatting {
186 return format_timestamp_naive_date_medium(
187 timestamp,
188 is_12_hour_time_by_locale(current_locale.as_str()),
189 );
190 }
191
192 let timestamp_date = timestamp.date();
193 let reference_date = reference.date();
194 if timestamp_date == reference_date {
195 "Today".to_string()
196 } else if reference_date.previous_day() == Some(timestamp_date) {
197 "Yesterday".to_string()
198 } else {
199 format_timestamp_naive_date_medium(
200 timestamp,
201 is_12_hour_time_by_locale(current_locale.as_str()),
202 )
203 }
204 }
205}
206
207fn format_absolute_timestamp_medium(
208 timestamp: OffsetDateTime,
209 reference: OffsetDateTime,
210) -> String {
211 #[cfg(target_os = "macos")]
212 {
213 format_absolute_date_medium(timestamp, reference, false)
214 }
215 #[cfg(not(target_os = "macos"))]
216 {
217 // todo(linux) respect user's date/time preferences
218 // todo(windows) respect user's date/time preferences
219 format_timestamp_fallback(timestamp, reference)
220 }
221}
222
223fn format_relative_time(timestamp: OffsetDateTime, reference: OffsetDateTime) -> Option<String> {
224 let difference = reference - timestamp;
225 let minutes = difference.whole_minutes();
226 match minutes {
227 0 => Some("Just now".to_string()),
228 1 => Some("1 minute ago".to_string()),
229 2..=59 => Some(format!("{} minutes ago", minutes)),
230 _ => {
231 let hours = difference.whole_hours();
232 match hours {
233 1 => Some("1 hour ago".to_string()),
234 2..=23 => Some(format!("{} hours ago", hours)),
235 _ => None,
236 }
237 }
238 }
239}
240
241fn format_relative_date(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String {
242 let timestamp_date = timestamp.date();
243 let reference_date = reference.date();
244 let difference = reference_date - timestamp_date;
245 let days = difference.whole_days();
246 match days {
247 0 => "Today".to_string(),
248 1 => "Yesterday".to_string(),
249 2..=6 => format!("{} days ago", days),
250 _ => {
251 let weeks = difference.whole_weeks();
252 match weeks {
253 1 => "1 week ago".to_string(),
254 2..=4 => format!("{} weeks ago", weeks),
255 _ => {
256 let month_diff = calculate_month_difference(timestamp, reference);
257 match month_diff {
258 0..=1 => "1 month ago".to_string(),
259 2..=11 => format!("{} months ago", month_diff),
260 months => {
261 let years = months / 12;
262 match years {
263 1 => "1 year ago".to_string(),
264 _ => format!("{years} years ago"),
265 }
266 }
267 }
268 }
269 }
270 }
271 }
272}
273
274/// Calculates the difference in months between two timestamps.
275/// The reference timestamp should always be greater than the timestamp.
276fn calculate_month_difference(timestamp: OffsetDateTime, reference: OffsetDateTime) -> usize {
277 let timestamp_year = timestamp.year();
278 let reference_year = reference.year();
279 let timestamp_month: u8 = timestamp.month().into();
280 let reference_month: u8 = reference.month().into();
281
282 let month_diff = if reference_month >= timestamp_month {
283 reference_month as usize - timestamp_month as usize
284 } else {
285 12 - timestamp_month as usize + reference_month as usize
286 };
287
288 let year_diff = (reference_year - timestamp_year) as usize;
289 if year_diff == 0 {
290 reference_month as usize - timestamp_month as usize
291 } else if month_diff == 0 {
292 year_diff * 12
293 } else if timestamp_month > reference_month {
294 (year_diff - 1) * 12 + month_diff
295 } else {
296 year_diff * 12 + month_diff
297 }
298}
299
300/// Formats a timestamp, which is either in 12-hour or 24-hour time format.
301/// Note:
302/// This function does not respect the user's date and time preferences.
303/// This should only be used as a fallback mechanism when the OS time formatting fails.
304fn format_timestamp_naive_time(timestamp_local: OffsetDateTime, is_12_hour_time: bool) -> String {
305 let timestamp_local_hour = timestamp_local.hour();
306 let timestamp_local_minute = timestamp_local.minute();
307
308 let (hour, meridiem) = if is_12_hour_time {
309 let meridiem = if timestamp_local_hour >= 12 {
310 "PM"
311 } else {
312 "AM"
313 };
314
315 let hour_12 = match timestamp_local_hour {
316 0 => 12, // Midnight
317 13..=23 => timestamp_local_hour - 12, // PM hours
318 _ => timestamp_local_hour, // AM hours
319 };
320
321 (hour_12, Some(meridiem))
322 } else {
323 (timestamp_local_hour, None)
324 };
325
326 match meridiem {
327 Some(meridiem) => format!("{}:{:02} {}", hour, timestamp_local_minute, meridiem),
328 None => format!("{:02}:{:02}", hour, timestamp_local_minute),
329 }
330}
331
332#[cfg(not(target_os = "macos"))]
333fn format_timestamp_naive_date(
334 timestamp_local: OffsetDateTime,
335 reference_local: OffsetDateTime,
336 is_12_hour_time: bool,
337) -> String {
338 let reference_local_date = reference_local.date();
339 let timestamp_local_date = timestamp_local.date();
340
341 if timestamp_local_date == reference_local_date {
342 "Today".to_string()
343 } else if reference_local_date.previous_day() == Some(timestamp_local_date) {
344 "Yesterday".to_string()
345 } else {
346 match is_12_hour_time {
347 true => format!(
348 "{:02}/{:02}/{}",
349 timestamp_local_date.month() as u32,
350 timestamp_local_date.day(),
351 timestamp_local_date.year()
352 ),
353 false => format!(
354 "{:02}/{:02}/{}",
355 timestamp_local_date.day(),
356 timestamp_local_date.month() as u32,
357 timestamp_local_date.year()
358 ),
359 }
360 }
361}
362
363#[cfg(not(target_os = "macos"))]
364fn format_timestamp_naive_date_medium(
365 timestamp_local: OffsetDateTime,
366 is_12_hour_time: bool,
367) -> String {
368 let timestamp_local_date = timestamp_local.date();
369
370 match is_12_hour_time {
371 true => format!(
372 "{:02}/{:02}/{}",
373 timestamp_local_date.month() as u32,
374 timestamp_local_date.day(),
375 timestamp_local_date.year()
376 ),
377 false => format!(
378 "{:02}/{:02}/{}",
379 timestamp_local_date.day(),
380 timestamp_local_date.month() as u32,
381 timestamp_local_date.year()
382 ),
383 }
384}
385
386pub fn format_timestamp_naive(
387 timestamp_local: OffsetDateTime,
388 reference_local: OffsetDateTime,
389 is_12_hour_time: bool,
390) -> String {
391 let formatted_time = format_timestamp_naive_time(timestamp_local, is_12_hour_time);
392 let reference_local_date = reference_local.date();
393 let timestamp_local_date = timestamp_local.date();
394
395 if timestamp_local_date == reference_local_date {
396 format!("Today at {}", formatted_time)
397 } else if reference_local_date.previous_day() == Some(timestamp_local_date) {
398 format!("Yesterday at {}", formatted_time)
399 } else {
400 let formatted_date = match is_12_hour_time {
401 true => format!(
402 "{:02}/{:02}/{}",
403 timestamp_local_date.month() as u32,
404 timestamp_local_date.day(),
405 timestamp_local_date.year()
406 ),
407 false => format!(
408 "{:02}/{:02}/{}",
409 timestamp_local_date.day(),
410 timestamp_local_date.month() as u32,
411 timestamp_local_date.year()
412 ),
413 };
414 format!("{} {}", formatted_date, formatted_time)
415 }
416}
417
418#[cfg(not(target_os = "macos"))]
419static CURRENT_LOCALE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
420
421#[cfg(not(target_os = "macos"))]
422fn format_timestamp_fallback(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String {
423 let current_locale = CURRENT_LOCALE
424 .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
425
426 let is_12_hour_time = is_12_hour_time_by_locale(current_locale.as_str());
427 format_timestamp_naive(timestamp, reference, is_12_hour_time)
428}
429
430/// Returns `true` if the locale is recognized as a 12-hour time locale.
431#[cfg(not(target_os = "macos"))]
432fn is_12_hour_time_by_locale(locale: &str) -> bool {
433 [
434 "es-MX", "es-CO", "es-SV", "es-NI",
435 "es-HN", // Mexico, Colombia, El Salvador, Nicaragua, Honduras
436 "en-US", "en-CA", "en-AU", "en-NZ", // U.S, Canada, Australia, New Zealand
437 "ar-SA", "ar-EG", "ar-JO", // Saudi Arabia, Egypt, Jordan
438 "en-IN", "hi-IN", // India, Hindu
439 "en-PK", "ur-PK", // Pakistan, Urdu
440 "en-PH", "fil-PH", // Philippines, Filipino
441 "bn-BD", "ccp-BD", // Bangladesh, Chakma
442 "en-IE", "ga-IE", // Ireland, Irish
443 "en-MY", "ms-MY", // Malaysia, Malay
444 ]
445 .contains(&locale)
446}
447
448#[cfg(target_os = "macos")]
449mod macos {
450 use core_foundation::base::TCFType;
451 use core_foundation::date::CFAbsoluteTime;
452 use core_foundation::string::CFString;
453 use core_foundation_sys::date_formatter::CFDateFormatterCreateStringWithAbsoluteTime;
454 use core_foundation_sys::date_formatter::CFDateFormatterRef;
455 use core_foundation_sys::locale::CFLocaleRef;
456 use core_foundation_sys::{
457 base::kCFAllocatorDefault,
458 date_formatter::{
459 CFDateFormatterCreate, kCFDateFormatterMediumStyle, kCFDateFormatterNoStyle,
460 kCFDateFormatterShortStyle,
461 },
462 locale::CFLocaleCopyCurrent,
463 };
464
465 pub fn format_time(timestamp: &time::OffsetDateTime) -> String {
466 format_with_date_formatter(timestamp, TIME_FORMATTER.with(|f| *f))
467 }
468
469 pub fn format_date(timestamp: &time::OffsetDateTime) -> String {
470 format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))
471 }
472
473 pub fn format_date_medium(timestamp: &time::OffsetDateTime) -> String {
474 format_with_date_formatter(timestamp, MEDIUM_DATE_FORMATTER.with(|f| *f))
475 }
476
477 fn format_with_date_formatter(
478 timestamp: &time::OffsetDateTime,
479 fmt: CFDateFormatterRef,
480 ) -> String {
481 const UNIX_TO_CF_ABSOLUTE_TIME_OFFSET: i64 = 978307200;
482 // Convert timestamp to macOS absolute time
483 let timestamp_macos = timestamp.unix_timestamp() - UNIX_TO_CF_ABSOLUTE_TIME_OFFSET;
484 let cf_absolute_time = timestamp_macos as CFAbsoluteTime;
485 unsafe {
486 let s = CFDateFormatterCreateStringWithAbsoluteTime(
487 kCFAllocatorDefault,
488 fmt,
489 cf_absolute_time,
490 );
491 CFString::wrap_under_create_rule(s).to_string()
492 }
493 }
494
495 thread_local! {
496 static CURRENT_LOCALE: CFLocaleRef = unsafe { CFLocaleCopyCurrent() };
497 static TIME_FORMATTER: CFDateFormatterRef = unsafe {
498 CFDateFormatterCreate(
499 kCFAllocatorDefault,
500 CURRENT_LOCALE.with(|locale| *locale),
501 kCFDateFormatterNoStyle,
502 kCFDateFormatterShortStyle,
503 )
504 };
505 static DATE_FORMATTER: CFDateFormatterRef = unsafe {
506 CFDateFormatterCreate(
507 kCFAllocatorDefault,
508 CURRENT_LOCALE.with(|locale| *locale),
509 kCFDateFormatterShortStyle,
510 kCFDateFormatterNoStyle,
511 )
512 };
513
514 static MEDIUM_DATE_FORMATTER: CFDateFormatterRef = unsafe {
515 CFDateFormatterCreate(
516 kCFAllocatorDefault,
517 CURRENT_LOCALE.with(|locale| *locale),
518 kCFDateFormatterMediumStyle,
519 kCFDateFormatterNoStyle,
520 )
521 };
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn test_format_date() {
531 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
532
533 // Test with same date (today)
534 let timestamp_today = create_offset_datetime(1990, 4, 12, 9, 30, 0);
535 assert_eq!(format_date(timestamp_today, reference, true), "Today");
536
537 // Test with previous day (yesterday)
538 let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
539 assert_eq!(
540 format_date(timestamp_yesterday, reference, true),
541 "Yesterday"
542 );
543
544 // Test with other date
545 let timestamp_other = create_offset_datetime(1990, 4, 10, 9, 30, 0);
546 let result = format_date(timestamp_other, reference, true);
547 assert!(!result.is_empty());
548 assert_ne!(result, "Today");
549 assert_ne!(result, "Yesterday");
550 }
551
552 #[test]
553 fn test_format_time() {
554 let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
555
556 // We can't assert the exact output as it depends on the platform and locale
557 // But we can at least confirm it doesn't panic and returns a non-empty string
558 let result = format_time(timestamp);
559 assert!(!result.is_empty());
560 }
561
562 #[test]
563 fn test_format_date_medium() {
564 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
565 let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
566
567 // Test with enhanced formatting (today)
568 let result_enhanced = format_date_medium(timestamp, reference, true);
569 assert_eq!(result_enhanced, "Today");
570
571 // Test with standard formatting
572 let result_standard = format_date_medium(timestamp, reference, false);
573 assert!(!result_standard.is_empty());
574
575 // Test yesterday with enhanced formatting
576 let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
577 let result_yesterday = format_date_medium(timestamp_yesterday, reference, true);
578 assert_eq!(result_yesterday, "Yesterday");
579
580 // Test other date with enhanced formatting
581 let timestamp_other = create_offset_datetime(1990, 4, 10, 9, 30, 0);
582 let result_other = format_date_medium(timestamp_other, reference, true);
583 assert!(!result_other.is_empty());
584 assert_ne!(result_other, "Today");
585 assert_ne!(result_other, "Yesterday");
586 }
587
588 #[test]
589 fn test_format_absolute_time() {
590 let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
591
592 // We can't assert the exact output as it depends on the platform and locale
593 // But we can at least confirm it doesn't panic and returns a non-empty string
594 let result = format_absolute_time(timestamp);
595 assert!(!result.is_empty());
596 }
597
598 #[test]
599 fn test_format_absolute_date() {
600 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
601
602 // Test with same date (today)
603 let timestamp_today = create_offset_datetime(1990, 4, 12, 9, 30, 0);
604 assert_eq!(
605 format_absolute_date(timestamp_today, reference, true),
606 "Today"
607 );
608
609 // Test with previous day (yesterday)
610 let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
611 assert_eq!(
612 format_absolute_date(timestamp_yesterday, reference, true),
613 "Yesterday"
614 );
615
616 // Test with other date
617 let timestamp_other = create_offset_datetime(1990, 4, 10, 9, 30, 0);
618 let result = format_absolute_date(timestamp_other, reference, true);
619 assert!(!result.is_empty());
620 assert_ne!(result, "Today");
621 assert_ne!(result, "Yesterday");
622 }
623
624 #[test]
625 fn test_format_absolute_date_medium() {
626 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
627 let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
628
629 // Test with enhanced formatting (today)
630 let result_enhanced = format_absolute_date_medium(timestamp, reference, true);
631 assert_eq!(result_enhanced, "Today");
632
633 // Test with standard formatting
634 let result_standard = format_absolute_date_medium(timestamp, reference, false);
635 assert!(!result_standard.is_empty());
636
637 // Test yesterday with enhanced formatting
638 let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
639 let result_yesterday = format_absolute_date_medium(timestamp_yesterday, reference, true);
640 assert_eq!(result_yesterday, "Yesterday");
641 }
642
643 #[test]
644 fn test_format_timestamp_naive_time() {
645 let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
646 assert_eq!(format_timestamp_naive_time(timestamp, true), "9:30 AM");
647 assert_eq!(format_timestamp_naive_time(timestamp, false), "09:30");
648
649 let timestamp_pm = create_offset_datetime(1990, 4, 12, 15, 45, 0);
650 assert_eq!(format_timestamp_naive_time(timestamp_pm, true), "3:45 PM");
651 assert_eq!(format_timestamp_naive_time(timestamp_pm, false), "15:45");
652 }
653
654 #[test]
655 fn test_format_24_hour_time() {
656 let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
657 let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
658
659 assert_eq!(
660 format_timestamp_naive(timestamp, reference, false),
661 "Today at 15:30"
662 );
663 }
664
665 #[test]
666 fn test_format_today() {
667 let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
668 let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
669
670 assert_eq!(
671 format_timestamp_naive(timestamp, reference, true),
672 "Today at 3:30 PM"
673 );
674 }
675
676 #[test]
677 fn test_format_yesterday() {
678 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
679 let timestamp = create_offset_datetime(1990, 4, 11, 9, 0, 0);
680
681 assert_eq!(
682 format_timestamp_naive(timestamp, reference, true),
683 "Yesterday at 9:00 AM"
684 );
685 }
686
687 #[test]
688 fn test_format_yesterday_less_than_24_hours_ago() {
689 let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
690 let timestamp = create_offset_datetime(1990, 4, 11, 20, 0, 0);
691
692 assert_eq!(
693 format_timestamp_naive(timestamp, reference, true),
694 "Yesterday at 8:00 PM"
695 );
696 }
697
698 #[test]
699 fn test_format_yesterday_more_than_24_hours_ago() {
700 let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
701 let timestamp = create_offset_datetime(1990, 4, 11, 18, 0, 0);
702
703 assert_eq!(
704 format_timestamp_naive(timestamp, reference, true),
705 "Yesterday at 6:00 PM"
706 );
707 }
708
709 #[test]
710 fn test_format_yesterday_over_midnight() {
711 let reference = create_offset_datetime(1990, 4, 12, 0, 5, 0);
712 let timestamp = create_offset_datetime(1990, 4, 11, 23, 55, 0);
713
714 assert_eq!(
715 format_timestamp_naive(timestamp, reference, true),
716 "Yesterday at 11:55 PM"
717 );
718 }
719
720 #[test]
721 fn test_format_yesterday_over_month() {
722 let reference = create_offset_datetime(1990, 4, 2, 9, 0, 0);
723 let timestamp = create_offset_datetime(1990, 4, 1, 20, 0, 0);
724
725 assert_eq!(
726 format_timestamp_naive(timestamp, reference, true),
727 "Yesterday at 8:00 PM"
728 );
729 }
730
731 #[test]
732 fn test_format_before_yesterday() {
733 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
734 let timestamp = create_offset_datetime(1990, 4, 10, 20, 20, 0);
735
736 assert_eq!(
737 format_timestamp_naive(timestamp, reference, true),
738 "04/10/1990 8:20 PM"
739 );
740 }
741
742 #[test]
743 fn test_relative_format_minutes() {
744 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
745 let mut current_timestamp = reference;
746
747 let mut next_minute = || {
748 current_timestamp = if current_timestamp.minute() == 0 {
749 current_timestamp
750 .replace_hour(current_timestamp.hour() - 1)
751 .unwrap()
752 .replace_minute(59)
753 .unwrap()
754 } else {
755 current_timestamp
756 .replace_minute(current_timestamp.minute() - 1)
757 .unwrap()
758 };
759 current_timestamp
760 };
761
762 assert_eq!(
763 format_relative_time(reference, reference),
764 Some("Just now".to_string())
765 );
766
767 assert_eq!(
768 format_relative_time(next_minute(), reference),
769 Some("1 minute ago".to_string())
770 );
771
772 for i in 2..=59 {
773 assert_eq!(
774 format_relative_time(next_minute(), reference),
775 Some(format!("{} minutes ago", i))
776 );
777 }
778
779 assert_eq!(
780 format_relative_time(next_minute(), reference),
781 Some("1 hour ago".to_string())
782 );
783 }
784
785 #[test]
786 fn test_relative_format_hours() {
787 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
788 let mut current_timestamp = reference;
789
790 let mut next_hour = || {
791 current_timestamp = if current_timestamp.hour() == 0 {
792 let date = current_timestamp.date().previous_day().unwrap();
793 current_timestamp.replace_date(date)
794 } else {
795 current_timestamp
796 .replace_hour(current_timestamp.hour() - 1)
797 .unwrap()
798 };
799 current_timestamp
800 };
801
802 assert_eq!(
803 format_relative_time(next_hour(), reference),
804 Some("1 hour ago".to_string())
805 );
806
807 for i in 2..=23 {
808 assert_eq!(
809 format_relative_time(next_hour(), reference),
810 Some(format!("{} hours ago", i))
811 );
812 }
813
814 assert_eq!(format_relative_time(next_hour(), reference), None);
815 }
816
817 #[test]
818 fn test_relative_format_days() {
819 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
820 let mut current_timestamp = reference;
821
822 let mut next_day = || {
823 let date = current_timestamp.date().previous_day().unwrap();
824 current_timestamp = current_timestamp.replace_date(date);
825 current_timestamp
826 };
827
828 assert_eq!(
829 format_relative_date(reference, reference),
830 "Today".to_string()
831 );
832
833 assert_eq!(
834 format_relative_date(next_day(), reference),
835 "Yesterday".to_string()
836 );
837
838 for i in 2..=6 {
839 assert_eq!(
840 format_relative_date(next_day(), reference),
841 format!("{} days ago", i)
842 );
843 }
844
845 assert_eq!(format_relative_date(next_day(), reference), "1 week ago");
846 }
847
848 #[test]
849 fn test_relative_format_weeks() {
850 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
851 let mut current_timestamp = reference;
852
853 let mut next_week = || {
854 for _ in 0..7 {
855 let date = current_timestamp.date().previous_day().unwrap();
856 current_timestamp = current_timestamp.replace_date(date);
857 }
858 current_timestamp
859 };
860
861 assert_eq!(
862 format_relative_date(next_week(), reference),
863 "1 week ago".to_string()
864 );
865
866 for i in 2..=4 {
867 assert_eq!(
868 format_relative_date(next_week(), reference),
869 format!("{} weeks ago", i)
870 );
871 }
872
873 assert_eq!(format_relative_date(next_week(), reference), "1 month ago");
874 }
875
876 #[test]
877 fn test_relative_format_months() {
878 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
879 let mut current_timestamp = reference;
880
881 let mut next_month = || {
882 if current_timestamp.month() == time::Month::January {
883 current_timestamp = current_timestamp
884 .replace_month(time::Month::December)
885 .unwrap()
886 .replace_year(current_timestamp.year() - 1)
887 .unwrap();
888 } else {
889 current_timestamp = current_timestamp
890 .replace_month(current_timestamp.month().previous())
891 .unwrap();
892 }
893 current_timestamp
894 };
895
896 assert_eq!(
897 format_relative_date(next_month(), reference),
898 "4 weeks ago".to_string()
899 );
900
901 for i in 2..=11 {
902 assert_eq!(
903 format_relative_date(next_month(), reference),
904 format!("{} months ago", i)
905 );
906 }
907
908 assert_eq!(format_relative_date(next_month(), reference), "1 year ago");
909 }
910
911 #[test]
912 fn test_relative_format_years() {
913 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
914
915 // 12 months
916 assert_eq!(
917 format_relative_date(create_offset_datetime(1989, 4, 12, 23, 0, 0), reference),
918 "1 year ago"
919 );
920
921 // 13 months
922 assert_eq!(
923 format_relative_date(create_offset_datetime(1989, 3, 12, 23, 0, 0), reference),
924 "1 year ago"
925 );
926
927 // 23 months
928 assert_eq!(
929 format_relative_date(create_offset_datetime(1988, 5, 12, 23, 0, 0), reference),
930 "1 year ago"
931 );
932
933 // 24 months
934 assert_eq!(
935 format_relative_date(create_offset_datetime(1988, 4, 12, 23, 0, 0), reference),
936 "2 years ago"
937 );
938
939 // 25 months
940 assert_eq!(
941 format_relative_date(create_offset_datetime(1988, 3, 12, 23, 0, 0), reference),
942 "2 years ago"
943 );
944
945 // 35 months
946 assert_eq!(
947 format_relative_date(create_offset_datetime(1987, 5, 12, 23, 0, 0), reference),
948 "2 years ago"
949 );
950
951 // 36 months
952 assert_eq!(
953 format_relative_date(create_offset_datetime(1987, 4, 12, 23, 0, 0), reference),
954 "3 years ago"
955 );
956
957 // 37 months
958 assert_eq!(
959 format_relative_date(create_offset_datetime(1987, 3, 12, 23, 0, 0), reference),
960 "3 years ago"
961 );
962
963 // 120 months
964 assert_eq!(
965 format_relative_date(create_offset_datetime(1980, 4, 12, 23, 0, 0), reference),
966 "10 years ago"
967 );
968 }
969
970 #[test]
971 fn test_calculate_month_difference() {
972 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
973
974 assert_eq!(calculate_month_difference(reference, reference), 0);
975
976 assert_eq!(
977 calculate_month_difference(create_offset_datetime(1990, 1, 12, 23, 0, 0), reference),
978 3
979 );
980
981 assert_eq!(
982 calculate_month_difference(create_offset_datetime(1989, 11, 12, 23, 0, 0), reference),
983 5
984 );
985
986 assert_eq!(
987 calculate_month_difference(create_offset_datetime(1989, 4, 12, 23, 0, 0), reference),
988 12
989 );
990
991 assert_eq!(
992 calculate_month_difference(create_offset_datetime(1989, 3, 12, 23, 0, 0), reference),
993 13
994 );
995
996 assert_eq!(
997 calculate_month_difference(create_offset_datetime(1987, 5, 12, 23, 0, 0), reference),
998 35
999 );
1000
1001 assert_eq!(
1002 calculate_month_difference(create_offset_datetime(1987, 4, 12, 23, 0, 0), reference),
1003 36
1004 );
1005
1006 assert_eq!(
1007 calculate_month_difference(create_offset_datetime(1987, 3, 12, 23, 0, 0), reference),
1008 37
1009 );
1010 }
1011
1012 fn test_timezone() -> UtcOffset {
1013 UtcOffset::from_hms(0, 0, 0).expect("Valid timezone offset")
1014 }
1015
1016 fn create_offset_datetime(
1017 year: i32,
1018 month: u8,
1019 day: u8,
1020 hour: u8,
1021 minute: u8,
1022 second: u8,
1023 ) -> OffsetDateTime {
1024 let date = time::Date::from_calendar_date(year, time::Month::try_from(month).unwrap(), day)
1025 .unwrap();
1026 let time = time::Time::from_hms(hour, minute, second).unwrap();
1027 let date = date.with_time(time).assume_utc(); // Assume UTC for simplicity
1028 date.to_offset(test_timezone())
1029 }
1030}