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(target_os = "windows")]
  90    {
  91        if !enhanced_date_formatting {
  92            return windows::format_date(&timestamp);
  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(&timestamp)
 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(&timestamp)
 122    }
 123    #[cfg(target_os = "windows")]
 124    {
 125        windows::format_time(&timestamp)
 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(&timestamp);
 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(&timestamp)
 194        }
 195    }
 196    #[cfg(target_os = "windows")]
 197    {
 198        if !enhanced_formatting {
 199            return windows::format_date_medium(&timestamp);
 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(&timestamp)
 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}