1use std::sync::OnceLock;
2
3use time::{OffsetDateTime, UtcOffset};
4
5/// Formats a timestamp, which respects the user's date and time preferences/custom format.
6pub fn format_localized_timestamp(
7 reference: OffsetDateTime,
8 timestamp: OffsetDateTime,
9 timezone: UtcOffset,
10) -> String {
11 #[cfg(target_os = "macos")]
12 {
13 let timestamp_local = timestamp.to_offset(timezone);
14 let reference_local = reference.to_offset(timezone);
15 let reference_local_date = reference_local.date();
16 let timestamp_local_date = timestamp_local.date();
17
18 let native_fmt = if timestamp_local_date == reference_local_date {
19 macos::format_time(×tamp)
20 } else if reference_local_date.previous_day() == Some(timestamp_local_date) {
21 macos::format_time(×tamp).map(|t| format!("yesterday at {}", t).to_string())
22 } else {
23 macos::format_date(×tamp)
24 };
25 native_fmt.unwrap_or_else(|_| format_timestamp_fallback(reference, timestamp, timezone))
26 }
27 #[cfg(not(target_os = "macos"))]
28 {
29 // todo(linux) respect user's date/time preferences
30 // todo(windows) respect user's date/time preferences
31 format_timestamp_fallback(reference, timestamp, timezone)
32 }
33}
34
35fn format_timestamp_fallback(
36 reference: OffsetDateTime,
37 timestamp: OffsetDateTime,
38 timezone: UtcOffset,
39) -> String {
40 static CURRENT_LOCALE: OnceLock<String> = OnceLock::new();
41 let current_locale = CURRENT_LOCALE
42 .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
43
44 let is_12_hour_time = is_12_hour_time_by_locale(current_locale.as_str());
45 format_timestamp_naive(reference, timestamp, timezone, is_12_hour_time)
46}
47
48/// Formats a timestamp, which is either in 12-hour or 24-hour time format.
49/// Note:
50/// This function does not respect the user's date and time preferences.
51/// This should only be used as a fallback mechanism when the OS time formatting fails.
52pub fn format_timestamp_naive(
53 reference: OffsetDateTime,
54 timestamp: OffsetDateTime,
55 timezone: UtcOffset,
56 is_12_hour_time: bool,
57) -> String {
58 let timestamp_local = timestamp.to_offset(timezone);
59 let timestamp_local_hour = timestamp_local.hour();
60 let timestamp_local_minute = timestamp_local.minute();
61
62 let (hour, meridiem) = if is_12_hour_time {
63 let meridiem = if timestamp_local_hour >= 12 {
64 "pm"
65 } else {
66 "am"
67 };
68
69 let hour_12 = match timestamp_local_hour {
70 0 => 12, // Midnight
71 13..=23 => timestamp_local_hour - 12, // PM hours
72 _ => timestamp_local_hour, // AM hours
73 };
74
75 (hour_12, Some(meridiem))
76 } else {
77 (timestamp_local_hour, None)
78 };
79
80 let formatted_time = match meridiem {
81 Some(meridiem) => format!("{:02}:{:02} {}", hour, timestamp_local_minute, meridiem),
82 None => format!("{:02}:{:02}", hour, timestamp_local_minute),
83 };
84
85 let reference_local = reference.to_offset(timezone);
86 let reference_local_date = reference_local.date();
87 let timestamp_local_date = timestamp_local.date();
88
89 if timestamp_local_date == reference_local_date {
90 return formatted_time;
91 }
92
93 if reference_local_date.previous_day() == Some(timestamp_local_date) {
94 return format!("yesterday at {}", formatted_time);
95 }
96
97 match meridiem {
98 Some(_) => format!(
99 "{:02}/{:02}/{}",
100 timestamp_local_date.month() as u32,
101 timestamp_local_date.day(),
102 timestamp_local_date.year()
103 ),
104 None => format!(
105 "{:02}/{:02}/{}",
106 timestamp_local_date.day(),
107 timestamp_local_date.month() as u32,
108 timestamp_local_date.year()
109 ),
110 }
111}
112
113/// Returns `true` if the locale is recognized as a 12-hour time locale.
114fn is_12_hour_time_by_locale(locale: &str) -> bool {
115 [
116 "es-MX", "es-CO", "es-SV", "es-NI",
117 "es-HN", // Mexico, Colombia, El Salvador, Nicaragua, Honduras
118 "en-US", "en-CA", "en-AU", "en-NZ", // U.S, Canada, Australia, New Zealand
119 "ar-SA", "ar-EG", "ar-JO", // Saudi Arabia, Egypt, Jordan
120 "en-IN", "hi-IN", // India, Hindu
121 "en-PK", "ur-PK", // Pakistan, Urdu
122 "en-PH", "fil-PH", // Philippines, Filipino
123 "bn-BD", "ccp-BD", // Bangladesh, Chakma
124 "en-IE", "ga-IE", // Ireland, Irish
125 "en-MY", "ms-MY", // Malaysia, Malay
126 ]
127 .contains(&locale)
128}
129
130#[cfg(target_os = "macos")]
131mod macos {
132 use anyhow::Result;
133 use core_foundation::base::TCFType;
134 use core_foundation::date::CFAbsoluteTime;
135 use core_foundation::string::CFString;
136 use core_foundation_sys::date_formatter::CFDateFormatterCreateStringWithAbsoluteTime;
137 use core_foundation_sys::date_formatter::CFDateFormatterRef;
138 use core_foundation_sys::locale::CFLocaleRef;
139 use core_foundation_sys::{
140 base::kCFAllocatorDefault,
141 date_formatter::{
142 kCFDateFormatterNoStyle, kCFDateFormatterShortStyle, CFDateFormatterCreate,
143 },
144 locale::CFLocaleCopyCurrent,
145 };
146
147 pub fn format_time(timestamp: &time::OffsetDateTime) -> Result<String> {
148 format_with_date_formatter(timestamp, TIME_FORMATTER.with(|f| *f))
149 }
150
151 pub fn format_date(timestamp: &time::OffsetDateTime) -> Result<String> {
152 format_with_date_formatter(timestamp, DATE_FORMATTER.with(|f| *f))
153 }
154
155 fn format_with_date_formatter(
156 timestamp: &time::OffsetDateTime,
157 fmt: CFDateFormatterRef,
158 ) -> Result<String> {
159 const UNIX_TO_CF_ABSOLUTE_TIME_OFFSET: i64 = 978307200;
160 // Convert timestamp to macOS absolute time
161 let timestamp_macos = timestamp.unix_timestamp() - UNIX_TO_CF_ABSOLUTE_TIME_OFFSET;
162 let cf_absolute_time = timestamp_macos as CFAbsoluteTime;
163 unsafe {
164 let s = CFDateFormatterCreateStringWithAbsoluteTime(
165 kCFAllocatorDefault,
166 fmt,
167 cf_absolute_time,
168 );
169 Ok(CFString::wrap_under_create_rule(s).to_string())
170 }
171 }
172
173 thread_local! {
174 static CURRENT_LOCALE: CFLocaleRef = unsafe { CFLocaleCopyCurrent() };
175 static TIME_FORMATTER: CFDateFormatterRef = unsafe {
176 CFDateFormatterCreate(
177 kCFAllocatorDefault,
178 CURRENT_LOCALE.with(|locale| *locale),
179 kCFDateFormatterNoStyle,
180 kCFDateFormatterShortStyle,
181 )
182 };
183 static DATE_FORMATTER: CFDateFormatterRef = unsafe {
184 CFDateFormatterCreate(
185 kCFAllocatorDefault,
186 CURRENT_LOCALE.with(|locale| *locale),
187 kCFDateFormatterShortStyle,
188 kCFDateFormatterNoStyle,
189 )
190 };
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn test_format_24_hour_time() {
200 let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
201 let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
202
203 assert_eq!(
204 format_timestamp_naive(reference, timestamp, test_timezone(), false),
205 "15:30"
206 );
207 }
208
209 #[test]
210 fn test_format_today() {
211 let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);
212 let timestamp = create_offset_datetime(1990, 4, 12, 15, 30, 0);
213
214 assert_eq!(
215 format_timestamp_naive(reference, timestamp, test_timezone(), true),
216 "03:30 pm"
217 );
218 }
219
220 #[test]
221 fn test_format_yesterday() {
222 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
223 let timestamp = create_offset_datetime(1990, 4, 11, 9, 0, 0);
224
225 assert_eq!(
226 format_timestamp_naive(reference, timestamp, test_timezone(), true),
227 "yesterday at 09:00 am"
228 );
229 }
230
231 #[test]
232 fn test_format_yesterday_less_than_24_hours_ago() {
233 let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
234 let timestamp = create_offset_datetime(1990, 4, 11, 20, 0, 0);
235
236 assert_eq!(
237 format_timestamp_naive(reference, timestamp, test_timezone(), true),
238 "yesterday at 08:00 pm"
239 );
240 }
241
242 #[test]
243 fn test_format_yesterday_more_than_24_hours_ago() {
244 let reference = create_offset_datetime(1990, 4, 12, 19, 59, 0);
245 let timestamp = create_offset_datetime(1990, 4, 11, 18, 0, 0);
246
247 assert_eq!(
248 format_timestamp_naive(reference, timestamp, test_timezone(), true),
249 "yesterday at 06:00 pm"
250 );
251 }
252
253 #[test]
254 fn test_format_yesterday_over_midnight() {
255 let reference = create_offset_datetime(1990, 4, 12, 0, 5, 0);
256 let timestamp = create_offset_datetime(1990, 4, 11, 23, 55, 0);
257
258 assert_eq!(
259 format_timestamp_naive(reference, timestamp, test_timezone(), true),
260 "yesterday at 11:55 pm"
261 );
262 }
263
264 #[test]
265 fn test_format_yesterday_over_month() {
266 let reference = create_offset_datetime(1990, 4, 2, 9, 0, 0);
267 let timestamp = create_offset_datetime(1990, 4, 1, 20, 0, 0);
268
269 assert_eq!(
270 format_timestamp_naive(reference, timestamp, test_timezone(), true),
271 "yesterday at 08:00 pm"
272 );
273 }
274
275 #[test]
276 fn test_format_before_yesterday() {
277 let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
278 let timestamp = create_offset_datetime(1990, 4, 10, 20, 20, 0);
279
280 assert_eq!(
281 format_timestamp_naive(reference, timestamp, test_timezone(), true),
282 "04/10/1990"
283 );
284 }
285
286 fn test_timezone() -> UtcOffset {
287 UtcOffset::from_hms(0, 0, 0).expect("Valid timezone offset")
288 }
289
290 fn create_offset_datetime(
291 year: i32,
292 month: u8,
293 day: u8,
294 hour: u8,
295 minute: u8,
296 second: u8,
297 ) -> OffsetDateTime {
298 let date = time::Date::from_calendar_date(year, time::Month::try_from(month).unwrap(), day)
299 .unwrap();
300 let time = time::Time::from_hms(hour, minute, second).unwrap();
301 date.with_time(time).assume_utc() // Assume UTC for simplicity
302 }
303}