time_format.rs

  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(&timestamp);
 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(&timestamp)
 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(&timestamp)
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(&timestamp);
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(&timestamp)
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}