Skip to main content

sofie_values/time/
time_of_day.rs

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/// To the minute, as a time input posts it; stored as `HH:MM` text, which sorts as it reads.
13#[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    /// For a time written in code, such as a schedule's: a wrong one fails the build in a constant.
31    ///
32    /// ```compile_fail,E0080
33    /// const _: () = { let _ = sofie_values::TimeOfDay::constant("25:00"); };
34    /// ```
35    #[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}