quick_m3u8/
date.rs

1//! Constructs to reason about date and time in HLS
2//!
3//! The structs offered here don't provide much functionality. The purpose is primarily
4//! informational. These types can be used with another date/time library (such as [chrono]) for
5//! more feature rich date/time comparisons and operations.
6//!
7//! [chrono]: https://crates.io/crates/chrono
8
9use crate::{error::DateTimeSyntaxError, utils::parse_date_time_bytes};
10use std::fmt::Display;
11
12/// A macro to help constructing a [`DateTime`] struct.
13///
14/// Given that there are a lot of fields to the `DateTime` struct, for convenience this macro is
15/// provided, so a date can be constructed more easily. The syntax is intended to mimic [RFC3339].
16/// For example:
17/// ```
18/// # use quick_m3u8::{date_time, date::{DateTime, DateTimeTimezoneOffset}};
19/// assert_eq!(
20///     date_time!(2025-07-30 T 22:44:38.718 -05:00),
21///     DateTime {
22///         date_fullyear: 2025,
23///         date_month: 7,
24///         date_mday: 30,
25///         time_hour: 22,
26///         time_minute: 44,
27///         time_second: 38.718,
28///         timezone_offset: DateTimeTimezoneOffset {
29///             time_hour: -5,
30///             time_minute: 0,
31///         },
32///     }
33/// )
34/// ```
35///
36/// ## Input validation
37///
38/// The macro is also able to validate input looks correct (with the exception of the `$D` parameter
39/// which depends on which month is used, so it just validates that the value passed is less than
40/// 31).
41///
42/// Each of the following will fail compilation (thus providing some compile-time safety to usage):
43/// ```compile_fail
44/// # use quick_m3u8::date_time;
45/// let bad_date = date_time!(10000-01-01 T 00:00:00.000);       // Year greater than 4 digits
46/// ```
47/// ```compile_fail
48/// # use quick_m3u8::date_time;
49/// let bad_date = date_time!(1970-00-01 T 00:00:00.000);        // Month not greater than 0
50/// ```
51/// ```compile_fail
52/// # use quick_m3u8::date_time;
53/// let bad_date = date_time!(1970-13-01 T 00:00:00.000);        // Month greater than 12
54/// ```
55/// ```compile_fail
56/// # use quick_m3u8::date_time;
57/// let bad_date = date_time!(1970-01-00 T 00:00:00.000);        // Day not greater than 0
58/// ```
59/// ```compile_fail
60/// # use quick_m3u8::date_time;
61/// let bad_date = date_time!(1970-01-32 T 00:00:00.000);        // Day greater than 31
62/// ```
63/// ```compile_fail
64/// # use quick_m3u8::date_time;
65/// let bad_date = date_time!(1970-01-01 T 24:00:00.000);        // Hour greater than 23
66/// ```
67/// ```compile_fail
68/// # use quick_m3u8::date_time;
69/// let bad_date = date_time!(1970-01-01 T 00:60:00.000);        // Minute greater than 59
70/// ```
71/// ```compile_fail
72/// # use quick_m3u8::date_time;
73/// let bad_date = date_time!(1970-01-01 T 00:00:-1.000);        // Seconds negative
74/// ```
75/// ```compile_fail
76/// # use quick_m3u8::date_time;
77/// let bad_date = date_time!(1970-01-01 T 00:00:60.000);        // Seconds greater than 59
78/// ```
79/// ```compile_fail
80/// # use quick_m3u8::date_time;
81/// let bad_date = date_time!(1970-01-01 T 00:00:00.000 -24:00); // Hour offset less than -23
82/// ```
83/// ```compile_fail
84/// # use quick_m3u8::date_time;
85/// let bad_date = date_time!(1970-01-01 T 00:00:00.000 24:00);  // Hour offset more than 23
86/// ```
87/// ```compile_fail
88/// # use quick_m3u8::date_time;
89/// let bad_date = date_time!(1970-01-01 T 00:00:00.000 00:60);  // Minute offset more than 59
90/// ```
91///
92/// [RFC3339]: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6
93#[macro_export]
94macro_rules! date_time {
95    ($Y:literal-$M:literal-$D:literal T $h:literal:$m:literal:$s:literal) => {
96        date_time!($Y-$M-$D T $h:$m:$s 0:0)
97    };
98    ($Y:literal-$M:literal-$D:literal T $h:literal:$m:literal:$s:literal $x:literal:$y:literal) => {{
99        const _: () = assert!($Y <= 9999, "Year must be at most 4 digits");
100        const _: () = assert!($M > 0, "Month must be greater than 0");
101        const _: () = assert!($M <= 12, "Month must be less than or equal to 12");
102        const _: () = assert!($D > 0, "Day must be greater than 0");
103        const _: () = assert!($D <= 31, "Day must be less than or equal to 31");
104        const _: () = assert!($h < 24, "Hour must be less than 24");
105        const _: () = assert!($m < 60, "Minute must be less than 60");
106        const _: () = assert!($s >= 0.0, "Seconds must be positive");
107        const _: () = assert!($s < 60.0, "Seconds must be less than 60.0");
108        const _: () = assert!($x > -24, "Hour offset must be greater than -24");
109        const _: () = assert!($x < 24, "Hour offset must be less than 24");
110        const _: () = assert!($y < 60, "Minute offset must be less than 60");
111        $crate::date::DateTime {
112            date_fullyear: $Y,
113            date_month: $M,
114            date_mday: $D,
115            time_hour: $h,
116            time_minute: $m,
117            time_second: $s,
118            timezone_offset: $crate::date::DateTimeTimezoneOffset {
119                time_hour: $x,
120                time_minute: $y,
121            },
122        }
123    }};
124}
125
126/// A struct representing a date in the format of [RFC3339].
127///
128/// [RFC3339]: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6
129#[derive(Debug, PartialEq, Clone, Copy)]
130pub struct DateTime {
131    /// The full year (must be `4DIGIT`).
132    pub date_fullyear: u32,
133    /// The month (`1-12`).
134    pub date_month: u8,
135    /// The day (`1-31`).
136    pub date_mday: u8,
137    /// The hour (`0-23`).
138    pub time_hour: u8,
139    /// The minute (`0-59`).
140    pub time_minute: u8,
141    /// The seconds, including millisconds (seconds are `0-59`, while the mantissa may be any
142    /// length, though HLS recommends milliscond accuracy via the [EXT-X-PROGRAM-DATE-TIME]
143    /// documentation).
144    ///
145    /// [EXT-X-PROGRAM-DATE-TIME]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-18#section-4.4.4.6
146    pub time_second: f64,
147    /// The timezone offset.
148    pub timezone_offset: DateTimeTimezoneOffset,
149}
150
151impl Display for DateTime {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        write!(
154            f,
155            "{:04}-{:02}-{:02}T{:02}:{:02}:{:06.3}{}",
156            self.date_fullyear,
157            self.date_month,
158            self.date_mday,
159            self.time_hour,
160            self.time_minute,
161            self.time_second,
162            self.timezone_offset
163        )
164    }
165}
166
167impl From<DateTime> for String {
168    fn from(value: DateTime) -> Self {
169        format!("{value}")
170    }
171}
172
173impl Default for DateTime {
174    fn default() -> Self {
175        Self {
176            date_fullyear: 1970,
177            date_month: 1,
178            date_mday: 1,
179            time_hour: 0,
180            time_minute: 0,
181            time_second: 0.0,
182            timezone_offset: Default::default(),
183        }
184    }
185}
186
187/// The timezone offset.
188#[derive(Debug, PartialEq, Clone, Copy, Default)]
189pub struct DateTimeTimezoneOffset {
190    /// The hour offset (plus or minus `0-23`).
191    pub time_hour: i8,
192    /// The minute offset (`0-59`).
193    pub time_minute: u8,
194}
195
196impl Display for DateTimeTimezoneOffset {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        if self.time_hour == 0 && self.time_minute == 0 {
199            write!(f, "Z")
200        } else {
201            write!(f, "{:+03}:{:02}", self.time_hour, self.time_minute)
202        }
203    }
204}
205
206impl From<DateTimeTimezoneOffset> for String {
207    fn from(value: DateTimeTimezoneOffset) -> Self {
208        format!("{value}")
209    }
210}
211
212/// Parses a string slice into a `DateTime`.
213pub fn parse(input: &str) -> Result<DateTime, DateTimeSyntaxError> {
214    parse_bytes(input.as_bytes())
215}
216
217/// Parses a byte slice into a `DateTime`.
218pub fn parse_bytes(input: &[u8]) -> Result<DateTime, DateTimeSyntaxError> {
219    Ok(parse_date_time_bytes(input)?.parsed)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use pretty_assertions::assert_eq;
226
227    #[test]
228    fn no_timezone() {
229        assert_eq!(
230            date_time!(2025-06-04 T 13:50:42.148),
231            parse("2025-06-04T13:50:42.148Z").unwrap()
232        );
233    }
234
235    #[test]
236    fn plus_timezone() {
237        assert_eq!(
238            date_time!(2025-06-04 T 13:50:42.148 03:00),
239            parse("2025-06-04T13:50:42.148+03:00").unwrap()
240        );
241    }
242
243    #[test]
244    fn negative_timezone() {
245        assert_eq!(
246            date_time!(2025-06-04 T 13:50:42.148 -01:30),
247            parse("2025-06-04T13:50:42.148-01:30").unwrap()
248        );
249    }
250
251    #[test]
252    fn no_fractional_seconds() {
253        assert_eq!(
254            date_time!(2025-06-04 T 13:50:42.0),
255            parse("2025-06-04T13:50:42Z").unwrap()
256        );
257    }
258
259    #[test]
260    fn string_from_single_digit_dates_should_be_valid() {
261        assert_eq!(
262            String::from("2025-06-04T13:50:42.123Z"),
263            String::from(date_time!(2025-06-04 T 13:50:42.123))
264        )
265    }
266
267    #[test]
268    fn string_from_no_fractional_seconds_should_still_be_3_decimals_precise() {
269        assert_eq!(
270            String::from("2025-06-04T13:50:42.000Z"),
271            String::from(date_time!(2025-06-04 T 13:50:42.0))
272        )
273    }
274
275    #[test]
276    fn string_from_single_digit_times_should_be_valid() {
277        assert_eq!(
278            String::from("2025-12-25T04:00:02.000Z"),
279            String::from(date_time!(2025-12-25 T 04:00:02.000))
280        )
281    }
282
283    #[test]
284    fn string_from_negative_time_offset_should_be_valid() {
285        assert_eq!(
286            String::from("2025-06-04T13:50:42.123-05:00"),
287            String::from(date_time!(2025-06-04 T 13:50:42.123 -05:00))
288        )
289    }
290
291    #[test]
292    fn string_from_positive_offset_should_be_valid() {
293        assert_eq!(
294            String::from("2025-06-04T13:50:42.100+01:00"),
295            String::from(date_time!(2025-06-04 T 13:50:42.100 01:00))
296        )
297    }
298
299    #[test]
300    fn string_from_positive_offset_non_zero_minutes_should_be_valid() {
301        assert_eq!(
302            String::from("2025-06-04T13:50:42.010+06:30"),
303            String::from(date_time!(2025-06-04 T 13:50:42.010 06:30))
304        )
305    }
306
307    #[test]
308    fn date_time_macro_should_work_with_no_offset() {
309        assert_eq!(
310            date_time!(2025-06-22 T 22:13:42.000),
311            DateTime {
312                date_fullyear: 2025,
313                date_month: 6,
314                date_mday: 22,
315                time_hour: 22,
316                time_minute: 13,
317                time_second: 42.0,
318                timezone_offset: DateTimeTimezoneOffset {
319                    time_hour: 0,
320                    time_minute: 0
321                }
322            }
323        );
324    }
325
326    #[test]
327    fn date_time_macro_should_work_with_positive_offset() {
328        assert_eq!(
329            date_time!(2025-06-22 T 22:13:42.000 01:00),
330            DateTime {
331                date_fullyear: 2025,
332                date_month: 6,
333                date_mday: 22,
334                time_hour: 22,
335                time_minute: 13,
336                time_second: 42.0,
337                timezone_offset: DateTimeTimezoneOffset {
338                    time_hour: 1,
339                    time_minute: 0
340                }
341            }
342        );
343    }
344
345    #[test]
346    fn date_time_macro_should_work_with_negative_offset() {
347        assert_eq!(
348            date_time!(2025-06-22 T 22:13:42.000 -01:30),
349            DateTime {
350                date_fullyear: 2025,
351                date_month: 6,
352                date_mday: 22,
353                time_hour: 22,
354                time_minute: 13,
355                time_second: 42.0,
356                timezone_offset: DateTimeTimezoneOffset {
357                    time_hour: -1,
358                    time_minute: 30
359                }
360            }
361        );
362    }
363}