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