Skip to main content

ttml_subtitle/
time.rs

1//! `<time-expression>` grammar — W3C TTML2 §12.3.1.
2//!
3//! The `<time-expression>` grammar is the highest-risk surface for a subtitle
4//! parser: a silently-wrong cue time is the worst failure mode. This module
5//! provides exact parsing of all three expression forms (clock-time, offset-time,
6//! wallclock-time) with full validation of frame/tick/SMPTE constraints.
7//!
8//! Time-base-specific constraints are enforced at parse time when `ttp:timeBase`,
9//! `ttp:frameRate`, `ttp:subFrameRate`, `ttp:dropMode`, `ttp:markerMode` are
10//! known. These parameters are gathered from the document root and passed to
11//! the parser via [`TimeContext`].
12//!
13//! ### Grammar (verbatim from TTML2 §12.3.1)
14//!
15//! ```text
16//! <time-expression> : clock-time | offset-time | wallclock-time
17//!
18//! clock-time    : hours ":" minutes ":" seconds ( fraction | ":" frames ("." sub-frames)? )?
19//! offset-time   : time-count fraction? metric
20//! wallclock-time: "wallclock(" <lwsp>? ( date-time | wall-time | date ) <lwsp>? ")"
21//!
22//! date-time     : date "T" wall-time
23//! wall-time     : hhmm-time | hhmmss-time
24//! date          : years "-" months "-" days
25//! hhmm-time     : hours2 ":" minutes
26//! hhmmss-time   : hours2 ":" minutes ":" seconds fraction?
27//!
28//! metric : "h" | "m" | "s" | "ms" | "f" | "t"
29//! ```
30
31extern crate alloc;
32
33use alloc::format;
34use alloc::string::String;
35use alloc::string::ToString;
36use alloc::vec::Vec;
37
38/// Time context gathered from document parameters.
39///
40/// Defaults match TTML2 §7.2 prose defaults.
41#[derive(Debug, Clone, PartialEq)]
42pub struct TimeContext {
43    /// `ttp:timeBase` — default `media`.
44    pub time_base: TimeBase,
45    /// `ttp:frameRate` — default 30.
46    pub frame_rate: u32,
47    /// `ttp:frameRateMultiplier` numerator/denominator — default `1 1`.
48    pub frame_rate_multiplier_numerator: u32,
49    /// `ttp:frameRateMultiplier` denominator.
50    pub frame_rate_multiplier_denominator: u32,
51    /// `ttp:subFrameRate` — default 1.
52    pub sub_frame_rate: u32,
53    /// `ttp:tickRate` — default derived from frame rate × sub frame rate, or 1.
54    pub tick_rate: u32,
55    /// `ttp:dropMode` — default `nonDrop`. Only meaningful when time_base=smpte.
56    pub drop_mode: DropMode,
57    /// `ttp:markerMode` — default `discontinuous`. Only meaningful when time_base=smpte.
58    pub marker_mode: MarkerMode,
59    /// `ttp:clockMode` — default `utc`. Only meaningful when time_base=clock.
60    pub clock_mode: ClockMode,
61}
62
63impl Default for TimeContext {
64    fn default() -> Self {
65        Self {
66            time_base: TimeBase::Media,
67            frame_rate: 30,
68            frame_rate_multiplier_numerator: 1,
69            frame_rate_multiplier_denominator: 1,
70            sub_frame_rate: 1,
71            tick_rate: 30, // effective frame rate × sub-frame rate
72            drop_mode: DropMode::NonDrop,
73            marker_mode: MarkerMode::Discontinuous,
74            clock_mode: ClockMode::Utc,
75        }
76    }
77}
78
79/// `ttp:timeBase` values — TTML2 §7.2.1.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81#[non_exhaustive]
82pub enum TimeBase {
83    /// Media timeline.
84    Media,
85    /// SMPTE ST 12-1 timecode.
86    Smpte,
87    /// Real-world clock time.
88    Clock,
89}
90
91impl TimeBase {
92    /// Label for the #204 convention.
93    pub fn name(&self) -> &'static str {
94        match self {
95            TimeBase::Media => "media",
96            TimeBase::Smpte => "smpte",
97            TimeBase::Clock => "clock",
98        }
99    }
100}
101
102broadcast_common::impl_spec_display!(TimeBase);
103
104/// `ttp:dropMode` values — TTML2 §7.2.4.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106#[non_exhaustive]
107pub enum DropMode {
108    /// No frame dropping.
109    NonDrop,
110    /// NTSC drop-frame (frames 00,01 dropped at minute start except multiples of 10).
111    DropNtsc,
112    /// PAL drop-frame (frames 00-03 dropped at even minute start except multiples of 20).
113    DropPal,
114}
115
116impl DropMode {
117    /// Label for the #204 convention.
118    pub fn name(&self) -> &'static str {
119        match self {
120            DropMode::NonDrop => "nonDrop",
121            DropMode::DropNtsc => "dropNTSC",
122            DropMode::DropPal => "dropPAL",
123        }
124    }
125}
126
127broadcast_common::impl_spec_display!(DropMode);
128
129/// `ttp:markerMode` values — TTML2 §7.2.6.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum MarkerMode {
133    /// SMPTE time coordinates are linear/monotonic.
134    Continuous,
135    /// No continuity assumed; arithmetic on time expressions undefined.
136    Discontinuous,
137}
138
139impl MarkerMode {
140    /// Label for the #204 convention.
141    pub fn name(&self) -> &'static str {
142        match self {
143            MarkerMode::Continuous => "continuous",
144            MarkerMode::Discontinuous => "discontinuous",
145        }
146    }
147}
148
149broadcast_common::impl_spec_display!(MarkerMode);
150
151/// `ttp:clockMode` values — TTML2 §7.2.2.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153#[non_exhaustive]
154pub enum ClockMode {
155    /// Local wall-clock time.
156    Local,
157    /// UTC.
158    Utc,
159    /// GPS time (not leap-second adjusted).
160    Gps,
161}
162
163impl ClockMode {
164    /// Label for the #204 convention.
165    pub fn name(&self) -> &'static str {
166        match self {
167            ClockMode::Local => "local",
168            ClockMode::Utc => "utc",
169            ClockMode::Gps => "gps",
170        }
171    }
172}
173
174broadcast_common::impl_spec_display!(ClockMode);
175
176/// A parsed time expression.
177///
178/// While the grammar in §12.3.1 defines three forms (clock-time, offset-time,
179/// wallclock-time), we represent all parsed time expressions uniformly.
180#[derive(Debug, Clone, PartialEq)]
181#[non_exhaustive]
182pub enum TimeExpression {
183    /// Clock-time: `HH:MM:SS[.fraction][:frames[.sub-frames]]`
184    ClockTime {
185        /// Hours (unbounded — can be ≥ 100).
186        hours: u32,
187        /// Minutes [0, 59].
188        minutes: u8,
189        /// Seconds [0, 60] (60 = leap second).
190        seconds: u8,
191        /// Fractional seconds, as a string of digits after the decimal point.
192        fraction: Option<String>,
193        /// Frames component (HH:MM:SS:FF).
194        frames: Option<u32>,
195        /// Sub-frames component (HH:MM:SS:FF.SF).
196        sub_frames: Option<u32>,
197    },
198    /// Offset-time: `count[.fraction]metric`
199    OffsetTime {
200        /// The integer count part.
201        count: u64,
202        /// Fractional part, as a string of digits.
203        fraction: Option<String>,
204        /// The metric unit.
205        metric: TimeMetric,
206    },
207    /// Wallclock-time: `wallclock(date-time|wall-time|date)`
208    WallclockTime {
209        /// The wallclock form: date-time, wall-time, or date.
210        form: WallclockForm,
211    },
212}
213
214/// Metric units for offset-time expressions — TTML2 §12.3.1.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216#[non_exhaustive]
217pub enum TimeMetric {
218    /// Hours.
219    H,
220    /// Minutes.
221    M,
222    /// Seconds.
223    S,
224    /// Milliseconds.
225    Ms,
226    /// Frames (requires `ttp:frameRate`).
227    F,
228    /// Ticks (requires `ttp:tickRate`).
229    T,
230}
231
232impl TimeMetric {
233    /// Label for the #204 convention.
234    pub fn name(&self) -> &'static str {
235        match self {
236            TimeMetric::H => "h",
237            TimeMetric::M => "m",
238            TimeMetric::S => "s",
239            TimeMetric::Ms => "ms",
240            TimeMetric::F => "f",
241            TimeMetric::T => "t",
242        }
243    }
244}
245
246broadcast_common::impl_spec_display!(TimeMetric);
247
248/// Wallclock-time forms — TTML2 §12.3.1.
249#[derive(Debug, Clone, PartialEq)]
250#[non_exhaustive]
251pub enum WallclockForm {
252    /// `YYYY-MM-DDThh:mm:ss[.fraction]`
253    DateTime {
254        /// Years (4 digits).
255        years: u16,
256        /// Months [1, 12].
257        months: u8,
258        /// Days [1, 31].
259        days: u8,
260        /// Hours [0, 23].
261        hours: u8,
262        /// Minutes [0, 59].
263        minutes: u8,
264        /// Seconds [0, 60].
265        seconds: u8,
266        /// Fractional seconds.
267        fraction: Option<String>,
268    },
269    /// `hh:mm[:ss[.fraction]]`
270    WallTime {
271        /// Hours [0, 23].
272        hours: u8,
273        /// Minutes [0, 59].
274        minutes: u8,
275        /// Seconds [0, 60].
276        seconds: Option<u8>,
277        /// Fractional seconds.
278        fraction: Option<String>,
279    },
280    /// `YYYY-MM-DD`
281    Date {
282        /// Years (4 digits).
283        years: u16,
284        /// Months [1, 12].
285        months: u8,
286        /// Days [1, 31].
287        days: u8,
288    },
289}
290
291/// Parse a time expression string to its typed representation.
292///
293/// Performs basic structural validation (digit counts, ranges).
294/// Time-base-specific constraint enforcement (frame/clock restrictions)
295/// is done separately depending on context.
296pub fn parse_time_expression(
297    input: &str,
298    ctx: &TimeContext,
299) -> Result<TimeExpression, crate::error::Error> {
300    if input.is_empty() {
301        return Err(crate::error::Error::InvalidTimeExpression {
302            value: input.to_string(),
303            reason: "empty time expression".into(),
304        });
305    }
306
307    // Try wallclock-time first: starts with "wallclock("
308    if input.starts_with("wallclock(") {
309        return parse_wallclock_time(input, ctx);
310    }
311
312    // Clock-time: contains ':' and first char is digit
313    // Offset-time: contains metric suffix (h, m, s, ms, f, t) and no ':'
314    if input.contains(':') {
315        parse_clock_time(input, ctx)
316    } else {
317        parse_offset_time(input, ctx)
318    }
319}
320
321fn parse_clock_time(input: &str, ctx: &TimeContext) -> Result<TimeExpression, crate::error::Error> {
322    let err = |reason: &str| crate::error::Error::InvalidTimeExpression {
323        value: input.to_string(),
324        reason: reason.into(),
325    };
326
327    // Split on ':'
328    let parts: Vec<&str> = input.split(':').collect();
329
330    // Must have at least 3 parts (HH:MM:SS) or 4 (HH:MM:SS:FF) or 5 (HH:MM:SS:FF.SF)
331    if parts.len() < 3 || parts.len() > 4 {
332        return Err(err(
333            "clock-time must have exactly 3 or 4 colon-separated components",
334        ));
335    }
336
337    // Each part must be non-empty
338    if parts.iter().any(|p| p.is_empty()) {
339        return Err(err("empty component in clock-time"));
340    }
341
342    // Hours
343    let hours: u32 = parse_digits(parts[0], "hours", &err)?;
344    // Must have at least 2 digits if < 100, or 3+ if >= 100
345    if hours < 100 && parts[0].len() < 2 {
346        return Err(err(
347            "hours < 100 must have leading zero (at least 2 digits)",
348        ));
349    }
350
351    // Minutes
352    let minutes_raw = parts[1];
353    if minutes_raw.len() != 2 {
354        return Err(err("minutes must be exactly 2 digits"));
355    }
356    let minutes: u8 = parse_digits_u8(minutes_raw, "minutes", &err)?;
357    if minutes > 59 {
358        return Err(err("minutes must be in [0, 59]"));
359    }
360
361    // Seconds (may have fraction)
362    let secs_part = parts[2];
363    let (secs_str, fraction): (&str, Option<String>) = if let Some(dot_pos) = secs_part.find('.') {
364        let (s, f) = secs_part.split_at(dot_pos);
365        let frac = &f[1..]; // skip the '.'
366        if frac.is_empty() || !frac.chars().all(|c| c.is_ascii_digit()) {
367            return Err(err("fractional seconds must be digits"));
368        }
369        (s, Some(frac.to_string()))
370    } else {
371        (secs_part, None)
372    };
373
374    if secs_str.len() != 2 {
375        return Err(err("seconds must be exactly 2 digits"));
376    }
377    let seconds: u8 = parse_digits_u8(secs_str, "seconds", &err)?;
378    if seconds > 60 {
379        return Err(err("seconds must be in [0, 60]"));
380    }
381
382    if parts.len() == 4 {
383        // Has frames component
384        let frames_part = parts[3];
385
386        // Frames component is error when timeBase=clock
387        if ctx.time_base == TimeBase::Clock {
388            return Err(err("frames term is an error when timeBase is clock"));
389        }
390
391        let (frames_str, sub_frames): (&str, Option<u32>) =
392            if let Some(dot_pos) = frames_part.find('.') {
393                let (f, sf) = frames_part.split_at(dot_pos);
394                let sf_str = &sf[1..];
395                if sf_str.is_empty() || !sf_str.chars().all(|c| c.is_ascii_digit()) {
396                    return Err(err("sub-frames must be digits"));
397                }
398                let sf_val: u32 = sf_str
399                    .parse()
400                    .map_err(|_| err("sub-frames value too large"))?;
401                // Sub-frames is error when timeBase=clock
402                if ctx.time_base == TimeBase::Clock {
403                    return Err(err("sub-frames term is an error when timeBase is clock"));
404                }
405                // Validate sub-frames range
406                if ctx.sub_frame_rate > 0 && sf_val >= ctx.sub_frame_rate {
407                    return Err(err(&format!(
408                        "sub-frames value {} must be < subFrameRate {}",
409                        sf_val, ctx.sub_frame_rate
410                    )));
411                }
412                (f, Some(sf_val))
413            } else {
414                (frames_part, None)
415            };
416
417        let frames: u32 = parse_digits(frames_str, "frames", &err)?;
418        // Validate frames range
419        let effective_frame_rate = if ctx.frame_rate > 0 {
420            ctx.frame_rate
421        } else {
422            30
423        };
424        if frames >= effective_frame_rate {
425            return Err(err(&format!(
426                "frames value {} must be < frameRate {}",
427                frames, effective_frame_rate
428            )));
429        }
430
431        Ok(TimeExpression::ClockTime {
432            hours,
433            minutes,
434            seconds,
435            fraction,
436            frames: Some(frames),
437            sub_frames,
438        })
439    } else {
440        Ok(TimeExpression::ClockTime {
441            hours,
442            minutes,
443            seconds,
444            fraction,
445            frames: None,
446            sub_frames: None,
447        })
448    }
449}
450
451fn parse_offset_time(
452    input: &str,
453    _ctx: &TimeContext,
454) -> Result<TimeExpression, crate::error::Error> {
455    let err = |reason: &str| crate::error::Error::InvalidTimeExpression {
456        value: input.to_string(),
457        reason: reason.into(),
458    };
459
460    // Find the metric suffix
461    let metric = if let Some(stripped) = input.strip_suffix("ms") {
462        (TimeMetric::Ms, stripped)
463    } else if let Some(stripped) = input.strip_suffix('h') {
464        (TimeMetric::H, stripped)
465    } else if let Some(stripped) = input.strip_suffix('m') {
466        (TimeMetric::M, stripped)
467    } else if let Some(stripped) = input.strip_suffix('s') {
468        (TimeMetric::S, stripped)
469    } else if let Some(stripped) = input.strip_suffix('f') {
470        (TimeMetric::F, stripped)
471    } else if let Some(stripped) = input.strip_suffix('t') {
472        (TimeMetric::T, stripped)
473    } else {
474        return Err(err(
475            "offset-time must end with a metric: h, m, s, ms, f, or t",
476        ));
477    };
478
479    let num_str = metric.1;
480    if num_str.is_empty() {
481        return Err(err(
482            "offset-time must have a numeric count before the metric",
483        ));
484    }
485
486    let (count_str, fraction): (&str, Option<String>) = if let Some(dot_pos) = num_str.find('.') {
487        let (c, f) = num_str.split_at(dot_pos);
488        let frac = &f[1..];
489        if frac.is_empty() || !frac.chars().all(|c| c.is_ascii_digit()) {
490            return Err(err("fractional part must be digits"));
491        }
492        (c, Some(frac.to_string()))
493    } else {
494        (num_str, None)
495    };
496
497    if count_str.is_empty() || !count_str.chars().all(|c| c.is_ascii_digit()) {
498        return Err(err("count part must be digits"));
499    }
500
501    let count: u64 = count_str
502        .parse()
503        .map_err(|_| err("count value too large"))?;
504
505    Ok(TimeExpression::OffsetTime {
506        count,
507        fraction,
508        metric: metric.0,
509    })
510}
511
512fn parse_wallclock_time(
513    input: &str,
514    ctx: &TimeContext,
515) -> Result<TimeExpression, crate::error::Error> {
516    let err = |reason: &str| crate::error::Error::InvalidTimeExpression {
517        value: input.to_string(),
518        reason: reason.into(),
519    };
520
521    // Wallclock-time is an error if timeBase is not clock
522    if ctx.time_base != TimeBase::Clock {
523        return Err(err("wallclock-time is an error when timeBase is not clock"));
524    }
525
526    // Extract content between "wallclock(" and ")"
527    let rest = &input["wallclock(".len()..];
528    let rest = rest.trim_start(); // <lwsp>?
529    let rest = if let Some(close_pos) = rest.rfind(')') {
530        rest[..close_pos].trim_end() // <lwsp>?
531    } else {
532        return Err(err("wallclock-time missing closing parenthesis"));
533    };
534
535    let rest = rest.trim();
536
537    if rest.is_empty() {
538        return Err(err("wallclock-time has no content"));
539    }
540
541    // Check which form: date-time, wall-time, or date
542    if rest.contains('T') {
543        // date-time: YYYY-MM-DDThh:mm:ss[.fraction]
544        parse_wallclock_datetime(rest, &err)
545    } else if rest.contains('-') {
546        // date: YYYY-MM-DD
547        parse_wallclock_date(rest, &err)
548    } else {
549        // wall-time: hh:mm[:ss[.fraction]]
550        parse_wallclock_walltime(rest, &err)
551    }
552}
553
554fn parse_wallclock_datetime(
555    input: &str,
556    err: &impl Fn(&str) -> crate::error::Error,
557) -> Result<TimeExpression, crate::error::Error> {
558    let parts: Vec<&str> = input.split('T').collect();
559    if parts.len() != 2 {
560        return Err(err("date-time must have exactly one 'T' separator"));
561    }
562
563    let date_part = parts[0];
564    let time_part = parts[1];
565
566    // Parse date: YYYY-MM-DD
567    let date_components: Vec<&str> = date_part.split('-').collect();
568    if date_components.len() != 3 {
569        return Err(err("date must have exactly 3 components: YYYY-MM-DD"));
570    }
571
572    if date_components[0].len() != 4 {
573        return Err(err("years must be exactly 4 digits"));
574    }
575    let years: u16 = date_components[0]
576        .parse()
577        .map_err(|_| err("invalid years"))?;
578
579    if date_components[1].len() != 2 {
580        return Err(err("months must be exactly 2 digits"));
581    }
582    let months: u8 = date_components[1]
583        .parse()
584        .map_err(|_| err("invalid months"))?;
585    if !(1..=12).contains(&months) {
586        return Err(err("months must be in [1, 12]"));
587    }
588
589    if date_components[2].len() != 2 {
590        return Err(err("days must be exactly 2 digits"));
591    }
592    let days: u8 = date_components[2]
593        .parse()
594        .map_err(|_| err("invalid days"))?;
595    if !(1..=31).contains(&days) {
596        return Err(err("days must be in [1, 31]"));
597    }
598
599    // Parse time: hh:mm[:ss[.fraction]]
600    let (hours, minutes, seconds, fraction) = parse_wallclock_time_components(time_part, err)?;
601
602    Ok(TimeExpression::WallclockTime {
603        form: WallclockForm::DateTime {
604            years,
605            months,
606            days,
607            hours,
608            minutes,
609            seconds: seconds.unwrap_or(0),
610            fraction,
611        },
612    })
613}
614
615fn parse_wallclock_date(
616    input: &str,
617    err: &impl Fn(&str) -> crate::error::Error,
618) -> Result<TimeExpression, crate::error::Error> {
619    let date_components: Vec<&str> = input.split('-').collect();
620    if date_components.len() != 3 {
621        return Err(err("date must have exactly 3 components: YYYY-MM-DD"));
622    }
623
624    if date_components[0].len() != 4 {
625        return Err(err("years must be exactly 4 digits"));
626    }
627    let years: u16 = date_components[0]
628        .parse()
629        .map_err(|_| err("invalid years"))?;
630
631    if date_components[1].len() != 2 {
632        return Err(err("months must be exactly 2 digits"));
633    }
634    let months: u8 = date_components[1]
635        .parse()
636        .map_err(|_| err("invalid months"))?;
637    if !(1..=12).contains(&months) {
638        return Err(err("months must be in [1, 12]"));
639    }
640
641    if date_components[2].len() != 2 {
642        return Err(err("days must be exactly 2 digits"));
643    }
644    let days: u8 = date_components[2]
645        .parse()
646        .map_err(|_| err("invalid days"))?;
647    if !(1..=31).contains(&days) {
648        return Err(err("days must be in [1, 31]"));
649    }
650
651    Ok(TimeExpression::WallclockTime {
652        form: WallclockForm::Date {
653            years,
654            months,
655            days,
656        },
657    })
658}
659
660fn parse_wallclock_walltime(
661    input: &str,
662    err: &impl Fn(&str) -> crate::error::Error,
663) -> Result<TimeExpression, crate::error::Error> {
664    let (hours, minutes, seconds, fraction) = parse_wallclock_time_components(input, err)?;
665
666    Ok(TimeExpression::WallclockTime {
667        form: WallclockForm::WallTime {
668            hours,
669            minutes,
670            seconds,
671            fraction,
672        },
673    })
674}
675
676fn parse_wallclock_time_components(
677    input: &str,
678    err: &impl Fn(&str) -> crate::error::Error,
679) -> Result<(u8, u8, Option<u8>, Option<String>), crate::error::Error> {
680    let parts: Vec<&str> = input.split(':').collect();
681
682    if parts.len() < 2 || parts.len() > 3 {
683        return Err(err(
684            "wallclock time must have 2 or 3 colon-separated components",
685        ));
686    }
687
688    if parts[0].len() != 2 {
689        return Err(err("wallclock hours must be exactly 2 digits"));
690    }
691    let hours: u8 = parts[0].parse().map_err(|_| err("invalid hours"))?;
692    if hours > 23 {
693        return Err(err("wallclock hours must be in [0, 23]"));
694    }
695
696    if parts[1].len() != 2 {
697        return Err(err("wallclock minutes must be exactly 2 digits"));
698    }
699    let minutes: u8 = parts[1].parse().map_err(|_| err("invalid minutes"))?;
700    if minutes > 59 {
701        return Err(err("wallclock minutes must be in [0, 59]"));
702    }
703
704    if parts.len() == 3 {
705        let (secs_str, fraction) = if let Some(dot_pos) = parts[2].find('.') {
706            let (s, f) = parts[2].split_at(dot_pos);
707            let frac = &f[1..];
708            if frac.is_empty() || !frac.chars().all(|c| c.is_ascii_digit()) {
709                return Err(err("fractional seconds must be digits"));
710            }
711            (s, Some(frac.to_string()))
712        } else {
713            (parts[2], None)
714        };
715
716        if secs_str.len() != 2 {
717            return Err(err("wallclock seconds must be exactly 2 digits"));
718        }
719        let seconds: u8 = secs_str.parse().map_err(|_| err("invalid seconds"))?;
720        if seconds > 60 {
721            return Err(err("wallclock seconds must be in [0, 60]"));
722        }
723
724        Ok((hours, minutes, Some(seconds), fraction))
725    } else {
726        Ok((hours, minutes, None, None))
727    }
728}
729
730fn parse_digits<T: core::str::FromStr>(
731    s: &str,
732    name: &str,
733    err: &impl Fn(&str) -> crate::error::Error,
734) -> Result<T, crate::error::Error> {
735    if !s.chars().all(|c| c.is_ascii_digit()) {
736        return Err(err(&format!("{name} must be digits")));
737    }
738    s.parse::<T>()
739        .map_err(|_| err(&format!("{name} value too large")))
740}
741
742fn parse_digits_u8(
743    s: &str,
744    name: &str,
745    err: &impl Fn(&str) -> crate::error::Error,
746) -> Result<u8, crate::error::Error> {
747    parse_digits::<u8>(s, name, err)
748}
749
750/// Format a parsed time expression back to its string representation.
751///
752/// This produces a normalized but semantically equivalent string that
753/// preserves the same form (clock-time/offset-time/wallclock-time).
754pub fn format_time_expression(expr: &TimeExpression) -> String {
755    match expr {
756        TimeExpression::ClockTime {
757            hours,
758            minutes,
759            seconds,
760            fraction,
761            frames,
762            sub_frames,
763        } => {
764            let mut s = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
765            if let Some(frac) = fraction {
766                s.push('.');
767                s.push_str(frac);
768            }
769            if let Some(frames) = frames {
770                s.push(':');
771                // Frames can be 2+ digits
772                s.push_str(&format!("{:02}", frames));
773                if let Some(sf) = sub_frames {
774                    s.push('.');
775                    s.push_str(&sf.to_string());
776                }
777            }
778            s
779        }
780        TimeExpression::OffsetTime {
781            count,
782            fraction,
783            metric,
784        } => {
785            let mut s = count.to_string();
786            if let Some(frac) = fraction {
787                s.push('.');
788                s.push_str(frac);
789            }
790            s.push_str(metric.name());
791            s
792        }
793        TimeExpression::WallclockTime { form } => {
794            let inner = match form {
795                WallclockForm::DateTime {
796                    years,
797                    months,
798                    days,
799                    hours,
800                    minutes,
801                    seconds,
802                    fraction,
803                } => {
804                    let mut s = format!(
805                        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
806                        years, months, days, hours, minutes, seconds
807                    );
808                    if let Some(frac) = fraction {
809                        s.push('.');
810                        s.push_str(frac);
811                    }
812                    s
813                }
814                WallclockForm::WallTime {
815                    hours,
816                    minutes,
817                    seconds,
818                    fraction,
819                } => {
820                    let mut s = format!("{:02}:{:02}", hours, minutes);
821                    if let Some(secs) = seconds {
822                        s.push(':');
823                        s.push_str(&format!("{:02}", secs));
824                    }
825                    if let Some(frac) = fraction {
826                        s.push('.');
827                        s.push_str(frac);
828                    }
829                    s
830                }
831                WallclockForm::Date {
832                    years,
833                    months,
834                    days,
835                } => {
836                    format!("{:04}-{:02}-{:02}", years, months, days)
837                }
838            };
839            format!("wallclock({})", inner)
840        }
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847
848    fn default_ctx() -> TimeContext {
849        TimeContext::default()
850    }
851
852    #[test]
853    fn test_offset_seconds() {
854        let expr = parse_time_expression("0s", &default_ctx()).unwrap();
855        assert_eq!(
856            expr,
857            TimeExpression::OffsetTime {
858                count: 0,
859                fraction: None,
860                metric: TimeMetric::S,
861            }
862        );
863        assert_eq!(format_time_expression(&expr), "0s");
864    }
865
866    #[test]
867    fn test_offset_fractional() {
868        let expr = parse_time_expression("1.2s", &default_ctx()).unwrap();
869        assert_eq!(
870            expr,
871            TimeExpression::OffsetTime {
872                count: 1,
873                fraction: Some("2".into()),
874                metric: TimeMetric::S,
875            }
876        );
877        assert_eq!(format_time_expression(&expr), "1.2s");
878    }
879
880    #[test]
881    fn test_offset_minutes() {
882        let expr = parse_time_expression("1.2m", &default_ctx()).unwrap();
883        assert_eq!(format_time_expression(&expr), "1.2m");
884    }
885
886    #[test]
887    fn test_offset_hours() {
888        let expr = parse_time_expression("1.2h", &default_ctx()).unwrap();
889        assert_eq!(format_time_expression(&expr), "1.2h");
890    }
891
892    #[test]
893    fn test_offset_frames() {
894        let expr = parse_time_expression("24f", &default_ctx()).unwrap();
895        assert_eq!(
896            expr,
897            TimeExpression::OffsetTime {
898                count: 24,
899                fraction: None,
900                metric: TimeMetric::F,
901            }
902        );
903        assert_eq!(format_time_expression(&expr), "24f");
904    }
905
906    #[test]
907    fn test_offset_ticks() {
908        let expr = parse_time_expression("120t", &default_ctx()).unwrap();
909        assert_eq!(format_time_expression(&expr), "120t");
910    }
911
912    #[test]
913    fn test_clock_time_simple() {
914        let expr = parse_time_expression("01:02:03", &default_ctx()).unwrap();
915        assert_eq!(
916            expr,
917            TimeExpression::ClockTime {
918                hours: 1,
919                minutes: 2,
920                seconds: 3,
921                fraction: None,
922                frames: None,
923                sub_frames: None,
924            }
925        );
926        assert_eq!(format_time_expression(&expr), "01:02:03");
927    }
928
929    #[test]
930    fn test_clock_time_fraction() {
931        let expr = parse_time_expression("01:02:03.235", &default_ctx()).unwrap();
932        if let TimeExpression::ClockTime { fraction, .. } = &expr {
933            assert_eq!(fraction.as_deref(), Some("235"));
934        } else {
935            panic!("expected ClockTime");
936        }
937        assert_eq!(format_time_expression(&expr), "01:02:03.235");
938    }
939
940    #[test]
941    fn test_clock_time_with_frames() {
942        let expr = parse_time_expression("01:02:03:20", &default_ctx()).unwrap();
943        if let TimeExpression::ClockTime { frames, .. } = &expr {
944            assert_eq!(*frames, Some(20));
945        } else {
946            panic!("expected ClockTime");
947        }
948        assert_eq!(format_time_expression(&expr), "01:02:03:20");
949    }
950
951    #[test]
952    fn test_clock_time_large_hours() {
953        let expr = parse_time_expression("100:00:00.1", &default_ctx()).unwrap();
954        let formatted = format_time_expression(&expr);
955        // 100:00:00.1 should be preserved
956        assert!(formatted.starts_with("100:00:00"));
957    }
958
959    #[test]
960    fn test_clock_time_with_frame_subframes() {
961        let ctx = TimeContext {
962            frame_rate: 24,
963            sub_frame_rate: 10,
964            ..default_ctx()
965        };
966        let expr = parse_time_expression("01:02:03:20.5", &ctx).unwrap();
967        if let TimeExpression::ClockTime {
968            frames, sub_frames, ..
969        } = &expr
970        {
971            assert_eq!(*frames, Some(20));
972            assert_eq!(*sub_frames, Some(5));
973        } else {
974            panic!("expected ClockTime");
975        }
976        // Normalized: seconds component gets no fraction since frames consume it
977        assert_eq!(format_time_expression(&expr), "01:02:03:20.5");
978    }
979
980    #[test]
981    fn test_frame_rate_validation() {
982        let ctx = TimeContext {
983            frame_rate: 24,
984            ..default_ctx()
985        };
986        // Clock-time frames must be < frameRate
987        parse_time_expression("01:02:03:23", &ctx).unwrap();
988        // 24f (offset-time f metric) — the value itself is not range-checked against frameRate;
989        // the context determines how the value is interpreted at presentation time.
990        // But the clock-time frames component 01:02:03:24 should fail
991        assert!(parse_time_expression("01:02:03:24", &ctx).is_err());
992    }
993
994    #[test]
995    fn test_subframe_rate_validation() {
996        let ctx = TimeContext {
997            frame_rate: 24,
998            sub_frame_rate: 10,
999            ..default_ctx()
1000        };
1001        // sub-frame 9 should be valid (must be < subFrameRate=10)
1002        parse_time_expression("01:02:03:20.9", &ctx).unwrap();
1003        // sub-frame 10 should fail
1004        assert!(parse_time_expression("01:02:03:20.10", &ctx).is_err());
1005    }
1006
1007    #[test]
1008    fn test_frames_error_on_clock_timebase() {
1009        let ctx = TimeContext {
1010            time_base: TimeBase::Clock,
1011            ..default_ctx()
1012        };
1013        // frames term is error when timeBase=clock
1014        assert!(parse_time_expression("01:02:03:20", &ctx).is_err());
1015    }
1016
1017    #[test]
1018    fn test_negative_cases() {
1019        // Empty
1020        assert!(parse_time_expression("", &default_ctx()).is_err());
1021        // No metric
1022        assert!(parse_time_expression("123", &default_ctx()).is_err());
1023        // Minutes out of range
1024        assert!(parse_time_expression("00:60:00", &default_ctx()).is_err());
1025        // Seconds = 61 is out of range (max is 60 for leap second)
1026        assert!(parse_time_expression("00:00:61", &default_ctx()).is_err());
1027        // Missing leading zero
1028        assert!(parse_time_expression("0:00:00", &default_ctx()).is_err());
1029        // No digits before metric
1030        assert!(parse_time_expression("s", &default_ctx()).is_err());
1031    }
1032
1033    #[test]
1034    fn test_wallclock_error_on_non_clock_timebase() {
1035        let ctx = TimeContext {
1036            time_base: TimeBase::Media,
1037            ..default_ctx()
1038        };
1039        assert!(parse_time_expression("wallclock(2024-01-01T00:00:00)", &ctx).is_err());
1040    }
1041
1042    #[test]
1043    fn test_milliseconds() {
1044        let expr = parse_time_expression("500ms", &default_ctx()).unwrap();
1045        if let TimeExpression::OffsetTime { count, metric, .. } = &expr {
1046            assert_eq!(*count, 500);
1047            assert_eq!(*metric, TimeMetric::Ms);
1048        } else {
1049            panic!("expected OffsetTime");
1050        }
1051        assert_eq!(format_time_expression(&expr), "500ms");
1052    }
1053
1054    #[test]
1055    fn test_round_trip_all_fixture_expressions() {
1056        let expressions = vec![
1057            "0s",
1058            "1.2s",
1059            "1.2m",
1060            "1.2h",
1061            "24f",
1062            "120t",
1063            "01:02:03",
1064            "01:02:03.235",
1065            "01:02:03.2350",
1066            "01:02:03:20",
1067            "100:00:00.1",
1068            "100:00:00:00",
1069            "00:00:00.000",
1070            "00:00:10.000",
1071            "1s",
1072            "5s",
1073            "6s",
1074            "9s",
1075            "10s",
1076            "20s",
1077        ];
1078
1079        for expr_str in &expressions {
1080            let parsed = parse_time_expression(expr_str, &default_ctx())
1081                .unwrap_or_else(|e| panic!("failed to parse '{expr_str}': {e}"));
1082            let formatted = format_time_expression(&parsed);
1083            // Re-parse the formatted version
1084            let re_parsed = parse_time_expression(&formatted, &default_ctx())
1085                .unwrap_or_else(|e| panic!("failed to re-parse '{formatted}': {e}"));
1086            assert_eq!(
1087                parsed, re_parsed,
1088                "round-trip failed for '{expr_str}' -> '{formatted}'"
1089            );
1090        }
1091    }
1092}