Skip to main content

oximedia_timecode/
tc_validator.rs

1//! Timecode validation rules and violation reporting.
2//!
3//! This module provides structured validation of SMPTE timecodes against a
4//! configurable set of rules, producing typed violation reports rather than
5//! bare errors so callers can decide how to handle each issue.
6
7#![allow(dead_code)]
8
9use crate::Timecode;
10
11// ── Validation rules ──────────────────────────────────────────────────────────
12
13/// A single validation rule that can be applied to a timecode.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ValidationRule {
16    /// Hours must be in 0–23.
17    HoursInRange,
18    /// Minutes must be in 0–59.
19    MinutesInRange,
20    /// Seconds must be in 0–59.
21    SecondsInRange,
22    /// Frame count must be in 0–(fps-1).
23    FramesInRange,
24    /// Frames 0 and 1 are illegal at the start of non-tenth minutes (DF only).
25    DropFramePositions,
26    /// Timecode must lie within an explicit allowed range \[start, end\].
27    WithinRange,
28}
29
30impl std::fmt::Display for ValidationRule {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::HoursInRange => write!(f, "hours-in-range"),
34            Self::MinutesInRange => write!(f, "minutes-in-range"),
35            Self::SecondsInRange => write!(f, "seconds-in-range"),
36            Self::FramesInRange => write!(f, "frames-in-range"),
37            Self::DropFramePositions => write!(f, "drop-frame-positions"),
38            Self::WithinRange => write!(f, "within-range"),
39        }
40    }
41}
42
43// ── Violations ────────────────────────────────────────────────────────────────
44
45/// A validation violation: which rule failed and a human-readable message.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct TcViolation {
48    /// The rule that was violated.
49    pub rule: ValidationRule,
50    /// A description of the problem.
51    pub message: String,
52}
53
54impl TcViolation {
55    /// Create a new violation.
56    pub fn new(rule: ValidationRule, message: impl Into<String>) -> Self {
57        Self {
58            rule,
59            message: message.into(),
60        }
61    }
62}
63
64impl std::fmt::Display for TcViolation {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "[{}] {}", self.rule, self.message)
67    }
68}
69
70// ── Validator ─────────────────────────────────────────────────────────────────
71
72/// Configuration for `TimecodeValidator`.
73#[derive(Debug, Clone)]
74pub struct ValidatorConfig {
75    /// Rules to check. Defaults to all rules except `WithinRange`.
76    pub rules: Vec<ValidationRule>,
77    /// Optional allowed range `[start_frames, end_frames]` (inclusive).
78    /// Only checked when `ValidationRule::WithinRange` is enabled.
79    pub allowed_range: Option<(u64, u64)>,
80}
81
82impl Default for ValidatorConfig {
83    fn default() -> Self {
84        Self {
85            rules: vec![
86                ValidationRule::HoursInRange,
87                ValidationRule::MinutesInRange,
88                ValidationRule::SecondsInRange,
89                ValidationRule::FramesInRange,
90                ValidationRule::DropFramePositions,
91            ],
92            allowed_range: None,
93        }
94    }
95}
96
97/// Validates timecodes against a configurable set of rules.
98///
99/// # Example
100/// ```
101/// use oximedia_timecode::{Timecode, FrameRate};
102/// use oximedia_timecode::tc_validator::{TimecodeValidator, ValidatorConfig};
103///
104/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
105/// let validator = TimecodeValidator::new(ValidatorConfig::default());
106/// let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25)?;
107/// assert!(validator.validate(&tc).is_empty());
108/// # Ok(())
109/// # }
110/// ```
111#[derive(Debug, Clone)]
112pub struct TimecodeValidator {
113    config: ValidatorConfig,
114}
115
116impl TimecodeValidator {
117    /// Create a new validator with the given configuration.
118    pub fn new(config: ValidatorConfig) -> Self {
119        Self { config }
120    }
121
122    /// Create a validator with default rules.
123    pub fn default_validator() -> Self {
124        Self::new(ValidatorConfig::default())
125    }
126
127    /// Validate a `Timecode` and return all violations found.
128    /// An empty `Vec` means the timecode is valid under the configured rules.
129    pub fn validate(&self, tc: &Timecode) -> Vec<TcViolation> {
130        let mut violations = Vec::new();
131        for &rule in &self.config.rules {
132            match rule {
133                ValidationRule::HoursInRange => {
134                    if tc.hours > 23 {
135                        violations.push(TcViolation::new(
136                            rule,
137                            format!("hours {} exceeds maximum of 23", tc.hours),
138                        ));
139                    }
140                }
141                ValidationRule::MinutesInRange => {
142                    if tc.minutes > 59 {
143                        violations.push(TcViolation::new(
144                            rule,
145                            format!("minutes {} exceeds maximum of 59", tc.minutes),
146                        ));
147                    }
148                }
149                ValidationRule::SecondsInRange => {
150                    if tc.seconds > 59 {
151                        violations.push(TcViolation::new(
152                            rule,
153                            format!("seconds {} exceeds maximum of 59", tc.seconds),
154                        ));
155                    }
156                }
157                ValidationRule::FramesInRange => {
158                    if tc.frames >= tc.frame_rate.fps {
159                        violations.push(TcViolation::new(
160                            rule,
161                            format!("frames {} >= fps {}", tc.frames, tc.frame_rate.fps),
162                        ));
163                    }
164                }
165                ValidationRule::DropFramePositions => {
166                    if tc.frame_rate.drop_frame
167                        && tc.seconds == 0
168                        && tc.frames < 2
169                        && !tc.minutes.is_multiple_of(10)
170                    {
171                        violations.push(TcViolation::new(
172                            rule,
173                            format!(
174                                "frames {f} at {m}:00 is an illegal drop-frame position",
175                                f = tc.frames,
176                                m = tc.minutes,
177                            ),
178                        ));
179                    }
180                }
181                ValidationRule::WithinRange => {
182                    if let Some((start, end)) = self.config.allowed_range {
183                        let pos = tc.to_frames();
184                        if pos < start || pos > end {
185                            violations.push(TcViolation::new(
186                                rule,
187                                format!(
188                                    "frame position {pos} is outside allowed range [{start}, {end}]"
189                                ),
190                            ));
191                        }
192                    }
193                }
194            }
195        }
196        violations
197    }
198
199    /// Validate a range of consecutive timecodes for continuity.
200    /// Returns violations for any timecode in the slice that fails validation.
201    pub fn validate_range(&self, timecodes: &[Timecode]) -> Vec<(usize, TcViolation)> {
202        let mut out = Vec::new();
203        for (i, tc) in timecodes.iter().enumerate() {
204            for v in self.validate(tc) {
205                out.push((i, v));
206            }
207        }
208        out
209    }
210
211    /// Return `true` if the timecode passes all configured rules.
212    pub fn is_valid(&self, tc: &Timecode) -> bool {
213        self.validate(tc).is_empty()
214    }
215}
216
217// ── Helper: build a raw Timecode bypassing constructor checks ─────────────────
218
219// ── Non-monotonic sequence detection ──────────────────────────────────────────
220
221/// A single non-monotonic event detected in a timecode sequence.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct NonMonotonicEvent {
224    /// Index of the timecode in the sequence that caused the event.
225    pub frame_index: usize,
226    /// The previous timecode (at `frame_index - 1`).
227    pub prev_tc: Timecode,
228    /// The current timecode (at `frame_index`).
229    pub curr_tc: Timecode,
230    /// Signed frame jump: `curr_tc.to_frames() - prev_tc.to_frames()`.
231    /// Negative means backwards; very large positive means a forward skip.
232    pub jump_frames: i64,
233}
234
235/// Scans a timecode sequence and reports positions where the timecode
236/// does **not** advance monotonically by the expected one frame per step.
237///
238/// Only jumps whose absolute value exceeds `threshold_frames` are reported,
239/// so callers can ignore minor jitter (e.g. `threshold_frames = 0` reports
240/// every non-unit step; `threshold_frames = 1` only reports jumps ≥ 2 frames).
241///
242/// # Example
243/// ```
244/// use oximedia_timecode::{Timecode, FrameRate};
245/// use oximedia_timecode::tc_validator::NonMonotonicDetector;
246///
247/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
248/// let tc0 = Timecode::new(0, 0, 0, 0, FrameRate::Fps25)?;
249/// let tc1 = Timecode::new(0, 0, 2, 0, FrameRate::Fps25)?; // 2-second jump
250/// let events = NonMonotonicDetector::new(1).scan_sequence(&[tc0, tc1]);
251/// assert_eq!(events.len(), 1);
252/// assert_eq!(events[0].frame_index, 1);
253/// # Ok(())
254/// # }
255/// ```
256#[derive(Debug, Clone)]
257pub struct NonMonotonicDetector {
258    /// Only report absolute jumps strictly greater than this value.
259    /// A threshold of `0` reports every non-unit step (including backwards).
260    /// A threshold of `1` reports jumps whose absolute magnitude is > 1 frame
261    /// (i.e. gaps of ≥ 2 or backwards movement of ≥ 2 frames).
262    threshold_frames: i64,
263}
264
265impl NonMonotonicDetector {
266    /// Create a new detector with the given threshold.
267    ///
268    /// `threshold_frames` is the *exclusive* lower bound on `|jump|` for
269    /// events to be emitted. Set to `0` to report every non-unit step.
270    pub fn new(threshold_frames: i64) -> Self {
271        Self { threshold_frames }
272    }
273
274    /// Scan `timecodes` and return all non-monotonic events.
275    ///
276    /// The slice must contain at least 2 elements for any events to be
277    /// produced; a slice of 0 or 1 elements always returns an empty `Vec`.
278    pub fn scan_sequence(self, timecodes: &[Timecode]) -> Vec<NonMonotonicEvent> {
279        let mut events = Vec::new();
280
281        for i in 1..timecodes.len() {
282            let prev = timecodes[i - 1];
283            let curr = timecodes[i];
284
285            let prev_f = prev.to_frames() as i64;
286            let curr_f = curr.to_frames() as i64;
287            let jump = curr_f - prev_f;
288
289            // Expected monotonic step is +1; any other step is non-monotonic.
290            // Only emit if |jump - 1| > threshold.
291            let deviation = (jump - 1).abs();
292            if deviation > self.threshold_frames {
293                events.push(NonMonotonicEvent {
294                    frame_index: i,
295                    prev_tc: prev,
296                    curr_tc: curr,
297                    jump_frames: jump,
298                });
299            }
300        }
301
302        events
303    }
304}
305
306// ── Helper: build a raw Timecode bypassing constructor checks ─────────────────
307
308/// Build a `Timecode` directly without the safe constructor (for tests).
309fn raw_timecode(hours: u8, minutes: u8, seconds: u8, frames: u8, fps: u8, drop: bool) -> Timecode {
310    Timecode::from_raw_fields(hours, minutes, seconds, frames, fps, drop, 0)
311}
312
313// ── Tests ─────────────────────────────────────────────────────────────────────
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::FrameRate;
319
320    fn valid_25fps() -> Timecode {
321        Timecode::new(1, 30, 0, 12, FrameRate::Fps25).expect("valid timecode")
322    }
323
324    #[test]
325    fn test_valid_timecode_no_violations() {
326        let v = TimecodeValidator::default_validator();
327        assert!(v.validate(&valid_25fps()).is_empty());
328    }
329
330    #[test]
331    fn test_is_valid_returns_true_for_good_tc() {
332        let v = TimecodeValidator::default_validator();
333        assert!(v.is_valid(&valid_25fps()));
334    }
335
336    #[test]
337    fn test_hours_out_of_range() {
338        let tc = raw_timecode(24, 0, 0, 0, 25, false);
339        let v = TimecodeValidator::default_validator();
340        let vios = v.validate(&tc);
341        assert!(vios.iter().any(|x| x.rule == ValidationRule::HoursInRange));
342    }
343
344    #[test]
345    fn test_minutes_out_of_range() {
346        let tc = raw_timecode(0, 60, 0, 0, 25, false);
347        let v = TimecodeValidator::default_validator();
348        let vios = v.validate(&tc);
349        assert!(vios
350            .iter()
351            .any(|x| x.rule == ValidationRule::MinutesInRange));
352    }
353
354    #[test]
355    fn test_seconds_out_of_range() {
356        let tc = raw_timecode(0, 0, 60, 0, 25, false);
357        let v = TimecodeValidator::default_validator();
358        let vios = v.validate(&tc);
359        assert!(vios
360            .iter()
361            .any(|x| x.rule == ValidationRule::SecondsInRange));
362    }
363
364    #[test]
365    fn test_frames_out_of_range() {
366        let tc = raw_timecode(0, 0, 0, 25, 25, false);
367        let v = TimecodeValidator::default_validator();
368        let vios = v.validate(&tc);
369        assert!(vios.iter().any(|x| x.rule == ValidationRule::FramesInRange));
370    }
371
372    #[test]
373    fn test_drop_frame_illegal_position_detected() {
374        // Frames 0 at minute 1, second 0 — illegal in 29.97 DF
375        let tc = raw_timecode(0, 1, 0, 0, 30, true);
376        let v = TimecodeValidator::default_validator();
377        let vios = v.validate(&tc);
378        assert!(vios
379            .iter()
380            .any(|x| x.rule == ValidationRule::DropFramePositions));
381    }
382
383    #[test]
384    fn test_drop_frame_tenth_minute_is_ok() {
385        // Minute 10 is a "keep" minute for DF — frames 0 is legal
386        let tc = raw_timecode(0, 10, 0, 0, 30, true);
387        let v = TimecodeValidator::default_validator();
388        let vios = v.validate(&tc);
389        assert!(!vios
390            .iter()
391            .any(|x| x.rule == ValidationRule::DropFramePositions));
392    }
393
394    #[test]
395    fn test_within_range_pass() {
396        let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid timecode"); // 25 frames
397        let cfg = ValidatorConfig {
398            rules: vec![ValidationRule::WithinRange],
399            allowed_range: Some((0, 100)),
400        };
401        let v = TimecodeValidator::new(cfg);
402        assert!(v.validate(&tc).is_empty());
403    }
404
405    #[test]
406    fn test_within_range_fail() {
407        let tc = Timecode::new(0, 0, 10, 0, FrameRate::Fps25).expect("valid timecode"); // 250 frames
408        let cfg = ValidatorConfig {
409            rules: vec![ValidationRule::WithinRange],
410            allowed_range: Some((0, 100)),
411        };
412        let v = TimecodeValidator::new(cfg);
413        let vios = v.validate(&tc);
414        assert!(vios.iter().any(|x| x.rule == ValidationRule::WithinRange));
415    }
416
417    #[test]
418    fn test_validate_range_empty_slice() {
419        let v = TimecodeValidator::default_validator();
420        assert!(v.validate_range(&[]).is_empty());
421    }
422
423    #[test]
424    fn test_validate_range_all_valid() {
425        let tcs: Vec<Timecode> = (0u8..5)
426            .map(|f| Timecode::new(0, 0, 0, f, FrameRate::Fps25).expect("valid timecode"))
427            .collect();
428        let v = TimecodeValidator::default_validator();
429        assert!(v.validate_range(&tcs).is_empty());
430    }
431
432    #[test]
433    fn test_validate_range_with_violation() {
434        let good = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
435        let bad = raw_timecode(0, 0, 0, 25, 25, false); // frames == fps
436        let v = TimecodeValidator::default_validator();
437        let results = v.validate_range(&[good, bad]);
438        assert!(!results.is_empty());
439        assert_eq!(results[0].0, 1); // index of bad timecode
440    }
441
442    #[test]
443    fn test_rule_display() {
444        assert_eq!(ValidationRule::HoursInRange.to_string(), "hours-in-range");
445        assert_eq!(
446            ValidationRule::DropFramePositions.to_string(),
447            "drop-frame-positions"
448        );
449        assert_eq!(ValidationRule::WithinRange.to_string(), "within-range");
450    }
451
452    #[test]
453    fn test_violation_display() {
454        let v = TcViolation::new(ValidationRule::FramesInRange, "frames 30 >= fps 30");
455        let s = v.to_string();
456        assert!(s.contains("frames-in-range"));
457        assert!(s.contains("frames 30"));
458    }
459
460    #[test]
461    fn test_no_rules_produces_no_violations() {
462        let tc = raw_timecode(99, 99, 99, 99, 25, false); // everything out of range
463        let cfg = ValidatorConfig {
464            rules: vec![],
465            allowed_range: None,
466        };
467        let v = TimecodeValidator::new(cfg);
468        assert!(v.validate(&tc).is_empty());
469    }
470
471    #[test]
472    fn test_multiple_violations_accumulate() {
473        let tc = raw_timecode(24, 60, 60, 25, 25, false);
474        let v = TimecodeValidator::default_validator();
475        let vios = v.validate(&tc);
476        // Should find at least hours, minutes, seconds, frames violations
477        assert!(vios.len() >= 4);
478    }
479
480    // ── NonMonotonicDetector tests ──────────────────────────────────────────
481
482    #[test]
483    fn test_non_monotonic_empty_slice() {
484        let events = NonMonotonicDetector::new(0).scan_sequence(&[]);
485        assert!(events.is_empty());
486    }
487
488    #[test]
489    fn test_non_monotonic_single_element() {
490        let tc = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
491        let events = NonMonotonicDetector::new(0).scan_sequence(&[tc]);
492        assert!(events.is_empty());
493    }
494
495    #[test]
496    fn test_non_monotonic_normal_sequence_no_events() {
497        // Build a perfectly sequential 25-frame sequence at 25fps.
498        let tcs: Vec<Timecode> = (0u8..25)
499            .map(|f| Timecode::new(0, 0, 0, f, FrameRate::Fps25).expect("valid"))
500            .collect();
501        let events = NonMonotonicDetector::new(0).scan_sequence(&tcs);
502        assert!(
503            events.is_empty(),
504            "sequential sequence should produce no events, got: {:?}",
505            events
506        );
507    }
508
509    #[test]
510    fn test_non_monotonic_2_second_jump_detected() {
511        // Jump from 00:00:00:00 to 00:00:02:00 = +50 frames at 25fps.
512        let tc0 = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
513        let tc1 = Timecode::new(0, 0, 2, 0, FrameRate::Fps25).expect("valid");
514        let events = NonMonotonicDetector::new(1).scan_sequence(&[tc0, tc1]);
515        assert_eq!(events.len(), 1);
516        assert_eq!(events[0].frame_index, 1);
517        assert_eq!(events[0].jump_frames, 50);
518    }
519
520    #[test]
521    fn test_non_monotonic_backwards_detected() {
522        let tc0 = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid");
523        let tc1 = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid"); // backwards
524        let events = NonMonotonicDetector::new(0).scan_sequence(&[tc0, tc1]);
525        assert_eq!(events.len(), 1);
526        assert!(events[0].jump_frames < 0);
527    }
528
529    #[test]
530    fn test_non_monotonic_threshold_filters_small_jumps() {
531        // Jump of exactly 2 frames: with threshold=1, |2-1|=1 which is NOT > 1, so no event.
532        let tc0 = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
533        let tc1 = Timecode::new(0, 0, 0, 2, FrameRate::Fps25).expect("valid"); // +2 frame jump
534                                                                               // threshold=1: deviation = |2-1| = 1, NOT > 1 → no event
535        let events = NonMonotonicDetector::new(1).scan_sequence(&[tc0, tc1]);
536        assert!(
537            events.is_empty(),
538            "jump of 2 should be filtered by threshold=1"
539        );
540
541        // threshold=0: deviation = 1 > 0 → event emitted
542        let tc0b = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
543        let tc1b = Timecode::new(0, 0, 0, 2, FrameRate::Fps25).expect("valid");
544        let events2 = NonMonotonicDetector::new(0).scan_sequence(&[tc0b, tc1b]);
545        assert_eq!(events2.len(), 1);
546    }
547
548    #[test]
549    fn test_non_monotonic_multiple_events() {
550        // A sequence: normal, then jump, then normal again, then backwards.
551        let tcs = vec![
552            Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid"),
553            Timecode::new(0, 0, 0, 1, FrameRate::Fps25).expect("valid"), // +1 ok
554            Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid"), // +24 jump
555            Timecode::new(0, 0, 1, 1, FrameRate::Fps25).expect("valid"), // +1 ok
556            Timecode::new(0, 0, 0, 5, FrameRate::Fps25).expect("valid"), // backwards
557        ];
558        let events = NonMonotonicDetector::new(1).scan_sequence(&tcs);
559        // Should detect index 2 (jump) and index 4 (backwards)
560        assert_eq!(events.len(), 2);
561        assert_eq!(events[0].frame_index, 2);
562        assert_eq!(events[1].frame_index, 4);
563    }
564}