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