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