1use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Timelike, Utc};
5
6const DATE: &str = "%Y-%m-%d";
7const LOCAL_DATETIME: &str = "%Y-%m-%dT%H:%M:%S%.f";
8const MS_PER_DAY: i64 = 86_400_000;
9
10#[must_use]
13pub fn parse_datetime(value: &str) -> Option<i64> {
14 let value = value.trim();
15 if let Some(ms) = parse_signed_year(value) {
21 return Some(ms);
22 }
23 if let Ok(datetime) = DateTime::parse_from_rfc3339(value) {
24 return Some(datetime.timestamp_millis());
25 }
26
27 if let Some(prefix) = value.strip_suffix('z') {
30 let normalized = format!("{prefix}Z");
31 if let Ok(datetime) = DateTime::parse_from_rfc3339(&normalized) {
32 return Some(datetime.timestamp_millis());
33 }
34 }
35
36 if let Ok(datetime) = NaiveDateTime::parse_from_str(value, LOCAL_DATETIME) {
37 return Some(datetime.and_utc().timestamp_millis());
38 }
39 NaiveDate::parse_from_str(value, DATE)
40 .ok()?
41 .and_hms_opt(0, 0, 0)
42 .map(|datetime| datetime.and_utc().timestamp_millis())
43}
44
45fn parse_signed_year(value: &str) -> Option<i64> {
49 let (sign, rest) = value.split_at_checked(1)?;
50 let digits = rest.split('-').next()?;
51 if !matches!(sign, "-" | "+") || digits.len() < 4 || !digits.bytes().all(|b| b.is_ascii_digit())
52 {
53 return None;
54 }
55 if let Ok(datetime) = DateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f%:z") {
56 return Some(datetime.timestamp_millis());
57 }
58 if let Some(local) = value.strip_suffix(['Z', 'z']) {
59 return NaiveDateTime::parse_from_str(local, LOCAL_DATETIME)
60 .ok()
61 .map(|datetime| datetime.and_utc().timestamp_millis());
62 }
63 if let Ok(datetime) = NaiveDateTime::parse_from_str(value, LOCAL_DATETIME) {
64 return Some(datetime.and_utc().timestamp_millis());
65 }
66 NaiveDate::parse_from_str(value, DATE)
67 .ok()?
68 .and_hms_opt(0, 0, 0)
69 .map(|datetime| datetime.and_utc().timestamp_millis())
70}
71
72#[must_use]
78pub fn components_to_epoch_ms_checked(
79 year: i64,
80 month: i64,
81 day: i64,
82 hour: i64,
83 minute: i64,
84 second: i64,
85 millis: i64,
86) -> Option<i64> {
87 let year = i32::try_from(year).ok()?;
88 let month = u32::try_from(month).ok()?;
89 let day = u32::try_from(day).ok()?;
90 let hour = u32::try_from(hour).ok()?;
91 let minute = u32::try_from(minute).ok()?;
92 let second = u32::try_from(second).ok()?;
93 let millis = u32::try_from(millis).ok()?;
94 Utc.with_ymd_and_hms(year, month, day, hour, minute, second)
95 .single()?
96 .with_nanosecond(millis.checked_mul(1_000_000)?)
97 .map(|datetime| datetime.timestamp_millis())
98}
99
100#[must_use]
109pub fn components_to_epoch_ms(
110 year: i64,
111 month: i64,
112 day: i64,
113 hour: i64,
114 minute: i64,
115 second: i64,
116 millis: i64,
117) -> i64 {
118 components_to_epoch_ms_checked(year, month, day, hour, minute, second, millis)
119 .expect("valid datetime components")
120}
121
122#[must_use]
124pub fn is_datetime_string(value: &str) -> bool {
125 parse_datetime(value).is_some()
126}
127
128#[must_use]
133pub fn json_datetime_ms(value: &serde_json::Value) -> Option<i64> {
134 match value {
135 serde_json::Value::Number(number) => number.as_i64(),
136 serde_json::Value::String(value) => parse_datetime(value),
137 _ => None,
138 }
139}
140
141fn utc(ms: i64) -> DateTime<Utc> {
142 DateTime::from_timestamp_millis(ms).expect("epoch milliseconds in Chrono range")
143}
144
145#[must_use]
152pub fn start_of_month(ms: i64) -> i64 {
153 let datetime = utc(ms);
154 Utc.with_ymd_and_hms(datetime.year(), datetime.month(), 1, 0, 0, 0)
155 .single()
156 .expect("valid month start")
157 .timestamp_millis()
158}
159
160#[must_use]
167pub fn start_of_year(ms: i64) -> i64 {
168 let datetime = utc(ms);
169 Utc.with_ymd_and_hms(datetime.year(), 1, 1, 0, 0, 0)
170 .single()
171 .expect("valid year start")
172 .timestamp_millis()
173}
174
175#[must_use]
182pub fn start_of_quarter(ms: i64) -> i64 {
183 let datetime = utc(ms);
184 let quarter_start_month = ((datetime.month() - 1) / 3) * 3 + 1;
185 Utc.with_ymd_and_hms(datetime.year(), quarter_start_month, 1, 0, 0, 0)
186 .single()
187 .expect("valid quarter start")
188 .timestamp_millis()
189}
190
191#[must_use]
194pub fn ms_to_datetime_string(ms: i64) -> String {
195 let datetime = utc(ms);
196 if ms.rem_euclid(MS_PER_DAY) == 0 {
197 datetime.format(DATE).to_string()
198 } else {
199 datetime.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
202 }
203}
204
205#[must_use]
211pub fn component_of(ms: i64, which: u8) -> i64 {
212 let datetime = utc(ms);
213 match which {
214 0 => i64::from(datetime.year()),
215 1 => i64::from(datetime.month()),
216 2 => i64::from(datetime.day()),
217 3 => i64::from(datetime.hour()),
218 4 => i64::from(datetime.minute()),
219 5 => i64::from(datetime.second()),
220 6 => i64::from(datetime.timestamp_subsec_millis()),
221 7 => i64::from(datetime.timestamp_subsec_micros()),
222 8 => i64::from(datetime.timestamp_subsec_nanos()),
223 _ => 0,
224 }
225}