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