Skip to main content

ocpi_kit/types/
local.rs

1//! Local wall-clock times and dates: the `string(5)` and `string(10)` fields OCPI uses for
2//! opening hours and tariff restrictions.
3//!
4//! These are the only values in OCPI that are **not** in UTC. Both `RegularHours` and
5//! `TariffRestrictions` say the same thing: *"in local time, the time zone is defined in the
6//! `time_zone` field of the Location"*. Modelling them as parsed hour/minute and year/month/day
7//! rather than as opaque strings is what lets [`tariffs`](crate::tariffs) evaluate a restriction
8//! like "weekdays 09:00–18:00" against a session that crosses a daylight-saving boundary.
9
10use core::fmt;
11use core::str::FromStr;
12
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15use super::validate::{Validate, Validator};
16
17/// A wall-clock time of day, `HH:MM` in 24-hour form with leading zeros.
18///
19/// > *Must be in 24h format with leading zeros. Example: "18:15". Hour/Minute separator: ":"
20/// > Regex: `([0-1][0-9]|2[0-3]):[0-5][0-9]`*
21///
22/// Spec: 2.3.0 §mod_locations_regularhours_class, §mod_tariffs_tariffrestrictions_class
23#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
24pub struct LocalTime {
25    hour: u8,
26    minute: u8,
27}
28
29impl LocalTime {
30    /// Midnight, which the spec also uses to mean "end of day" in `TariffRestrictions.end_time`.
31    pub const MIDNIGHT: Self = Self { hour: 0, minute: 0 };
32
33    /// Creates a time of day.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`InvalidLocalTime`] unless `hour` is 0–23 and `minute` is 0–59.
38    pub fn new(hour: u8, minute: u8) -> Result<Self, InvalidLocalTime> {
39        if hour > 23 || minute > 59 {
40            return Err(InvalidLocalTime(format!("{hour:02}:{minute:02} is not a time of day")));
41        }
42        Ok(Self { hour, minute })
43    }
44
45    /// The hour, 0–23.
46    #[must_use]
47    pub const fn hour(self) -> u8 {
48        self.hour
49    }
50
51    /// The minute, 0–59.
52    #[must_use]
53    pub const fn minute(self) -> u8 {
54        self.minute
55    }
56
57    /// Minutes since midnight, for comparing and for interval arithmetic.
58    #[must_use]
59    pub const fn minutes_since_midnight(self) -> u16 {
60        self.hour as u16 * 60 + self.minute as u16
61    }
62
63    /// Whether `self` falls in the half-open interval `[start, end)`, wrapping past midnight.
64    ///
65    /// > *If `end_time` < `start_time` then the period wraps around to the next day. To stop at
66    /// > end of the day use: 00:00.*
67    ///
68    /// # `start == end` is the whole day
69    ///
70    /// The specification does not say what `start_time == end_time` means, and the two readings
71    /// are not close: taken as an empty interval the restriction never matches, taken as a
72    /// wrap-around it always does. This crate reads it as **the whole day**, for two reasons.
73    ///
74    /// It is what the wrap-around rule already produces without a special case — the interval
75    /// runs from `start`, past midnight, all the way back round to `start` — and it is the
76    /// reading that fails safe. A `TariffElement` restricted to `00:00`–`00:00` that never
77    /// matches leaves its dimension with no Price Component, and the specification's answer to
78    /// that is that the dimension is free; a tariff writer who meant "all day" would have
79    /// silently given the energy away. The other direction merely charges what the tariff says.
80    ///
81    /// Spec: 2.3.0 §mod_tariffs_tariffrestrictions_class
82    #[must_use]
83    pub const fn is_within(self, start: Self, end: Self) -> bool {
84        let (t, s, e) =
85            (self.minutes_since_midnight(), start.minutes_since_midnight(), end.minutes_since_midnight());
86        if s == e {
87            return true;
88        }
89        if s < e { t >= s && t < e } else { t >= s || t < e }
90    }
91}
92
93impl fmt::Display for LocalTime {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        write!(f, "{:02}:{:02}", self.hour, self.minute)
96    }
97}
98
99impl fmt::Debug for LocalTime {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        write!(f, "LocalTime({self})")
102    }
103}
104
105/// Why a string is not an `HH:MM` time of day.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct InvalidLocalTime(String);
108
109impl fmt::Display for InvalidLocalTime {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(f, "invalid time of day: {}", self.0)
112    }
113}
114impl std::error::Error for InvalidLocalTime {}
115
116impl FromStr for LocalTime {
117    type Err = InvalidLocalTime;
118    fn from_str(s: &str) -> Result<Self, Self::Err> {
119        let bad = || InvalidLocalTime(format!("{s:?} is not \"HH:MM\""));
120        let (h, m) = s.split_once(':').ok_or_else(bad)?;
121        if h.len() != 2 || m.len() != 2 || !h.bytes().chain(m.bytes()).all(|b| b.is_ascii_digit()) {
122            return Err(bad());
123        }
124        Self::new(h.parse().map_err(|_| bad())?, m.parse().map_err(|_| bad())?)
125    }
126}
127
128impl Validate for LocalTime {
129    // Unrepresentable values cannot exist: parsing already rejected them.
130    fn validate_in(&self, _v: &mut Validator) {}
131}
132
133impl Serialize for LocalTime {
134    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
135        s.collect_str(self)
136    }
137}
138
139impl<'de> Deserialize<'de> for LocalTime {
140    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
141        let raw = String::deserialize(d)?;
142        raw.parse().map_err(serde::de::Error::custom)
143    }
144}
145
146#[cfg(feature = "schema")]
147impl schemars::JsonSchema for LocalTime {
148    fn schema_name() -> std::borrow::Cow<'static, str> {
149        "LocalTime".into()
150    }
151    fn json_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
152        schemars::json_schema!({
153            "type": "string", "maxLength": 5, "pattern": "^([0-1][0-9]|2[0-3]):[0-5][0-9]$"
154        })
155    }
156}
157
158/// A local calendar date, `YYYY-MM-DD`.
159///
160/// > *Start date in local time, the time zone is defined in the `time_zone` field of the
161/// > Location, for example: 2015-12-24, valid from this day (inclusive). Regex:
162/// > `([12][0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])`*
163///
164/// Spec: 2.3.0 §mod_tariffs_tariffrestrictions_class
165#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
166pub struct LocalDate {
167    year: i32,
168    month: u8,
169    day: u8,
170}
171
172impl LocalDate {
173    /// Creates a date, checking that it exists in the proleptic Gregorian calendar.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`InvalidLocalDate`] for a date that does not exist, such as 2015-02-30.
178    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, InvalidLocalDate> {
179        let bad = || InvalidLocalDate(format!("{year:04}-{month:02}-{day:02} is not a date"));
180        let m = time::Month::try_from(month).map_err(|_| bad())?;
181        time::Date::from_calendar_date(year, m, day).map_err(|_| bad())?;
182        Ok(Self { year, month, day })
183    }
184
185    /// The year.
186    #[must_use]
187    pub const fn year(self) -> i32 {
188        self.year
189    }
190    /// The month, 1–12.
191    #[must_use]
192    pub const fn month(self) -> u8 {
193        self.month
194    }
195    /// The day of the month, 1–31.
196    #[must_use]
197    pub const fn day(self) -> u8 {
198        self.day
199    }
200
201    /// The date as a [`time::Date`].
202    ///
203    /// # Panics
204    ///
205    /// Never: the constructor already proved the date exists.
206    #[must_use]
207    pub fn to_date(self) -> time::Date {
208        time::Date::from_calendar_date(
209            self.year,
210            time::Month::try_from(self.month).expect("checked in constructor"),
211            self.day,
212        )
213        .expect("checked in constructor")
214    }
215
216    /// Builds a `LocalDate` from a [`time::Date`].
217    #[must_use]
218    pub fn from_date(date: time::Date) -> Self {
219        Self { year: date.year(), month: u8::from(date.month()), day: date.day() }
220    }
221}
222
223impl fmt::Display for LocalDate {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
226    }
227}
228impl fmt::Debug for LocalDate {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        write!(f, "LocalDate({self})")
231    }
232}
233
234/// Why a string is not a `YYYY-MM-DD` date.
235#[derive(Clone, Debug, PartialEq, Eq)]
236pub struct InvalidLocalDate(String);
237
238impl fmt::Display for InvalidLocalDate {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        write!(f, "invalid local date: {}", self.0)
241    }
242}
243impl std::error::Error for InvalidLocalDate {}
244
245impl FromStr for LocalDate {
246    type Err = InvalidLocalDate;
247    fn from_str(s: &str) -> Result<Self, Self::Err> {
248        let bad = || InvalidLocalDate(format!("{s:?} is not \"YYYY-MM-DD\""));
249        let b = s.as_bytes();
250        if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
251            return Err(bad());
252        }
253        if !s[0..4].bytes().chain(s[5..7].bytes()).chain(s[8..10].bytes()).all(|c| c.is_ascii_digit()) {
254            return Err(bad());
255        }
256        Self::new(
257            s[0..4].parse().map_err(|_| bad())?,
258            s[5..7].parse().map_err(|_| bad())?,
259            s[8..10].parse().map_err(|_| bad())?,
260        )
261    }
262}
263
264impl Validate for LocalDate {
265    fn validate_in(&self, _v: &mut Validator) {}
266}
267
268impl Serialize for LocalDate {
269    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
270        s.collect_str(self)
271    }
272}
273impl<'de> Deserialize<'de> for LocalDate {
274    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
275        let raw = String::deserialize(d)?;
276        raw.parse().map_err(serde::de::Error::custom)
277    }
278}
279
280#[cfg(feature = "schema")]
281impl schemars::JsonSchema for LocalDate {
282    fn schema_name() -> std::borrow::Cow<'static, str> {
283        "LocalDate".into()
284    }
285    fn json_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
286        schemars::json_schema!({ "type": "string", "format": "date", "maxLength": 10 })
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn time_of_day_requires_leading_zeros() {
296        assert_eq!("08:15".parse::<LocalTime>().unwrap().to_string(), "08:15");
297        for bad in ["8:15", "08:5", "24:00", "12:60", "0815", ""] {
298            assert!(bad.parse::<LocalTime>().is_err(), "{bad} should not parse");
299        }
300    }
301
302    #[test]
303    fn windows_wrap_around_midnight() {
304        let t = |s: &str| s.parse::<LocalTime>().unwrap();
305        // 09:00-18:00, a normal daytime window.
306        assert!(t("09:00").is_within(t("09:00"), t("18:00")));
307        assert!(!t("18:00").is_within(t("09:00"), t("18:00")), "end is exclusive");
308        assert!(!t("08:59").is_within(t("09:00"), t("18:00")));
309        // 22:00-06:00 wraps to the next day.
310        assert!(t("23:30").is_within(t("22:00"), t("06:00")));
311        assert!(t("05:59").is_within(t("22:00"), t("06:00")));
312        assert!(!t("12:00").is_within(t("22:00"), t("06:00")));
313        // "To stop at end of the day use: 00:00."
314        assert!(t("23:59").is_within(t("18:00"), t("00:00")));
315        assert!(!t("17:59").is_within(t("18:00"), t("00:00")));
316    }
317
318    #[test]
319    fn a_window_whose_ends_coincide_is_the_whole_day() {
320        // The spec leaves this open; reading it as an empty window would make a tariff element
321        // restricted to 00:00-00:00 never match, and the dimension it prices free of charge.
322        let t = |s: &str| s.parse::<LocalTime>().unwrap();
323        for probe in ["00:00", "09:30", "23:59"] {
324            assert!(t(probe).is_within(t("00:00"), t("00:00")), "{probe} is inside an all-day window");
325            assert!(t(probe).is_within(t("09:00"), t("09:00")), "{probe} is inside a wrapped full day");
326        }
327    }
328
329    #[test]
330    fn dates_must_exist() {
331        assert_eq!("2015-12-24".parse::<LocalDate>().unwrap().to_string(), "2015-12-24");
332        assert!("2015-02-30".parse::<LocalDate>().is_err());
333        assert!("2016-02-29".parse::<LocalDate>().is_ok(), "2016 is a leap year");
334        assert!("15-02-01".parse::<LocalDate>().is_err());
335    }
336
337    #[test]
338    fn serde_uses_the_wire_form() {
339        let t: LocalTime = serde_json::from_str("\"18:15\"").unwrap();
340        assert_eq!(serde_json::to_string(&t).unwrap(), "\"18:15\"");
341        let d: LocalDate = serde_json::from_str("\"2015-12-24\"").unwrap();
342        assert_eq!(serde_json::to_string(&d).unwrap(), "\"2015-12-24\"");
343    }
344}