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