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    /// Builds a `LocalDate` from a [`time::Date`].
202    #[must_use]
203    pub(crate) fn from_date(date: time::Date) -> Self {
204        Self { year: date.year(), month: u8::from(date.month()), day: date.day() }
205    }
206}
207
208impl fmt::Display for LocalDate {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
211    }
212}
213impl fmt::Debug for LocalDate {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        write!(f, "LocalDate({self})")
216    }
217}
218
219/// Why a string is not a `YYYY-MM-DD` date.
220#[derive(Clone, Debug, PartialEq, Eq)]
221pub struct InvalidLocalDate(String);
222
223impl fmt::Display for InvalidLocalDate {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        write!(f, "invalid local date: {}", self.0)
226    }
227}
228impl std::error::Error for InvalidLocalDate {}
229
230impl FromStr for LocalDate {
231    type Err = InvalidLocalDate;
232    fn from_str(s: &str) -> Result<Self, Self::Err> {
233        let bad = || InvalidLocalDate(format!("{s:?} is not \"YYYY-MM-DD\""));
234        let b = s.as_bytes();
235        if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
236            return Err(bad());
237        }
238        if !s[0..4].bytes().chain(s[5..7].bytes()).chain(s[8..10].bytes()).all(|c| c.is_ascii_digit()) {
239            return Err(bad());
240        }
241        Self::new(
242            s[0..4].parse().map_err(|_| bad())?,
243            s[5..7].parse().map_err(|_| bad())?,
244            s[8..10].parse().map_err(|_| bad())?,
245        )
246    }
247}
248
249impl Validate for LocalDate {
250    fn validate_in(&self, _v: &mut Validator) {}
251}
252
253impl Serialize for LocalDate {
254    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
255        s.collect_str(self)
256    }
257}
258impl<'de> Deserialize<'de> for LocalDate {
259    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
260        let raw = String::deserialize(d)?;
261        raw.parse().map_err(serde::de::Error::custom)
262    }
263}
264
265#[cfg(feature = "schema")]
266impl schemars::JsonSchema for LocalDate {
267    fn schema_name() -> std::borrow::Cow<'static, str> {
268        "LocalDate".into()
269    }
270    fn json_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
271        schemars::json_schema!({ "type": "string", "format": "date", "maxLength": 10 })
272    }
273}
274
275/// A wall-clock reading: the date, the time of day, and the day of the week.
276///
277/// What an OCPI restriction is written against. `start_time`, `end_time` and `day_of_week` are all
278/// *"in local time, the time zone is defined in the `time_zone` field of the Location"*, so a
279/// consumer needs the instant converted, not the instant.
280///
281/// Produced by [`TimeZone::to_local`](crate::tariffs::TimeZone::to_local). The fields are this
282/// crate's own types, so no date-time library appears in the API and the backend stays
283/// replaceable.
284#[derive(Clone, Copy, Debug, PartialEq, Eq)]
285pub struct LocalParts {
286    /// The local calendar date.
287    pub date: LocalDate,
288    /// The local time of day, to the minute.
289    pub time: LocalTime,
290    /// The ISO-8601 day of the week: Monday is 1, Sunday is 7.
291    pub iso_weekday: u8,
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn time_of_day_requires_leading_zeros() {
300        assert_eq!("08:15".parse::<LocalTime>().unwrap().to_string(), "08:15");
301        for bad in ["8:15", "08:5", "24:00", "12:60", "0815", ""] {
302            assert!(bad.parse::<LocalTime>().is_err(), "{bad} should not parse");
303        }
304    }
305
306    #[test]
307    fn windows_wrap_around_midnight() {
308        let t = |s: &str| s.parse::<LocalTime>().unwrap();
309        // 09:00-18:00, a normal daytime window.
310        assert!(t("09:00").is_within(t("09:00"), t("18:00")));
311        assert!(!t("18:00").is_within(t("09:00"), t("18:00")), "end is exclusive");
312        assert!(!t("08:59").is_within(t("09:00"), t("18:00")));
313        // 22:00-06:00 wraps to the next day.
314        assert!(t("23:30").is_within(t("22:00"), t("06:00")));
315        assert!(t("05:59").is_within(t("22:00"), t("06:00")));
316        assert!(!t("12:00").is_within(t("22:00"), t("06:00")));
317        // "To stop at end of the day use: 00:00."
318        assert!(t("23:59").is_within(t("18:00"), t("00:00")));
319        assert!(!t("17:59").is_within(t("18:00"), t("00:00")));
320    }
321
322    #[test]
323    fn a_window_whose_ends_coincide_is_the_whole_day() {
324        // The spec leaves this open; reading it as an empty window would make a tariff element
325        // restricted to 00:00-00:00 never match, and the dimension it prices free of charge.
326        let t = |s: &str| s.parse::<LocalTime>().unwrap();
327        for probe in ["00:00", "09:30", "23:59"] {
328            assert!(t(probe).is_within(t("00:00"), t("00:00")), "{probe} is inside an all-day window");
329            assert!(t(probe).is_within(t("09:00"), t("09:00")), "{probe} is inside a wrapped full day");
330        }
331    }
332
333    #[test]
334    fn dates_must_exist() {
335        assert_eq!("2015-12-24".parse::<LocalDate>().unwrap().to_string(), "2015-12-24");
336        assert!("2015-02-30".parse::<LocalDate>().is_err());
337        assert!("2016-02-29".parse::<LocalDate>().is_ok(), "2016 is a leap year");
338        assert!("15-02-01".parse::<LocalDate>().is_err());
339    }
340
341    #[test]
342    fn serde_uses_the_wire_form() {
343        let t: LocalTime = serde_json::from_str("\"18:15\"").unwrap();
344        assert_eq!(serde_json::to_string(&t).unwrap(), "\"18:15\"");
345        let d: LocalDate = serde_json::from_str("\"2015-12-24\"").unwrap();
346        assert_eq!(serde_json::to_string(&d).unwrap(), "\"2015-12-24\"");
347    }
348}