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::{FrameRateInfo, 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/// let validator = TimecodeValidator::new(ValidatorConfig::default());
105/// let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).unwrap();
106/// assert!(validator.validate(&tc).is_empty());
107/// ```
108#[derive(Debug, Clone)]
109pub struct TimecodeValidator {
110    config: ValidatorConfig,
111}
112
113impl TimecodeValidator {
114    /// Create a new validator with the given configuration.
115    pub fn new(config: ValidatorConfig) -> Self {
116        Self { config }
117    }
118
119    /// Create a validator with default rules.
120    pub fn default_validator() -> Self {
121        Self::new(ValidatorConfig::default())
122    }
123
124    /// Validate a `Timecode` and return all violations found.
125    /// An empty `Vec` means the timecode is valid under the configured rules.
126    pub fn validate(&self, tc: &Timecode) -> Vec<TcViolation> {
127        let mut violations = Vec::new();
128        for &rule in &self.config.rules {
129            match rule {
130                ValidationRule::HoursInRange => {
131                    if tc.hours > 23 {
132                        violations.push(TcViolation::new(
133                            rule,
134                            format!("hours {} exceeds maximum of 23", tc.hours),
135                        ));
136                    }
137                }
138                ValidationRule::MinutesInRange => {
139                    if tc.minutes > 59 {
140                        violations.push(TcViolation::new(
141                            rule,
142                            format!("minutes {} exceeds maximum of 59", tc.minutes),
143                        ));
144                    }
145                }
146                ValidationRule::SecondsInRange => {
147                    if tc.seconds > 59 {
148                        violations.push(TcViolation::new(
149                            rule,
150                            format!("seconds {} exceeds maximum of 59", tc.seconds),
151                        ));
152                    }
153                }
154                ValidationRule::FramesInRange => {
155                    if tc.frames >= tc.frame_rate.fps {
156                        violations.push(TcViolation::new(
157                            rule,
158                            format!("frames {} >= fps {}", tc.frames, tc.frame_rate.fps),
159                        ));
160                    }
161                }
162                ValidationRule::DropFramePositions => {
163                    if tc.frame_rate.drop_frame
164                        && tc.seconds == 0
165                        && tc.frames < 2
166                        && !tc.minutes.is_multiple_of(10)
167                    {
168                        violations.push(TcViolation::new(
169                            rule,
170                            format!(
171                                "frames {f} at {m}:00 is an illegal drop-frame position",
172                                f = tc.frames,
173                                m = tc.minutes,
174                            ),
175                        ));
176                    }
177                }
178                ValidationRule::WithinRange => {
179                    if let Some((start, end)) = self.config.allowed_range {
180                        let pos = tc.to_frames();
181                        if pos < start || pos > end {
182                            violations.push(TcViolation::new(
183                                rule,
184                                format!(
185                                    "frame position {pos} is outside allowed range [{start}, {end}]"
186                                ),
187                            ));
188                        }
189                    }
190                }
191            }
192        }
193        violations
194    }
195
196    /// Validate a range of consecutive timecodes for continuity.
197    /// Returns violations for any timecode in the slice that fails validation.
198    pub fn validate_range(&self, timecodes: &[Timecode]) -> Vec<(usize, TcViolation)> {
199        let mut out = Vec::new();
200        for (i, tc) in timecodes.iter().enumerate() {
201            for v in self.validate(tc) {
202                out.push((i, v));
203            }
204        }
205        out
206    }
207
208    /// Return `true` if the timecode passes all configured rules.
209    pub fn is_valid(&self, tc: &Timecode) -> bool {
210        self.validate(tc).is_empty()
211    }
212}
213
214// ── Helper: build a raw Timecode bypassing constructor checks ─────────────────
215
216/// Build a `Timecode` directly without the safe constructor (for tests).
217fn raw_timecode(hours: u8, minutes: u8, seconds: u8, frames: u8, fps: u8, drop: bool) -> Timecode {
218    Timecode {
219        hours,
220        minutes,
221        seconds,
222        frames,
223        frame_rate: FrameRateInfo {
224            fps,
225            drop_frame: drop,
226        },
227        user_bits: 0,
228    }
229}
230
231// ── Tests ─────────────────────────────────────────────────────────────────────
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::FrameRate;
237
238    fn valid_25fps() -> Timecode {
239        Timecode::new(1, 30, 0, 12, FrameRate::Fps25).unwrap()
240    }
241
242    #[test]
243    fn test_valid_timecode_no_violations() {
244        let v = TimecodeValidator::default_validator();
245        assert!(v.validate(&valid_25fps()).is_empty());
246    }
247
248    #[test]
249    fn test_is_valid_returns_true_for_good_tc() {
250        let v = TimecodeValidator::default_validator();
251        assert!(v.is_valid(&valid_25fps()));
252    }
253
254    #[test]
255    fn test_hours_out_of_range() {
256        let tc = raw_timecode(24, 0, 0, 0, 25, false);
257        let v = TimecodeValidator::default_validator();
258        let vios = v.validate(&tc);
259        assert!(vios.iter().any(|x| x.rule == ValidationRule::HoursInRange));
260    }
261
262    #[test]
263    fn test_minutes_out_of_range() {
264        let tc = raw_timecode(0, 60, 0, 0, 25, false);
265        let v = TimecodeValidator::default_validator();
266        let vios = v.validate(&tc);
267        assert!(vios
268            .iter()
269            .any(|x| x.rule == ValidationRule::MinutesInRange));
270    }
271
272    #[test]
273    fn test_seconds_out_of_range() {
274        let tc = raw_timecode(0, 0, 60, 0, 25, false);
275        let v = TimecodeValidator::default_validator();
276        let vios = v.validate(&tc);
277        assert!(vios
278            .iter()
279            .any(|x| x.rule == ValidationRule::SecondsInRange));
280    }
281
282    #[test]
283    fn test_frames_out_of_range() {
284        let tc = raw_timecode(0, 0, 0, 25, 25, false);
285        let v = TimecodeValidator::default_validator();
286        let vios = v.validate(&tc);
287        assert!(vios.iter().any(|x| x.rule == ValidationRule::FramesInRange));
288    }
289
290    #[test]
291    fn test_drop_frame_illegal_position_detected() {
292        // Frames 0 at minute 1, second 0 — illegal in 29.97 DF
293        let tc = raw_timecode(0, 1, 0, 0, 30, true);
294        let v = TimecodeValidator::default_validator();
295        let vios = v.validate(&tc);
296        assert!(vios
297            .iter()
298            .any(|x| x.rule == ValidationRule::DropFramePositions));
299    }
300
301    #[test]
302    fn test_drop_frame_tenth_minute_is_ok() {
303        // Minute 10 is a "keep" minute for DF — frames 0 is legal
304        let tc = raw_timecode(0, 10, 0, 0, 30, true);
305        let v = TimecodeValidator::default_validator();
306        let vios = v.validate(&tc);
307        assert!(!vios
308            .iter()
309            .any(|x| x.rule == ValidationRule::DropFramePositions));
310    }
311
312    #[test]
313    fn test_within_range_pass() {
314        let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).unwrap(); // 25 frames
315        let cfg = ValidatorConfig {
316            rules: vec![ValidationRule::WithinRange],
317            allowed_range: Some((0, 100)),
318        };
319        let v = TimecodeValidator::new(cfg);
320        assert!(v.validate(&tc).is_empty());
321    }
322
323    #[test]
324    fn test_within_range_fail() {
325        let tc = Timecode::new(0, 0, 10, 0, FrameRate::Fps25).unwrap(); // 250 frames
326        let cfg = ValidatorConfig {
327            rules: vec![ValidationRule::WithinRange],
328            allowed_range: Some((0, 100)),
329        };
330        let v = TimecodeValidator::new(cfg);
331        let vios = v.validate(&tc);
332        assert!(vios.iter().any(|x| x.rule == ValidationRule::WithinRange));
333    }
334
335    #[test]
336    fn test_validate_range_empty_slice() {
337        let v = TimecodeValidator::default_validator();
338        assert!(v.validate_range(&[]).is_empty());
339    }
340
341    #[test]
342    fn test_validate_range_all_valid() {
343        let tcs: Vec<Timecode> = (0u8..5)
344            .map(|f| Timecode::new(0, 0, 0, f, FrameRate::Fps25).unwrap())
345            .collect();
346        let v = TimecodeValidator::default_validator();
347        assert!(v.validate_range(&tcs).is_empty());
348    }
349
350    #[test]
351    fn test_validate_range_with_violation() {
352        let good = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).unwrap();
353        let bad = raw_timecode(0, 0, 0, 25, 25, false); // frames == fps
354        let v = TimecodeValidator::default_validator();
355        let results = v.validate_range(&[good, bad]);
356        assert!(!results.is_empty());
357        assert_eq!(results[0].0, 1); // index of bad timecode
358    }
359
360    #[test]
361    fn test_rule_display() {
362        assert_eq!(ValidationRule::HoursInRange.to_string(), "hours-in-range");
363        assert_eq!(
364            ValidationRule::DropFramePositions.to_string(),
365            "drop-frame-positions"
366        );
367        assert_eq!(ValidationRule::WithinRange.to_string(), "within-range");
368    }
369
370    #[test]
371    fn test_violation_display() {
372        let v = TcViolation::new(ValidationRule::FramesInRange, "frames 30 >= fps 30");
373        let s = v.to_string();
374        assert!(s.contains("frames-in-range"));
375        assert!(s.contains("frames 30"));
376    }
377
378    #[test]
379    fn test_no_rules_produces_no_violations() {
380        let tc = raw_timecode(99, 99, 99, 99, 25, false); // everything out of range
381        let cfg = ValidatorConfig {
382            rules: vec![],
383            allowed_range: None,
384        };
385        let v = TimecodeValidator::new(cfg);
386        assert!(v.validate(&tc).is_empty());
387    }
388
389    #[test]
390    fn test_multiple_violations_accumulate() {
391        let tc = raw_timecode(24, 60, 60, 25, 25, false);
392        let v = TimecodeValidator::default_validator();
393        let vios = v.validate(&tc);
394        // Should find at least hours, minutes, seconds, frames violations
395        assert!(vios.len() >= 4);
396    }
397}