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 _ => {
261 let timestamp_year = timestamp_date.year();
262 let reference_year = reference_date.year();
263 let years = reference_year - timestamp_year;
264 match years {
265 1 => "1 year ago".to_string(),
266 _ => format!("{} years ago", years),
267 }
268 }
269 }
270 }
271 }
272 }
273 }
274}
275
276/// Calculates the difference in months between two timestamps.
277/// The reference timestamp should always be greater than the timestamp.
278fn calculate_month_difference(timestamp: OffsetDateTime, reference: OffsetDateTime) -> usize {
279 let timestamp_year = timestamp.year();
280 let reference_year = reference.year();
281 let timestamp_month: u8 = timestamp.month().into();
282 let reference_month: u8 = reference.month().into();
283
284 let month_diff = if reference_month >= timestamp_month {
285 reference_month as usize - timestamp_month as usize
286 } else {
287 12 - timestamp_month as usize + reference_month as usize
288 };
289
290 let year_diff = (reference_year - timestamp_year) as usize;
291 if year_diff == 0 {
292 reference_month as usize - timestamp_month as usize
293 } else if month_diff == 0 {
294 year_diff * 12
295 } else if timestamp_month > reference_month {
296 (year_diff - 1) * 12 + month_diff
297 } else {
298 year_diff * 12 + month_diff
299 }
300}
301
302/// Formats a timestamp, which is either in 12-hour or 24-hour time format.
303/// Note:
304/// This function does not respect the user's date and time preferences.
305/// This should only be used as a fallback mechanism when the OS time formatting fails.
306fn format_timestamp_naive_time(timestamp_local: OffsetDateTime, is_12_hour_time: bool) -> String {
307 let timestamp_local_hour = timestamp_local.hour();
308 let timestamp_local_minute = timestamp_local.minute();
309
310 let (hour, meridiem) = if is_12_hour_time {
311 let meridiem = if timestamp_local_hour >= 12 {
312 "PM"
313 } else {
314 "AM"
315 };
316
317 let hour_12 = match timestamp_local_hour {
318 0 => 12, // Midnight
319 13..=23 => timestamp_local_hour - 12, // PM hours
320 _ => timestamp_local_hour, // AM hours
321 };
322
323 (hour_12, Some(meridiem))
324 } else {
325 (timestamp_local_hour, None)
326 };
327
328 match meridiem {
329 Some(meridiem) => format!("{}:{:02} {}", hour, timestamp_local_minute, meridiem),
330 None => format!("{:02}:{:02}", hour, timestamp_local_minute),
331 }
332}
333
334#[cfg(not(target_os = "macos"))]
335fn format_timestamp_naive_date(
336 timestamp_local: OffsetDateTime,
337 reference_local: OffsetDateTime,
338 is_12_hour_time: bool,
339) -> String {
340 let reference_local_date = reference_local.date();
341 let timestamp_local_date = timestamp_local.date();
342
343 if timestamp_local_date == reference_local_date {
344 "Today".to_string()
345 } else if reference_local_date.previous_day() == Some(timestamp_local_date) {
346 "Yesterday".to_string()
347 } else {
348 match is_12_hour_time {
349 true => format!(
350 "{:02}/{:02}/{}",
351 timestamp_local_date.month() as u32,
352 timestamp_local_date.day(),
353 timestamp_local_date.year()
354 ),
355 false => format!(
356 "{:02}/{:02}/{}",
357 timestamp_local_date.day(),
358 timestamp_local_date.month() as u32,
359 timestamp_local_date.year()
360 ),
361 }
362 }
363}
364
365#[cfg(not(target_os = "macos"))]
366fn format_timestamp_naive_date_medium(
367 timestamp_local: OffsetDateTime,
368 is_12_hour_time: bool,
369) -> String {
370 let timestamp_local_date = timestamp_local.date();
371
372 match is_12_hour_time {
373 true => format!(
374 "{:02}/{:02}/{}",
375 timestamp_local_date.month() as u32,
376 timestamp_local_date.day(),
377 timestamp_local_date.year()
378 ),
379 false => format!(
380 "{:02}/{:02}/{}",
381 timestamp_local_date.day(),
382 timestamp_local_date.month() as u32,
383 timestamp_local_date.year()
384 ),
385 }
386}
387
388pub fn format_timestamp_naive(
389 timestamp_local: OffsetDateTime,
390 reference_local: OffsetDateTime,
391 is_12_hour_time: bool,
392) -> String {
393 let formatted_time = format_timestamp_naive_time(timestamp_local, is_12_hour_time);
394 let reference_local_date = reference_local.date();
395 let timestamp_local_date = timestamp_local.date();
396
397 if timestamp_local_date == reference_local_date {
398 format!("Today at {}", formatted_time)
399 } else if reference_local_date.previous_day() == Some(timestamp_local_date) {
400 format!("Yesterday at {}", formatted_time)
401 } else {
402 let formatted_date = match is_12_hour_time {
403 true => format!(
404 "{:02}/{:02}/{}",
405 timestamp_local_date.month() as u32,
406 timestamp_local_date.day(),
407 timestamp_local_date.year()
408 ),
409 false => format!(
410 "{:02}/{:02}/{}",
411 timestamp_local_date.day(),
412 timestamp_local_date.month() as u32,
413 timestamp_local_date.year()
414 ),
415 };
416 format!("{} {}", formatted_date, formatted_time)
417 }
418}
419
420#[cfg(not(target_os = "macos"))]
421static CURRENT_LOCALE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
422
423#[cfg(not(target_os = "macos"))]
424fn format_timestamp_fallback(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String {
425 let current_locale = CURRENT_LOCALE
426 .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
427
428 let is_12_hour_time = is_12_hour_time_by_locale(current_locale.as_str());
429 format_timestamp_naive(timestamp, reference, is_12_hour_time)
430}
431
432/// Returns `true` if the locale is recognized as a 12-hour time locale.
433#[cfg(not(target_os = "macos"))]
434fn is_12_hour_time_by_locale(locale: &str) -> bool {
435 [
436 "es-MX", "es-CO", "es-SV", "es-NI",
437 "es-HN", // Mexico, Colombia, El Salvador, Nicaragua, Honduras
438 "en-US", "en-CA", "en-AU", "en-NZ", // U.S, Canada, Australia, New Zealand
439 "ar-SA", "ar-EG", "ar-JO", // Saudi Arabia, Egypt, Jordan
440 "en-IN", "hi-IN", // India, Hindu
441 "en-PK", "ur-PK", // Pakistan, Urdu
442 "en-PH", "fil-PH", // Philippines, Filipino
443 "bn-BD", "ccp-BD", // Bangladesh, Chakma
444 "en-IE", "ga-IE", // Ireland, Irish
445 "en-MY", "ms-MY", // Malaysia, Malay
446 ]
447 .contains(&locale)
448}
449
450#[cfg(target_os = "macos")]
451mod macos {
452 use core_foundation::base::TCFType;
453 use core_foundation::date::CFAbsoluteTime;
454 use core_foundation::string::CFString;
455 use core_foundation_sys::date_formatter::CFDateFormatterCreateStringWithAbsoluteTime;
456 use core_foundation_sys::date_formatter::CFDateFormatterRef;
457 use core_foundation_sys::locale::CFLocaleRef;
458 use core_foundation_sys::{
459 base::kCFAllocatorDefault,
460 date_formatter::{
461 CFDateFormatterCreate, kCFDateFormatterMediumStyle, kCFDateFormatterNoStyle,
462 kCFDateFormatterShortStyle,
463 },
464 locale::CFLocaleCopyCurrent,
465 };
466
467 pub fn format_time(timestamp: &time::OffsetDateTime) -> String {
468 format_with_date_formatter(timestamp, TIME_FORMATTER.with(|f| *f))
469 }
470
471 pub fn format_date(timestamp: &time::OffsetDateTime) -> String {
472 format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))
473 }
474
475 pub fn format_date_medium(timestamp: &time::OffsetDateTime) -> String {
476 format_with_date_formatter(timestamp, MEDIUM_DATE_FORMATTER.with(|f| *f))
477 }
478
479 fn format_with_date_formatter(
480 timestamp: &time::OffsetDateTime,
481 fmt: CFDateFormatterRef,
482 ) -> String {
483 const UNIX_TO_CF_ABSOLUTE_TIME_OFFSET: i64 = 978307200;
484 // Convert timestamp to macOS absolute time
485 let timestamp_macos = timestamp.unix_timestamp() - UNIX_TO_CF_ABSOLUTE_TIME_OFFSET;
486 let cf_absolute_time = timestamp_macos as CFAbsoluteTime;
487 unsafe {
488 let s = CFDateFormatterCreateStringWithAbsoluteTime(
489 kCFAllocatorDefault,
490 fmt,
491 cf_absolute_time,
492 );
493 CFString::wrap_under_create_rule(s).to_string()
494 }
495 }
496
497 thread_local! {
498 static CURRENT_LOCALE: CFLocaleRef = unsafe { CFLocaleCopyCurrent() };
499 static TIME_FORMATTER: CFDateFormatterRef = unsafe {
500 CFDateFormatterCreate(
501 kCFAllocatorDefault,
502 CURRENT_LOCALE.with(|locale| *locale),
503 kCFDateFormatterNoStyle,
504 kCFDateFormatterShortStyle,
505 )
506 };
507 static DATE_FORMATTER: CFDateFormatterRef = unsafe {
508 CFDateFormatterCreate(
509 kCFAllocatorDefault,
510 CURRENT_LOCALE.with(|locale| *locale),
511 kCFDateFormatterShortStyle,
512 kCFDateFormatterNoStyle,
513 )
514 };
515
516 static MEDIUM_DATE_FORMATTER: CFDateFormatterRef = unsafe {
517 CFDateFormatterCreate(
518 kCFAllocatorDefault,
519 CURRENT_LOCALE.with(|locale| *locale),
520 kCFDateFormatterMediumStyle,
521 kCFDateFormatterNoStyle,
522 )
523 };
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn test_format_date() {
533 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
534
535 // Test with same date (today)
536 let timestamp_today = create_offset_datetime(1990, 4, 12, 9, 30, 0);
537 assert_eq!(format_date(timestamp_today, reference, true), "Today");
538
539 // Test with previous day (yesterday)
540 let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
541 assert_eq!(
542 format_date(timestamp_yesterday, reference, true),
543 "Yesterday"
544 );
545
546 // Test with other date
547 let timestamp_other = create_offset_datetime(1990, 4, 10, 9, 30, 0);
548 let result = format_date(timestamp_other, reference, true);
549 assert!(!result.is_empty());
550 assert_ne!(result, "Today");
551 assert_ne!(result, "Yesterday");
552 }
553
554 #[test]
555 fn test_format_time() {
556 let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
557
558 // We can't assert the exact output as it depends on the platform and locale
559 // But we can at least confirm it doesn't panic and returns a non-empty string
560 let result = format_time(timestamp);
561 assert!(!result.is_empty());
562 }
563
564 #[test]
565 fn test_format_date_medium() {
566 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
567 let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
568
569 // Test with enhanced formatting (today)
570 let result_enhanced = format_date_medium(timestamp, reference, true);
571 assert_eq!(result_enhanced, "Today");
572
573 // Test with standard formatting
574 let result_standard = format_date_medium(timestamp, reference, false);
575 assert!(!result_standard.is_empty());
576
577 // Test yesterday with enhanced formatting
578 let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
579 let result_yesterday = format_date_medium(timestamp_yesterday, reference, true);
580 assert_eq!(result_yesterday, "Yesterday");
581
582 // Test other date with enhanced formatting
583 let timestamp_other = create_offset_datetime(1990, 4, 10, 9, 30, 0);
584 let result_other = format_date_medium(timestamp_other, reference, true);
585 assert!(!result_other.is_empty());
586 assert_ne!(result_other, "Today");
587 assert_ne!(result_other, "Yesterday");
588 }
589
590 #[test]
591 fn test_format_absolute_time() {
592 let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
593
594 // We can't assert the exact output as it depends on the platform and locale
595 // But we can at least confirm it doesn't panic and returns a non-empty string
596 let result = format_absolute_time(timestamp);
597 assert!(!result.is_empty());
598 }
599
600 #[test]
601 fn test_format_absolute_date() {
602 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
603
604 // Test with same date (today)
605 let timestamp_today = create_offset_datetime(1990, 4, 12, 9, 30, 0);
606 assert_eq!(
607 format_absolute_date(timestamp_today, reference, true),
608 "Today"
609 );
610
611 // Test with previous day (yesterday)
612 let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
613 assert_eq!(
614 format_absolute_date(timestamp_yesterday, reference, true),
615 "Yesterday"
616 );
617
618 // Test with other date
619 let timestamp_other = create_offset_datetime(1990, 4, 10, 9, 30, 0);
620 let result = format_absolute_date(timestamp_other, reference, true);
621 assert!(!result.is_empty());
622 assert_ne!(result, "Today");
623 assert_ne!(result, "Yesterday");
624 }
625
626 #[test]
627 fn test_format_absolute_date_medium() {
628 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
629 let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
630
631 // Test with enhanced formatting (today)
632 let result_enhanced = format_absolute_date_medium(timestamp, reference, true);
633 assert_eq!(result_enhanced, "Today");
634
635 // Test with standard formatting
636 let result_standard = format_absolute_date_medium(timestamp, reference, false);
637 assert!(!result_standard.is_empty());
638
639 // Test yesterday with enhanced formatting
640 let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
641 let result_yesterday = format_absolute_date_medium(timestamp_yesterday, reference, true);
642 assert_eq!(result_yesterday, "Yesterday");
643 }
644
645 #[test]
646 fn test_format_timestamp_naive_time() {
647 let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
648 assert_eq!(format_timestamp_naive_time(timestamp, true), "9:30 AM");
649 assert_eq!(format_timestamp_naive_time(timestamp, false), "09:30");
650
651 let timestamp_pm = create_offset_datetime(1990, 4, 12, 15, 45, 0);
652 assert_eq!(format_timestamp_naive_time(timestamp_pm, true), "3:45 PM");
653 assert_eq!(format_timestamp_naive_time(timestamp_pm, false), "15:45");
654 }
655
656 #[test]
657 fn test_format_24_hour_time() {
658 let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
659 let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
660
661 assert_eq!(
662 format_timestamp_naive(timestamp, reference, false),
663 "Today at 15:30"
664 );
665 }
666
667 #[test]
668 fn test_format_today() {
669 let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
670 let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
671
672 assert_eq!(
673 format_timestamp_naive(timestamp, reference, true),
674 "Today at 3:30 PM"
675 );
676 }
677
678 #[test]
679 fn test_format_yesterday() {
680 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
681 let timestamp = create_offset_datetime(1990, 4, 11, 9, 0, 0);
682
683 assert_eq!(
684 format_timestamp_naive(timestamp, reference, true),
685 "Yesterday at 9:00 AM"
686 );
687 }
688
689 #[test]
690 fn test_format_yesterday_less_than_24_hours_ago() {
691 let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
692 let timestamp = create_offset_datetime(1990, 4, 11, 20, 0, 0);
693
694 assert_eq!(
695 format_timestamp_naive(timestamp, reference, true),
696 "Yesterday at 8:00 PM"
697 );
698 }
699
700 #[test]
701 fn test_format_yesterday_more_than_24_hours_ago() {
702 let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
703 let timestamp = create_offset_datetime(1990, 4, 11, 18, 0, 0);
704
705 assert_eq!(
706 format_timestamp_naive(timestamp, reference, true),
707 "Yesterday at 6:00 PM"
708 );
709 }
710
711 #[test]
712 fn test_format_yesterday_over_midnight() {
713 let reference = create_offset_datetime(1990, 4, 12, 0, 5, 0);
714 let timestamp = create_offset_datetime(1990, 4, 11, 23, 55, 0);
715
716 assert_eq!(
717 format_timestamp_naive(timestamp, reference, true),
718 "Yesterday at 11:55 PM"
719 );
720 }
721
722 #[test]
723 fn test_format_yesterday_over_month() {
724 let reference = create_offset_datetime(1990, 4, 2, 9, 0, 0);
725 let timestamp = create_offset_datetime(1990, 4, 1, 20, 0, 0);
726
727 assert_eq!(
728 format_timestamp_naive(timestamp, reference, true),
729 "Yesterday at 8:00 PM"
730 );
731 }
732
733 #[test]
734 fn test_format_before_yesterday() {
735 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
736 let timestamp = create_offset_datetime(1990, 4, 10, 20, 20, 0);
737
738 assert_eq!(
739 format_timestamp_naive(timestamp, reference, true),
740 "04/10/1990 8:20 PM"
741 );
742 }
743
744 #[test]
745 fn test_relative_format_minutes() {
746 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
747 let mut current_timestamp = reference;
748
749 let mut next_minute = || {
750 current_timestamp = if current_timestamp.minute() == 0 {
751 current_timestamp
752 .replace_hour(current_timestamp.hour() - 1)
753 .unwrap()
754 .replace_minute(59)
755 .unwrap()
756 } else {
757 current_timestamp
758 .replace_minute(current_timestamp.minute() - 1)
759 .unwrap()
760 };
761 current_timestamp
762 };
763
764 assert_eq!(
765 format_relative_time(reference, reference),
766 Some("Just now".to_string())
767 );
768
769 assert_eq!(
770 format_relative_time(next_minute(), reference),
771 Some("1 minute ago".to_string())
772 );
773
774 for i in 2..=59 {
775 assert_eq!(
776 format_relative_time(next_minute(), reference),
777 Some(format!("{} minutes ago", i))
778 );
779 }
780
781 assert_eq!(
782 format_relative_time(next_minute(), reference),
783 Some("1 hour ago".to_string())
784 );
785 }
786
787 #[test]
788 fn test_relative_format_hours() {
789 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
790 let mut current_timestamp = reference;
791
792 let mut next_hour = || {
793 current_timestamp = if current_timestamp.hour() == 0 {
794 let date = current_timestamp.date().previous_day().unwrap();
795 current_timestamp.replace_date(date)
796 } else {
797 current_timestamp
798 .replace_hour(current_timestamp.hour() - 1)
799 .unwrap()
800 };
801 current_timestamp
802 };
803
804 assert_eq!(
805 format_relative_time(next_hour(), reference),
806 Some("1 hour ago".to_string())
807 );
808
809 for i in 2..=23 {
810 assert_eq!(
811 format_relative_time(next_hour(), reference),
812 Some(format!("{} hours ago", i))
813 );
814 }
815
816 assert_eq!(format_relative_time(next_hour(), reference), None);
817 }
818
819 #[test]
820 fn test_relative_format_days() {
821 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
822 let mut current_timestamp = reference;
823
824 let mut next_day = || {
825 let date = current_timestamp.date().previous_day().unwrap();
826 current_timestamp = current_timestamp.replace_date(date);
827 current_timestamp
828 };
829
830 assert_eq!(
831 format_relative_date(reference, reference),
832 "Today".to_string()
833 );
834
835 assert_eq!(
836 format_relative_date(next_day(), reference),
837 "Yesterday".to_string()
838 );
839
840 for i in 2..=6 {
841 assert_eq!(
842 format_relative_date(next_day(), reference),
843 format!("{} days ago", i)
844 );
845 }
846
847 assert_eq!(format_relative_date(next_day(), reference), "1 week ago");
848 }
849
850 #[test]
851 fn test_relative_format_weeks() {
852 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
853 let mut current_timestamp = reference;
854
855 let mut next_week = || {
856 for _ in 0..7 {
857 let date = current_timestamp.date().previous_day().unwrap();
858 current_timestamp = current_timestamp.replace_date(date);
859 }
860 current_timestamp
861 };
862
863 assert_eq!(
864 format_relative_date(next_week(), reference),
865 "1 week ago".to_string()
866 );
867
868 for i in 2..=4 {
869 assert_eq!(
870 format_relative_date(next_week(), reference),
871 format!("{} weeks ago", i)
872 );
873 }
874
875 assert_eq!(format_relative_date(next_week(), reference), "1 month ago");
876 }
877
878 #[test]
879 fn test_relative_format_months() {
880 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
881 let mut current_timestamp = reference;
882
883 let mut next_month = || {
884 if current_timestamp.month() == time::Month::January {
885 current_timestamp = current_timestamp
886 .replace_month(time::Month::December)
887 .unwrap()
888 .replace_year(current_timestamp.year() - 1)
889 .unwrap();
890 } else {
891 current_timestamp = current_timestamp
892 .replace_month(current_timestamp.month().previous())
893 .unwrap();
894 }
895 current_timestamp
896 };
897
898 assert_eq!(
899 format_relative_date(next_month(), reference),
900 "4 weeks ago".to_string()
901 );
902
903 for i in 2..=11 {
904 assert_eq!(
905 format_relative_date(next_month(), reference),
906 format!("{} months ago", i)
907 );
908 }
909
910 assert_eq!(format_relative_date(next_month(), reference), "1 year ago");
911 }
912
913 #[test]
914 fn test_calculate_month_difference() {
915 let reference = create_offset_datetime(1990, 4, 12, 23, 0, 0);
916
917 assert_eq!(calculate_month_difference(reference, reference), 0);
918
919 assert_eq!(
920 calculate_month_difference(create_offset_datetime(1990, 1, 12, 23, 0, 0), reference),
921 3
922 );
923
924 assert_eq!(
925 calculate_month_difference(create_offset_datetime(1989, 11, 12, 23, 0, 0), reference),
926 5
927 );
928
929 assert_eq!(
930 calculate_month_difference(create_offset_datetime(1989, 4, 12, 23, 0, 0), reference),
931 12
932 );
933
934 assert_eq!(
935 calculate_month_difference(create_offset_datetime(1989, 3, 12, 23, 0, 0), reference),
936 13
937 );
938
939 assert_eq!(
940 calculate_month_difference(create_offset_datetime(1987, 5, 12, 23, 0, 0), reference),
941 35
942 );
943
944 assert_eq!(
945 calculate_month_difference(create_offset_datetime(1987, 4, 12, 23, 0, 0), reference),
946 36
947 );
948
949 assert_eq!(
950 calculate_month_difference(create_offset_datetime(1987, 3, 12, 23, 0, 0), reference),
951 37
952 );
953 }
954
955 fn test_timezone() -> UtcOffset {
956 UtcOffset::from_hms(0, 0, 0).expect("Valid timezone offset")
957 }
958
959 fn create_offset_datetime(
960 year: i32,
961 month: u8,
962 day: u8,
963 hour: u8,
964 minute: u8,
965 second: u8,
966 ) -> OffsetDateTime {
967 let date = time::Date::from_calendar_date(year, time::Month::try_from(month).unwrap(), day)
968 .unwrap();
969 let time = time::Time::from_hms(hour, minute, second).unwrap();
970 let date = date.with_time(time).assume_utc(); // Assume UTC for simplicity
971 date.to_offset(test_timezone())
972 }
973}