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