Skip to main content

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;
10#[cfg(not(feature = "chrono"))]
11use crate::utils::parse_date_time_bytes;
12#[cfg(not(feature = "chrono"))]
13use std::fmt::Display;
14
15#[cfg(feature = "chrono")]
16/// A macro to help constructing a [`chrono::DateTime`] struct.
17///
18/// Given that there are a lot of fields to the `DateTime` struct, for convenience this macro is
19/// provided, so a date can be constructed more easily. The syntax is intended to mimic [RFC3339].
20/// For example:
21/// ```
22/// # use quick_m3u8::date_time;
23/// assert_eq!(
24///     date_time!(2025-07-30 T 22:44:38.718 -05:00),
25///     chrono::NaiveDate::from_ymd_opt(2025, 7, 30).unwrap()
26///         .and_hms_milli_opt(22, 44, 38, 718).unwrap()
27///         .and_local_timezone(chrono::FixedOffset::west_opt(5 * 3600).unwrap())
28///         .earliest().unwrap()
29/// )
30/// ```
31///
32/// ## Input validation
33///
34/// The macro is also able to validate input looks correct (with the exception of the `$D` parameter
35/// which depends on which month is used, so it just validates that the value passed is less than
36/// 31).
37///
38/// Each of the following will fail compilation (thus providing some compile-time safety to usage):
39/// ```compile_fail
40/// # use quick_m3u8::date_time;
41/// let bad_date = date_time!(1970-00-01 T 00:00:00.000);        // Month not greater than 0
42/// ```
43/// ```compile_fail
44/// # use quick_m3u8::date_time;
45/// let bad_date = date_time!(1970-13-01 T 00:00:00.000);        // Month greater than 12
46/// ```
47/// ```compile_fail
48/// # use quick_m3u8::date_time;
49/// let bad_date = date_time!(1970-01-00 T 00:00:00.000);        // Day not greater than 0
50/// ```
51/// ```compile_fail
52/// # use quick_m3u8::date_time;
53/// let bad_date = date_time!(1970-01-32 T 00:00:00.000);        // Day greater than 31
54/// ```
55/// ```compile_fail
56/// # use quick_m3u8::date_time;
57/// let bad_date = date_time!(1970-01-01 T 24:00:00.000);        // Hour greater than 23
58/// ```
59/// ```compile_fail
60/// # use quick_m3u8::date_time;
61/// let bad_date = date_time!(1970-01-01 T 00:60:00.000);        // Minute greater than 59
62/// ```
63/// ```compile_fail
64/// # use quick_m3u8::date_time;
65/// let bad_date = date_time!(1970-01-01 T 00:00:-1.000);        // Seconds negative
66/// ```
67/// ```compile_fail
68/// # use quick_m3u8::date_time;
69/// let bad_date = date_time!(1970-01-01 T 00:00:60.000);        // Seconds 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:00.000 -24:00); // Hour offset less than -23
74/// ```
75/// ```compile_fail
76/// # use quick_m3u8::date_time;
77/// let bad_date = date_time!(1970-01-01 T 00:00:00.000 24:00);  // Hour offset more than 23
78/// ```
79///
80/// [RFC3339]: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6
81#[macro_export]
82macro_rules! date_time {
83    ($Y:literal-$M:literal-$D:literal T $h:literal:$m:literal:$s:literal) => {{
84    const D: chrono::NaiveDate = date_time!(@INTERNAL @DATE $Y-$M-$D);
85    const T: chrono::NaiveTime = date_time!(@INTERNAL @TIME $h:$m:$s);
86        D.and_time(T).and_utc().fixed_offset()
87    }};
88    ($Y:literal-$M:literal-$D:literal T $h:literal:$m:literal:$s:literal $x:literal:$y:literal) => {{
89    const D: chrono::NaiveDate = date_time!(@INTERNAL @DATE $Y-$M-$D);
90    const T: chrono::NaiveTime = date_time!(@INTERNAL @TIME $h:$m:$s);
91    const TZ: chrono::FixedOffset = date_time!(@INTERNAL @TIMEZONE $x:$y);
92        // The rest may panic at runtime.
93        D.and_time(T).and_local_timezone(TZ).earliest().unwrap()
94    }};
95    (@INTERNAL @DATE $Y:literal-$M:literal-$D:literal) => {{
96        const D: Option<chrono::NaiveDate> = chrono::NaiveDate::from_ymd_opt($Y, $M, $D);
97        const _: () = assert!(D.is_some(), "Invalid date");
98        D.unwrap()
99    }};
100    (@INTERNAL @TIME $h:literal:$m:literal:$s:literal) => {{
101        const _: () = assert!($s >= 0.0, "Seconds must be positive");
102        const S: u32 = $s as u32;
103        const MS: u32 = (($s * 1000.0 as f64).round() % 1000.0) as u32;
104        const T: Option<chrono::NaiveTime> = chrono::NaiveTime::from_hms_milli_opt($h, $m, S, MS);
105        const _: () = assert!(T.is_some(), "Invalid time");
106        T.unwrap()
107    }};
108    (@INTERNAL @TIMEZONE $x:literal:$y:literal) => {{
109        const _: () = assert!($y >= 0, "Minutes must be positive");
110        const TZ_H: i32 = ($x as i32).abs() as i32;
111        const TZ_M: i32 = $y as i32;
112        const MULTIPLIER: i32 = if $x == TZ_H { 1 } else { -1 };
113        const TZ: Option<chrono::FixedOffset> =
114            chrono::FixedOffset::east_opt(MULTIPLIER * ((TZ_H * 3600) + (TZ_M * 60)));
115        const _: () = assert!(TZ.is_some(), "Invalid timezone offset");
116        TZ.unwrap()
117    }};
118}
119#[cfg(not(feature = "chrono"))]
120/// A macro to help constructing a [`DateTime`] struct.
121///
122/// Given that there are a lot of fields to the `DateTime` struct, for convenience this macro is
123/// provided, so a date can be constructed more easily. The syntax is intended to mimic [RFC3339].
124/// For example:
125/// ```
126/// # use quick_m3u8::{date_time, date::{DateTime, DateTimeTimezoneOffset}};
127/// assert_eq!(
128///     date_time!(2025-07-30 T 22:44:38.718 -05:00),
129///     DateTime {
130///         date_fullyear: 2025,
131///         date_month: 7,
132///         date_mday: 30,
133///         time_hour: 22,
134///         time_minute: 44,
135///         time_second: 38.718,
136///         timezone_offset: DateTimeTimezoneOffset {
137///             time_hour: -5,
138///             time_minute: 0,
139///         },
140///     }
141/// )
142/// ```
143///
144/// ## Input validation
145///
146/// The macro is also able to validate input looks correct (with the exception of the `$D` parameter
147/// which depends on which month is used, so it just validates that the value passed is less than
148/// 31).
149///
150/// Each of the following will fail compilation (thus providing some compile-time safety to usage):
151/// ```compile_fail
152/// # use quick_m3u8::date_time;
153/// let bad_date = date_time!(10000-01-01 T 00:00:00.000);       // Year greater than 4 digits
154/// ```
155/// ```compile_fail
156/// # use quick_m3u8::date_time;
157/// let bad_date = date_time!(1970-00-01 T 00:00:00.000);        // Month not greater than 0
158/// ```
159/// ```compile_fail
160/// # use quick_m3u8::date_time;
161/// let bad_date = date_time!(1970-13-01 T 00:00:00.000);        // Month greater than 12
162/// ```
163/// ```compile_fail
164/// # use quick_m3u8::date_time;
165/// let bad_date = date_time!(1970-01-00 T 00:00:00.000);        // Day not greater than 0
166/// ```
167/// ```compile_fail
168/// # use quick_m3u8::date_time;
169/// let bad_date = date_time!(1970-01-32 T 00:00:00.000);        // Day greater than 31
170/// ```
171/// ```compile_fail
172/// # use quick_m3u8::date_time;
173/// let bad_date = date_time!(1970-01-01 T 24:00:00.000);        // Hour greater than 23
174/// ```
175/// ```compile_fail
176/// # use quick_m3u8::date_time;
177/// let bad_date = date_time!(1970-01-01 T 00:60:00.000);        // Minute greater than 59
178/// ```
179/// ```compile_fail
180/// # use quick_m3u8::date_time;
181/// let bad_date = date_time!(1970-01-01 T 00:00:-1.000);        // Seconds negative
182/// ```
183/// ```compile_fail
184/// # use quick_m3u8::date_time;
185/// let bad_date = date_time!(1970-01-01 T 00:00:60.000);        // Seconds greater than 59
186/// ```
187/// ```compile_fail
188/// # use quick_m3u8::date_time;
189/// let bad_date = date_time!(1970-01-01 T 00:00:00.000 -24:00); // Hour offset less than -23
190/// ```
191/// ```compile_fail
192/// # use quick_m3u8::date_time;
193/// let bad_date = date_time!(1970-01-01 T 00:00:00.000 24:00);  // Hour offset more than 23
194/// ```
195/// ```compile_fail
196/// # use quick_m3u8::date_time;
197/// let bad_date = date_time!(1970-01-01 T 00:00:00.000 00:60);  // Minute offset more than 59
198/// ```
199///
200/// [RFC3339]: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6
201#[macro_export]
202macro_rules! date_time {
203    ($Y:literal-$M:literal-$D:literal T $h:literal:$m:literal:$s:literal) => {
204        date_time!($Y-$M-$D T $h:$m:$s 0:0)
205    };
206    ($Y:literal-$M:literal-$D:literal T $h:literal:$m:literal:$s:literal $x:literal:$y:literal) => {{
207        const _: () = assert!($Y <= 9999, "Year must be at most 4 digits");
208        const _: () = assert!($M > 0, "Month must be greater than 0");
209        const _: () = assert!($M <= 12, "Month must be less than or equal to 12");
210        const _: () = assert!($D > 0, "Day must be greater than 0");
211        const _: () = assert!($D <= 31, "Day must be less than or equal to 31");
212        const _: () = assert!($h < 24, "Hour must be less than 24");
213        const _: () = assert!($m < 60, "Minute must be less than 60");
214        const _: () = assert!($s >= 0.0, "Seconds must be positive");
215        const _: () = assert!($s < 60.0, "Seconds must be less than 60.0");
216        const _: () = assert!($x > -24, "Hour offset must be greater than -24");
217        const _: () = assert!($x < 24, "Hour offset must be less than 24");
218        const _: () = assert!($y < 60, "Minute offset must be less than 60");
219        $crate::date::DateTime {
220            date_fullyear: $Y,
221            date_month: $M,
222            date_mday: $D,
223            time_hour: $h,
224            time_minute: $m,
225            time_second: $s,
226            timezone_offset: $crate::date::DateTimeTimezoneOffset {
227                time_hour: $x,
228                time_minute: $y,
229            },
230        }
231    }};
232}
233
234#[cfg(not(feature = "chrono"))]
235/// A struct representing a date in the format of [RFC3339].
236///
237/// [RFC3339]: https://datatracker.ietf.org/doc/html/rfc3339#section-5.6
238#[derive(Debug, PartialEq, Clone, Copy)]
239pub struct DateTime {
240    /// The full year (must be `4DIGIT`).
241    pub date_fullyear: u32,
242    /// The month (`1-12`).
243    pub date_month: u8,
244    /// The day (`1-31`).
245    pub date_mday: u8,
246    /// The hour (`0-23`).
247    pub time_hour: u8,
248    /// The minute (`0-59`).
249    pub time_minute: u8,
250    /// The seconds, including millisconds (seconds are `0-59`, while the mantissa may be any
251    /// length, though HLS recommends milliscond accuracy via the [EXT-X-PROGRAM-DATE-TIME]
252    /// documentation).
253    ///
254    /// [EXT-X-PROGRAM-DATE-TIME]: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-18#section-4.4.4.6
255    pub time_second: f64,
256    /// The timezone offset.
257    pub timezone_offset: DateTimeTimezoneOffset,
258}
259
260#[cfg(not(feature = "chrono"))]
261impl Display for DateTime {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        write!(
264            f,
265            "{:04}-{:02}-{:02}T{:02}:{:02}:{:06.3}{}",
266            self.date_fullyear,
267            self.date_month,
268            self.date_mday,
269            self.time_hour,
270            self.time_minute,
271            self.time_second,
272            self.timezone_offset
273        )
274    }
275}
276
277#[cfg(not(feature = "chrono"))]
278impl From<DateTime> for String {
279    fn from(value: DateTime) -> Self {
280        format!("{value}")
281    }
282}
283
284#[cfg(not(feature = "chrono"))]
285impl Default for DateTime {
286    fn default() -> Self {
287        Self {
288            date_fullyear: 1970,
289            date_month: 1,
290            date_mday: 1,
291            time_hour: 0,
292            time_minute: 0,
293            time_second: 0.0,
294            timezone_offset: Default::default(),
295        }
296    }
297}
298
299#[cfg(not(feature = "chrono"))]
300/// The timezone offset.
301#[derive(Debug, PartialEq, Clone, Copy, Default)]
302pub struct DateTimeTimezoneOffset {
303    /// The hour offset (plus or minus `0-23`).
304    pub time_hour: i8,
305    /// The minute offset (`0-59`).
306    pub time_minute: u8,
307}
308
309#[cfg(not(feature = "chrono"))]
310impl Display for DateTimeTimezoneOffset {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        if self.time_hour == 0 && self.time_minute == 0 {
313            write!(f, "Z")
314        } else {
315            write!(f, "{:+03}:{:02}", self.time_hour, self.time_minute)
316        }
317    }
318}
319
320#[cfg(not(feature = "chrono"))]
321impl From<DateTimeTimezoneOffset> for String {
322    fn from(value: DateTimeTimezoneOffset) -> Self {
323        format!("{value}")
324    }
325}
326
327#[cfg(feature = "chrono")]
328/// Parses a string slice into a `DateTime`.
329pub fn parse(input: &str) -> Result<chrono::DateTime<chrono::FixedOffset>, DateTimeSyntaxError> {
330    chrono::DateTime::parse_from_rfc3339(input).map_err(DateTimeSyntaxError::from)
331}
332#[cfg(not(feature = "chrono"))]
333/// Parses a string slice into a `DateTime`.
334pub fn parse(input: &str) -> Result<DateTime, DateTimeSyntaxError> {
335    parse_bytes(input.as_bytes())
336}
337
338#[cfg(feature = "chrono")]
339/// Parses a byte slice into a `DateTime`.
340pub fn parse_bytes(
341    input: &[u8],
342) -> Result<chrono::DateTime<chrono::FixedOffset>, DateTimeSyntaxError> {
343    let input_str = str::from_utf8(input)?;
344    parse(input_str)
345}
346#[cfg(not(feature = "chrono"))]
347/// Parses a byte slice into a `DateTime`.
348pub fn parse_bytes(input: &[u8]) -> Result<DateTime, DateTimeSyntaxError> {
349    Ok(parse_date_time_bytes(input)?.parsed)
350}
351
352#[cfg(feature = "chrono")]
353/// Provides a string representation of the DateTime.
354pub fn string_from(date_time: &chrono::DateTime<chrono::FixedOffset>) -> String {
355    let dt = date_time.naive_local();
356    let date = dt.date();
357    let time = dt.time();
358    let offset = date_time.offset();
359    if offset.local_minus_utc() == 0 {
360        format!("{date}T{time}Z")
361    } else {
362        format!("{date}T{time}{offset}")
363    }
364}
365#[cfg(not(feature = "chrono"))]
366/// Provides a string representation of the DateTime.
367pub fn string_from(date_time: &DateTime) -> String {
368    format!("{date_time}")
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use pretty_assertions::assert_eq;
375
376    #[test]
377    fn no_timezone() {
378        assert_eq!(
379            date_time!(2025-06-04 T 13:50:42.148),
380            parse("2025-06-04T13:50:42.148Z").unwrap()
381        );
382    }
383
384    #[test]
385    fn plus_timezone() {
386        assert_eq!(
387            date_time!(2025-06-04 T 13:50:42.148 03:00),
388            parse("2025-06-04T13:50:42.148+03:00").unwrap()
389        );
390    }
391
392    #[test]
393    fn negative_timezone() {
394        assert_eq!(
395            date_time!(2025-06-04 T 13:50:42.148 -01:30),
396            parse("2025-06-04T13:50:42.148-01:30").unwrap()
397        );
398    }
399
400    #[test]
401    fn no_fractional_seconds() {
402        assert_eq!(
403            date_time!(2025-06-04 T 13:50:42.0),
404            parse("2025-06-04T13:50:42Z").unwrap()
405        );
406    }
407
408    #[test]
409    fn string_from_single_digit_dates_should_be_valid() {
410        assert_eq!(
411            String::from("2025-06-04T13:50:42.123Z"),
412            string_from(&date_time!(2025-06-04 T 13:50:42.123))
413        )
414    }
415
416    #[ignore = "change to chrono breaks test but maybe the expectation is wrong anyway"]
417    #[test]
418    fn string_from_no_fractional_seconds_should_still_be_3_decimals_precise() {
419        assert_eq!(
420            String::from("2025-06-04T13:50:42.000Z"),
421            string_from(&date_time!(2025-06-04 T 13:50:42.0))
422        )
423    }
424
425    #[test]
426    fn string_from_single_digit_times_should_be_valid() {
427        assert_eq!(
428            String::from("2025-12-25T04:00:02.001Z"),
429            string_from(&date_time!(2025-12-25 T 04:00:02.001))
430        )
431    }
432
433    #[test]
434    fn string_from_negative_time_offset_should_be_valid() {
435        assert_eq!(
436            String::from("2025-06-04T13:50:42.123-05:00"),
437            string_from(&date_time!(2025-06-04 T 13:50:42.123 -05:00))
438        )
439    }
440
441    #[test]
442    fn string_from_positive_offset_should_be_valid() {
443        assert_eq!(
444            String::from("2025-06-04T13:50:42.100+01:00"),
445            string_from(&date_time!(2025-06-04 T 13:50:42.100 01:00))
446        )
447    }
448
449    #[test]
450    fn string_from_positive_offset_non_zero_minutes_should_be_valid() {
451        assert_eq!(
452            String::from("2025-06-04T13:50:42.010+06:30"),
453            string_from(&date_time!(2025-06-04 T 13:50:42.010 06:30))
454        )
455    }
456
457    #[cfg(not(feature = "chrono"))]
458    #[test]
459    fn date_time_macro_should_work_with_no_offset() {
460        assert_eq!(
461            date_time!(2025-06-22 T 22:13:42.000),
462            DateTime {
463                date_fullyear: 2025,
464                date_month: 6,
465                date_mday: 22,
466                time_hour: 22,
467                time_minute: 13,
468                time_second: 42.0,
469                timezone_offset: DateTimeTimezoneOffset {
470                    time_hour: 0,
471                    time_minute: 0
472                }
473            }
474        );
475    }
476
477    #[cfg(not(feature = "chrono"))]
478    #[test]
479    fn date_time_macro_should_work_with_positive_offset() {
480        assert_eq!(
481            date_time!(2025-06-22 T 22:13:42.000 01:00),
482            DateTime {
483                date_fullyear: 2025,
484                date_month: 6,
485                date_mday: 22,
486                time_hour: 22,
487                time_minute: 13,
488                time_second: 42.0,
489                timezone_offset: DateTimeTimezoneOffset {
490                    time_hour: 1,
491                    time_minute: 0
492                }
493            }
494        );
495    }
496
497    #[cfg(not(feature = "chrono"))]
498    #[test]
499    fn date_time_macro_should_work_with_negative_offset() {
500        assert_eq!(
501            date_time!(2025-06-22 T 22:13:42.000 -01:30),
502            DateTime {
503                date_fullyear: 2025,
504                date_month: 6,
505                date_mday: 22,
506                time_hour: 22,
507                time_minute: 13,
508                time_second: 42.0,
509                timezone_offset: DateTimeTimezoneOffset {
510                    time_hour: -1,
511                    time_minute: 30
512                }
513            }
514        );
515    }
516}