1use core::fmt;
4use core::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};
8
9use super::validate::{Validate, Validator, ViolationCode};
10
11#[derive(Clone, Copy)]
49pub struct DateTime {
50 instant: OffsetDateTime,
52 frac_digits: u8,
54 canonical: bool,
56}
57
58impl DateTime {
59 #[must_use]
61 pub fn now() -> Self {
62 Self::from_utc(
63 OffsetDateTime::now_utc().replace_nanosecond(0).unwrap_or_else(|_| OffsetDateTime::now_utc()),
64 )
65 }
66
67 pub(crate) fn from_utc(value: OffsetDateTime) -> Self {
77 let instant = value.to_offset(UtcOffset::UTC);
78 Self { instant, frac_digits: shortest_frac_digits(instant.nanosecond()), canonical: true }
79 }
80
81 #[must_use]
105 pub fn local_parts(self, offset_seconds: i32) -> crate::types::LocalParts {
106 let local = self.instant + time::Duration::seconds(i64::from(offset_seconds));
107 crate::types::LocalParts {
108 date: crate::types::LocalDate::from_date(local.date()),
109 time: crate::types::LocalTime::new(local.hour(), local.minute())
111 .expect("an hour and minute from a timestamp are in range"),
112 iso_weekday: local.weekday().number_from_monday(),
113 }
114 }
115
116 #[must_use]
118 pub const fn unix_timestamp(self) -> i64 {
119 self.instant.unix_timestamp()
120 }
121
122 pub fn from_unix_timestamp(secs: i64) -> Result<Self, InvalidDateTime> {
128 OffsetDateTime::from_unix_timestamp(secs)
129 .map(Self::from_utc)
130 .map_err(|_| InvalidDateTime::new("timestamp out of range"))
131 }
132
133 #[must_use]
138 pub const fn is_canonical(self) -> bool {
139 self.canonical
140 }
141
142 #[must_use]
146 pub const fn with_fractional_digits(mut self, digits: u8) -> Self {
147 self.frac_digits = if digits > 9 { 9 } else { digits };
148 self
149 }
150
151 #[must_use]
153 pub const fn fractional_digits(self) -> u8 {
154 self.frac_digits
155 }
156
157 pub fn parse(text: &str) -> Result<Self, InvalidDateTime> {
165 parse_ocpi_datetime(text)
166 }
167}
168
169fn shortest_frac_digits(nanos: u32) -> u8 {
170 if nanos == 0 {
171 0
172 } else if nanos.is_multiple_of(1_000_000) {
173 3
174 } else if nanos.is_multiple_of(1_000) {
175 6
176 } else {
177 9
178 }
179}
180
181#[derive(Clone, Debug, PartialEq, Eq)]
183pub struct InvalidDateTime(String);
184
185impl InvalidDateTime {
186 fn new(message: impl Into<String>) -> Self {
187 Self(message.into())
188 }
189}
190
191impl fmt::Display for InvalidDateTime {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 write!(f, "invalid OCPI DateTime: {}", self.0)
194 }
195}
196
197impl std::error::Error for InvalidDateTime {}
198
199#[allow(clippy::too_many_lines)]
200fn parse_ocpi_datetime(text: &str) -> Result<DateTime, InvalidDateTime> {
201 let bytes = text.as_bytes();
202 let err = |m: &str| InvalidDateTime::new(format!("{m} in {text:?}"));
203
204 if bytes.len() < 19 {
205 return Err(err("too short for YYYY-MM-DDTHH:MM:SS"));
206 }
207 let digits = |from: usize, len: usize| -> Result<u32, InvalidDateTime> {
208 let slice = text.get(from..from + len).ok_or_else(|| err("truncated"))?;
209 if !slice.bytes().all(|b| b.is_ascii_digit()) {
210 return Err(err("expected digits"));
211 }
212 slice.parse::<u32>().map_err(|_| err("expected digits"))
213 };
214 if bytes[4] != b'-' || bytes[7] != b'-' {
215 return Err(err("expected YYYY-MM-DD"));
216 }
217 if bytes[13] != b':' || bytes[16] != b':' {
218 return Err(err("expected HH:MM:SS"));
219 }
220
221 let mut canonical = true;
222 match bytes[10] {
223 b'T' => {}
224 b't' | b' ' => canonical = false,
225 _ => return Err(err("expected 'T' between date and time")),
226 }
227
228 let year = i32::try_from(digits(0, 4)?).map_err(|_| err("year out of range"))?;
229 let month = u8::try_from(digits(5, 2)?).map_err(|_| err("month out of range"))?;
230 let day = u8::try_from(digits(8, 2)?).map_err(|_| err("day out of range"))?;
231 let hour = u8::try_from(digits(11, 2)?).map_err(|_| err("hour out of range"))?;
232 let minute = u8::try_from(digits(14, 2)?).map_err(|_| err("minute out of range"))?;
233 let second = u8::try_from(digits(17, 2)?).map_err(|_| err("second out of range"))?;
234
235 let mut idx = 19;
236 let mut nanos: u32 = 0;
237 let mut frac_digits: u8 = 0;
238 if bytes.get(idx) == Some(&b'.') || bytes.get(idx) == Some(&b',') {
239 if bytes[idx] == b',' {
240 canonical = false;
241 }
242 idx += 1;
243 let start = idx;
244 while bytes.get(idx).is_some_and(u8::is_ascii_digit) {
245 idx += 1;
246 }
247 if idx == start {
248 return Err(err("fractional separator with no digits"));
249 }
250 let raw = &text[start..idx];
251 frac_digits = u8::try_from(raw.len()).unwrap_or(u8::MAX);
254 let mut scaled = String::with_capacity(9);
256 scaled.push_str(&raw[..raw.len().min(9)]);
257 while scaled.len() < 9 {
258 scaled.push('0');
259 }
260 nanos = scaled.parse().map_err(|_| err("bad fractional seconds"))?;
261 if frac_digits > 9 {
262 frac_digits = 9;
263 canonical = false;
264 }
265 }
266
267 let offset_minutes: i32 = match bytes.get(idx) {
268 None => {
269 0
271 }
272 Some(b'Z') => {
273 idx += 1;
274 0
275 }
276 Some(b'z') => {
277 canonical = false;
278 idx += 1;
279 0
280 }
281 Some(sign @ (b'+' | b'-')) => {
282 canonical = false;
284 let sign = if *sign == b'-' { -1 } else { 1 };
285 idx += 1;
286 let oh = i32::try_from(digits(idx, 2)?).map_err(|_| err("offset out of range"))?;
287 idx += 2;
288 if bytes.get(idx) == Some(&b':') {
289 idx += 1;
290 }
291 let om = i32::try_from(digits(idx, 2)?).map_err(|_| err("offset out of range"))?;
292 idx += 2;
293 sign * (oh * 60 + om)
294 }
295 Some(_) => return Err(err("unexpected trailing characters")),
296 };
297 if idx != bytes.len() {
298 return Err(err("unexpected trailing characters"));
299 }
300
301 let date = time::Date::from_calendar_date(
302 year,
303 time::Month::try_from(month).map_err(|_| err("month out of range"))?,
304 day,
305 )
306 .map_err(|_| err("no such calendar date"))?;
307 let (second, leap) = if second == 60 { (59, true) } else { (second, false) };
309 let time_of_day =
310 time::Time::from_hms_nano(hour, minute, second, nanos).map_err(|_| err("no such time of day"))?;
311 if leap {
312 canonical = false;
313 }
314 let naive = PrimitiveDateTime::new(date, time_of_day);
315 let offset =
316 UtcOffset::from_whole_seconds(offset_minutes * 60).map_err(|_| err("offset out of range"))?;
317 let instant = naive.assume_offset(offset).to_offset(UtcOffset::UTC);
318
319 Ok(DateTime { instant, frac_digits, canonical })
320}
321
322impl fmt::Display for DateTime {
323 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
324 let d = self.instant.date();
325 let t = self.instant.time();
326 write!(
327 f,
328 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
329 d.year(),
330 u8::from(d.month()),
331 d.day(),
332 t.hour(),
333 t.minute(),
334 t.second()
335 )?;
336 if self.frac_digits > 0 {
337 let nanos = t.nanosecond();
338 let text = format!("{nanos:09}");
339 f.write_str(".")?;
340 f.write_str(&text[..usize::from(self.frac_digits)])?;
341 }
342 f.write_str("Z")
343 }
344}
345
346impl fmt::Debug for DateTime {
347 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
348 write!(f, "DateTime({self})")
349 }
350}
351
352impl PartialEq for DateTime {
353 fn eq(&self, other: &Self) -> bool {
354 self.instant == other.instant
355 }
356}
357impl Eq for DateTime {}
358
359impl PartialOrd for DateTime {
360 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
361 Some(self.cmp(other))
362 }
363}
364impl Ord for DateTime {
365 fn cmp(&self, other: &Self) -> core::cmp::Ordering {
366 self.instant.cmp(&other.instant)
367 }
368}
369impl core::hash::Hash for DateTime {
370 fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
371 self.instant.hash(state);
372 }
373}
374
375impl From<OffsetDateTime> for DateTime {
376 fn from(value: OffsetDateTime) -> Self {
377 Self::from_utc(value)
378 }
379}
380
381impl From<DateTime> for OffsetDateTime {
382 fn from(value: DateTime) -> Self {
383 value.instant
384 }
385}
386
387impl FromStr for DateTime {
388 type Err = InvalidDateTime;
389 fn from_str(s: &str) -> Result<Self, Self::Err> {
390 parse_ocpi_datetime(s)
391 }
392}
393
394impl TryFrom<&str> for DateTime {
395 type Error = InvalidDateTime;
396 fn try_from(s: &str) -> Result<Self, Self::Error> {
397 parse_ocpi_datetime(s)
398 }
399}
400
401impl Validate for DateTime {
402 fn validate_in(&self, v: &mut Validator) {
403 if !self.canonical {
404 v.report(
405 ViolationCode::Inconsistent,
406 "timestamp was not written in one of the six forms OCPI allows \
407 (an explicit UTC offset, a lower-case 'z' or a space separator was used); \
408 note that the spec states \"+00:00 is not the same as UTC\"",
409 );
410 }
411 }
412}
413
414impl Serialize for DateTime {
415 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
416 serializer.collect_str(self)
417 }
418}
419
420impl<'de> Deserialize<'de> for DateTime {
421 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
422 struct V;
423 impl serde::de::Visitor<'_> for V {
424 type Value = DateTime;
425 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426 f.write_str("an RFC 3339 UTC timestamp such as \"2015-06-29T20:39:09Z\"")
427 }
428 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<DateTime, E> {
429 parse_ocpi_datetime(v).map_err(E::custom)
430 }
431 }
432 deserializer.deserialize_str(V)
433 }
434}
435
436#[cfg(feature = "schema")]
437impl schemars::JsonSchema for DateTime {
438 fn schema_name() -> std::borrow::Cow<'static, str> {
439 "DateTime".into()
440 }
441 fn json_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
442 schemars::json_schema!({
443 "type": "string",
444 "format": "date-time",
445 "maxLength": 25,
446 "description": "OCPI DateTime: RFC 3339, always UTC",
447 })
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn parses_all_six_spec_forms() {
457 for (text, expect) in [
458 ("2015-06-29T20:39:09Z", "2015-06-29T20:39:09Z"),
459 ("2015-06-29T20:39:09", "2015-06-29T20:39:09Z"),
460 ("2016-12-29T17:45:09.2Z", "2016-12-29T17:45:09.2Z"),
461 ("2016-12-29T17:45:09.2", "2016-12-29T17:45:09.2Z"),
462 ("2018-01-01T01:08:01.123Z", "2018-01-01T01:08:01.123Z"),
463 ("2018-01-01T01:08:01.123", "2018-01-01T01:08:01.123Z"),
464 ] {
465 let dt: DateTime = text.parse().unwrap();
466 assert!(dt.is_canonical(), "{text} should be canonical");
467 assert_eq!(dt.to_string(), expect, "round-trip of {text}");
468 }
469 }
470
471 #[test]
472 fn explicit_offsets_are_converted_and_flagged() {
473 let dt: DateTime = "2015-06-29T22:39:09+02:00".parse().unwrap();
474 assert_eq!(dt.to_string(), "2015-06-29T20:39:09Z");
475 assert!(!dt.is_canonical());
476 let violations = dt.validate().unwrap_err();
477 assert_eq!(violations.as_slice()[0].code, ViolationCode::Inconsistent);
478
479 let zero: DateTime = "2015-06-29T20:39:09+00:00".parse().unwrap();
481 assert_eq!(zero.to_string(), "2015-06-29T20:39:09Z");
482 assert!(!zero.is_canonical());
483 }
484
485 #[test]
486 fn equality_ignores_fractional_digit_count() {
487 let a: DateTime = "2016-12-29T17:45:09.2Z".parse().unwrap();
488 let b: DateTime = "2016-12-29T17:45:09.200Z".parse().unwrap();
489 assert_eq!(a, b);
490 assert_ne!(a.to_string(), b.to_string(), "but formatting is preserved");
491 }
492
493 #[test]
494 fn rejects_nonsense() {
495 for bad in [
496 "",
497 "2015-06-29",
498 "not a date",
499 "2015-13-01T00:00:00Z",
500 "2015-06-29T25:00:00Z",
501 "2015-06-29T20:39:09Zjunk",
502 ] {
503 assert!(bad.parse::<DateTime>().is_err(), "{bad} should not parse");
504 }
505 }
506
507 #[test]
508 fn an_over_long_fraction_is_truncated_and_flagged() {
509 let dt: DateTime = "2018-01-01T01:08:01.1234567891234Z".parse().unwrap();
512 assert_eq!(dt.fractional_digits(), 9);
513 assert!(!dt.is_canonical());
514 assert_eq!(dt.to_string(), "2018-01-01T01:08:01.123456789Z");
515
516 let absurd = format!("2018-01-01T01:08:01.{}Z", "1".repeat(300));
517 let dt: DateTime = absurd.parse().unwrap();
518 assert!(!dt.is_canonical(), "300 fractional digits is not one of the six forms");
519 }
520
521 #[test]
522 fn serde_round_trip() {
523 let json = "\"2018-01-01T01:08:01.123Z\"";
524 let dt: DateTime = serde_json::from_str(json).unwrap();
525 assert_eq!(serde_json::to_string(&dt).unwrap(), json);
526 }
527
528 #[test]
529 fn from_utc_picks_the_shortest_exact_fraction() {
530 let base = OffsetDateTime::from_unix_timestamp(1_500_000_000).unwrap();
531 assert_eq!(DateTime::from_utc(base).fractional_digits(), 0);
532 let ms = base.replace_nanosecond(120_000_000).unwrap();
533 assert_eq!(DateTime::from_utc(ms).fractional_digits(), 3);
534 let us = base.replace_nanosecond(120_000_100).unwrap();
535 assert_eq!(DateTime::from_utc(us).fractional_digits(), 9);
536 }
537}