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]
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 #[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#[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 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 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 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 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}