1use std::fmt;
2
3use jiff::civil;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use sofie_core::{Cast, ColumnReader, ColumnSpec, Error, Ordered, Result, Show, Shown, SqlValue};
6
7#[cfg(feature = "web")]
8use crate::Conventions;
9use crate::parsed::deserialize_parsed;
10use crate::{Input, Present};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
14pub struct TimeOfDay {
15 time: civil::Time,
16}
17
18impl TimeOfDay {
19 pub fn try_new(hour: i8, minute: i8) -> Result<Self> {
20 civil::Time::new(hour, minute, 0, 0)
21 .map(|time| Self { time })
22 .map_err(|_| invalid_time())
23 }
24
25 pub fn parse(text: &str) -> Result<Self> {
26 let (hour, minute) = read(text.as_bytes()).ok_or_else(invalid_time)?;
27 Ok(Self::at(hour, minute))
28 }
29
30 #[must_use]
36 pub const fn constant(text: &str) -> Self {
37 match read(text.as_bytes()) {
38 Some((hour, minute)) => Self::at(hour, minute),
39 None => panic!("a time of day is written HH:MM, from 00:00 to 23:59"),
40 }
41 }
42
43 const fn at(hour: i8, minute: i8) -> Self {
44 Self {
45 time: civil::Time::constant(hour, minute, 0, 0),
46 }
47 }
48
49 #[must_use]
50 pub fn hour(self) -> i8 {
51 self.time.hour()
52 }
53
54 #[must_use]
55 pub fn minute(self) -> i8 {
56 self.time.minute()
57 }
58
59 #[must_use]
60 pub fn civil(self) -> civil::Time {
61 self.time
62 }
63}
64
65const fn read(text: &[u8]) -> Option<(i8, i8)> {
66 let [hour_tens, hour_ones, b':', minute_tens, minute_ones] = *text else {
67 return None;
68 };
69 let (Some(hour), Some(minute)) = (
70 two_digits(hour_tens, hour_ones),
71 two_digits(minute_tens, minute_ones),
72 ) else {
73 return None;
74 };
75 if hour > 23 || minute > 59 {
76 return None;
77 }
78 Some((hour, minute))
79}
80
81const fn two_digits(tens: u8, ones: u8) -> Option<i8> {
82 if !tens.is_ascii_digit() || !ones.is_ascii_digit() {
83 return None;
84 }
85 #[expect(
86 clippy::cast_possible_wrap,
87 reason = "two decimal digits are at most 99"
88 )]
89 Some(((tens - b'0') * 10 + (ones - b'0')) as i8)
90}
91
92fn invalid_time() -> Error {
93 Error::invalid("time_of_day", "must be a time such as 09:30")
94}
95
96impl fmt::Display for TimeOfDay {
97 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98 write!(formatter, "{:02}:{:02}", self.hour(), self.minute())
99 }
100}
101
102impl Ordered for TimeOfDay {}
103
104impl Show for TimeOfDay {
105 fn show(&self) -> Shown<'_> {
106 Shown::Formatted(self)
107 }
108}
109
110impl Cast for TimeOfDay {
111 const COLUMNS: &'static [ColumnSpec] = String::COLUMNS;
112
113 fn encode(&self, out: &mut Vec<SqlValue>) {
114 out.push(SqlValue::Text(self.to_string()));
115 }
116
117 fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
118 sofie_core::value::stored(Self::parse(row.text()?))
119 }
120}
121
122impl Serialize for TimeOfDay {
123 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
124 serializer.collect_str(self)
125 }
126}
127
128impl<'de> Deserialize<'de> for TimeOfDay {
129 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
130 deserialize_parsed(deserializer, "a time of day", Self::parse)
131 }
132}
133
134impl Present for TimeOfDay {
135 const INPUT: Input = Input::typed(&[("type", "time")]);
136 const SEARCH: crate::Search = crate::Search::Time;
137
138 fn csv(&self) -> String {
139 self.to_string()
140 }
141
142 #[cfg(feature = "web")]
143 fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
144 conventions.write_date(out, &self.csv());
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::testing::{assert_refused, round_trip};
152
153 #[test]
154 fn reads_exactly_hours_and_minutes() {
155 let time = TimeOfDay::parse("09:05").unwrap();
156
157 assert_eq!((time.hour(), time.minute()), (9, 5));
158 assert_eq!(TimeOfDay::parse("23:59").unwrap().to_string(), "23:59");
159 assert_eq!(TimeOfDay::try_new(9, 5).unwrap(), time);
160 assert_eq!(
161 TimeOfDay::constant("00:00"),
162 TimeOfDay::try_new(0, 0).unwrap()
163 );
164 assert_refused(
165 TimeOfDay::parse,
166 &[
167 "24:00", "12:60", "9:30", "09:30:00", "0930", " 09:30", "", "ab:cd",
168 ],
169 );
170 assert!(TimeOfDay::try_new(24, 0).is_err());
171 }
172
173 #[test]
174 #[should_panic(expected = "a time of day is written HH:MM")]
175 fn a_wrong_constant_time_fails() {
176 let _ = TimeOfDay::constant("25:00");
177 }
178
179 #[test]
180 fn round_trips_through_a_text_column_and_json_and_sorts_as_it_reads() {
181 let time = TimeOfDay::parse("07:45").unwrap();
182
183 assert_eq!(round_trip(&time), vec![SqlValue::Text("07:45".into())]);
184 let json = serde_json::to_value(time).unwrap();
185 assert_eq!(json, serde_json::json!("07:45"));
186 assert_eq!(serde_json::from_value::<TimeOfDay>(json).unwrap(), time);
187 assert!(serde_json::from_value::<TimeOfDay>(serde_json::json!("7:45")).is_err());
188 assert!(time < TimeOfDay::parse("10:00").unwrap());
189 assert_eq!(TimeOfDay::INPUT.attributes, &[("type", "time")]);
190 }
191}