1use std::cell::RefCell;
19use std::collections::HashMap;
20
21use icu_calendar::cal::Gregorian;
22use icu_datetime::FixedCalendarDateTimeFormatter;
23use icu_datetime::fieldsets::enums::CompositeDateTimeFieldSet;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub enum DateFields {
28 Year,
30 YearMonth,
32 Month,
35 MonthDay,
37 YearMonthDay,
39 HourMinute,
41 HourMinuteSecond,
43}
44
45type Formatter = FixedCalendarDateTimeFormatter<Gregorian, CompositeDateTimeFieldSet>;
46
47day_reactive::tls_slots! {
48 datetime;
49 static FORMATTERS: RefCell<HashMap<(String, DateFields), Option<Formatter>>> =
51 RefCell::new(HashMap::new());
52}
53
54fn build(locale: &str, fields: DateFields) -> Option<Formatter> {
55 use icu_datetime::fieldsets::builder::{DateFields as Icu, FieldSetBuilder};
56 use icu_datetime::options::{Length, TimePrecision};
57
58 let parsed: icu_locale_core::Locale = locale.parse().ok()?;
59 let mut b = FieldSetBuilder::new();
60 b.length = Some(Length::Medium);
65 match fields {
66 DateFields::Year => b.date_fields = Some(Icu::Y),
67 DateFields::YearMonth => b.date_fields = Some(Icu::YM),
68 DateFields::Month => b.date_fields = Some(Icu::M),
69 DateFields::MonthDay => b.date_fields = Some(Icu::MD),
70 DateFields::YearMonthDay => b.date_fields = Some(Icu::YMD),
71 DateFields::HourMinute => b.time_precision = Some(TimePrecision::Minute),
72 DateFields::HourMinuteSecond => b.time_precision = Some(TimePrecision::Second),
73 }
74 let field_set = b.build_composite_datetime().ok()?;
75 Formatter::try_new((&parsed).into(), field_set).ok()
76}
77
78fn iso(fields: DateFields, (y, m, d): (i32, u8, u8), (hh, mm, ss): (u8, u8, u8)) -> String {
80 match fields {
81 DateFields::Year => format!("{y:04}"),
82 DateFields::YearMonth => format!("{y:04}-{m:02}"),
83 DateFields::Month => format!("{m:02}"),
84 DateFields::MonthDay => format!("{m:02}-{d:02}"),
85 DateFields::YearMonthDay => format!("{y:04}-{m:02}-{d:02}"),
86 DateFields::HourMinute => format!("{hh:02}:{mm:02}"),
87 DateFields::HourMinuteSecond => format!("{hh:02}:{mm:02}:{ss:02}"),
88 }
89}
90
91pub fn format_date_in(locale: &str, epoch_seconds: f64, fields: DateFields) -> String {
95 if !epoch_seconds.is_finite() {
96 return format!("{epoch_seconds}");
97 }
98 let (Some(date), Some(time)) = crate::intl::from_epoch_seconds(epoch_seconds.floor() as i64)
99 else {
100 return format!("{epoch_seconds}");
101 };
102 let (Ok(d), Ok(t)) = (
103 icu_calendar::Date::try_new_gregorian(date.0, date.1, date.2),
104 icu_time::Time::try_new(time.0, time.1, time.2, 0),
105 ) else {
106 return iso(fields, date, time);
107 };
108 FORMATTERS.with(|c| {
109 let mut map = c.borrow_mut();
110 let formatter = map
111 .entry((locale.to_string(), fields))
112 .or_insert_with(|| build(locale, fields));
113 match formatter {
114 Some(f) => f
115 .format(&icu_time::DateTime { date: d, time: t })
116 .to_string(),
117 None => iso(fields, date, time),
118 }
119 })
120}
121
122pub fn format_date(epoch_seconds: f64, fields: DateFields) -> String {
125 let locale = crate::locale().get();
126 format_date_in(&locale, epoch_seconds, fields)
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 const T: f64 = 1_772_724_615.0;
135
136 fn f(locale: &str, fields: DateFields) -> String {
137 format_date_in(locale, T, fields).replace(['\u{a0}', '\u{202f}'], " ")
140 }
141
142 #[test]
143 fn a_month_and_day_are_written_the_locales_way() {
144 assert_eq!(f("en", DateFields::MonthDay), "Mar 5");
145 assert_eq!(f("fr", DateFields::MonthDay), "5 mars");
146 assert_eq!(f("de", DateFields::MonthDay), "05.03.");
148 assert_eq!(f("ja", DateFields::MonthDay), "03/05");
149 }
150
151 #[test]
152 fn a_month_and_year_are_written_the_locales_way() {
153 assert_eq!(f("en", DateFields::YearMonth), "Mar 2026");
154 assert_eq!(f("fr", DateFields::YearMonth), "mars 2026");
155 assert_eq!(f("de", DateFields::YearMonth), "03/2026");
156 assert_eq!(f("ja", DateFields::YearMonth), "2026/03");
157 assert_eq!(f("en", DateFields::Year), "2026");
158 assert_eq!(f("ja", DateFields::Year), "2026年");
159 }
160
161 #[test]
162 fn a_month_alone_is_the_locales_abbreviated_name() {
163 assert_eq!(f("en", DateFields::Month), "Mar");
164 assert_eq!(f("fr", DateFields::Month), "mars");
165 assert_eq!(f("de", DateFields::Month), "Mär");
166 assert_eq!(f("ja", DateFields::Month), "3月");
167 assert_eq!(f("ru", DateFields::Month), "март");
168 }
169
170 #[test]
171 fn a_full_date_follows_the_locales_order() {
172 assert_eq!(f("en", DateFields::YearMonthDay), "Mar 5, 2026");
173 assert_eq!(f("fr", DateFields::YearMonthDay), "5 mars 2026");
174 assert_eq!(f("de", DateFields::YearMonthDay), "05.03.2026");
175 }
176
177 #[test]
178 fn the_clock_keeps_the_locales_hour_cycle() {
179 assert_eq!(f("en", DateFields::HourMinute), "3:30 PM");
180 assert_eq!(f("fr", DateFields::HourMinute), "15:30");
181 assert_eq!(f("de", DateFields::HourMinute), "15:30");
182 assert_eq!(f("en", DateFields::HourMinuteSecond), "3:30:15 PM");
183 assert_eq!(f("fr", DateFields::HourMinuteSecond), "15:30:15");
184 }
185
186 #[test]
187 fn an_instant_before_the_epoch_and_a_non_finite_one_degrade_sensibly() {
188 assert_eq!(
190 format_date_in("en", -3600.0, DateFields::YearMonthDay),
191 "Dec 31, 1969"
192 );
193 assert_eq!(format_date_in("en", f64::NAN, DateFields::Year), "NaN");
194 }
195
196 #[test]
197 fn an_unparseable_locale_falls_back_to_iso() {
198 assert_eq!(
199 format_date_in("", T, DateFields::YearMonthDay),
200 "2026-03-05"
201 );
202 assert_eq!(format_date_in("", T, DateFields::HourMinute), "15:30");
203 }
204
205 #[test]
206 fn the_tracked_form_follows_the_current_locale() {
207 crate::set_locale("fr");
208 assert_eq!(
209 format_date(T, DateFields::MonthDay).replace('\u{a0}', " "),
210 "5 mars"
211 );
212 crate::set_locale("en");
213 assert_eq!(format_date(T, DateFields::MonthDay), "Mar 5");
214 }
215}