1use core::fmt;
11use core::str::FromStr;
12
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15use super::validate::{Validate, Validator};
16
17#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
24pub struct LocalTime {
25 hour: u8,
26 minute: u8,
27}
28
29impl LocalTime {
30 pub const MIDNIGHT: Self = Self { hour: 0, minute: 0 };
32
33 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 #[must_use]
47 pub const fn hour(self) -> u8 {
48 self.hour
49 }
50
51 #[must_use]
53 pub const fn minute(self) -> u8 {
54 self.minute
55 }
56
57 #[must_use]
59 pub const fn minutes_since_midnight(self) -> u16 {
60 self.hour as u16 * 60 + self.minute as u16
61 }
62
63 #[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#[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 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#[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 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 #[must_use]
187 pub const fn year(self) -> i32 {
188 self.year
189 }
190 #[must_use]
192 pub const fn month(self) -> u8 {
193 self.month
194 }
195 #[must_use]
197 pub const fn day(self) -> u8 {
198 self.day
199 }
200
201 #[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
285pub struct LocalParts {
286 pub date: LocalDate,
288 pub time: LocalTime,
290 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 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 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 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 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}