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/// # 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/// Build a `Timecode` directly without the safe constructor (for tests).
220fn raw_timecode(hours: u8, minutes: u8, seconds: u8, frames: u8, fps: u8, drop: bool) -> Timecode {
221    Timecode {
222        hours,
223        minutes,
224        seconds,
225        frames,
226        frame_rate: FrameRateInfo {
227            fps,
228            drop_frame: drop,
229        },
230        user_bits: 0,
231    }
232}
233
234// ── Tests ─────────────────────────────────────────────────────────────────────
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::FrameRate;
240
241    fn valid_25fps() -> Timecode {
242        Timecode::new(1, 30, 0, 12, FrameRate::Fps25).expect("valid timecode")
243    }
244
245    #[test]
246    fn test_valid_timecode_no_violations() {
247        let v = TimecodeValidator::default_validator();
248        assert!(v.validate(&valid_25fps()).is_empty());
249    }
250
251    #[test]
252    fn test_is_valid_returns_true_for_good_tc() {
253        let v = TimecodeValidator::default_validator();
254        assert!(v.is_valid(&valid_25fps()));
255    }
256
257    #[test]
258    fn test_hours_out_of_range() {
259        let tc = raw_timecode(24, 0, 0, 0, 25, false);
260        let v = TimecodeValidator::default_validator();
261        let vios = v.validate(&tc);
262        assert!(vios.iter().any(|x| x.rule == ValidationRule::HoursInRange));
263    }
264
265    #[test]
266    fn test_minutes_out_of_range() {
267        let tc = raw_timecode(0, 60, 0, 0, 25, false);
268        let v = TimecodeValidator::default_validator();
269        let vios = v.validate(&tc);
270        assert!(vios
271            .iter()
272            .any(|x| x.rule == ValidationRule::MinutesInRange));
273    }
274
275    #[test]
276    fn test_seconds_out_of_range() {
277        let tc = raw_timecode(0, 0, 60, 0, 25, false);
278        let v = TimecodeValidator::default_validator();
279        let vios = v.validate(&tc);
280        assert!(vios
281            .iter()
282            .any(|x| x.rule == ValidationRule::SecondsInRange));
283    }
284
285    #[test]
286    fn test_frames_out_of_range() {
287        let tc = raw_timecode(0, 0, 0, 25, 25, false);
288        let v = TimecodeValidator::default_validator();
289        let vios = v.validate(&tc);
290        assert!(vios.iter().any(|x| x.rule == ValidationRule::FramesInRange));
291    }
292
293    #[test]
294    fn test_drop_frame_illegal_position_detected() {
295        // Frames 0 at minute 1, second 0 — illegal in 29.97 DF
296        let tc = raw_timecode(0, 1, 0, 0, 30, true);
297        let v = TimecodeValidator::default_validator();
298        let vios = v.validate(&tc);
299        assert!(vios
300            .iter()
301            .any(|x| x.rule == ValidationRule::DropFramePositions));
302    }
303
304    #[test]
305    fn test_drop_frame_tenth_minute_is_ok() {
306        // Minute 10 is a "keep" minute for DF — frames 0 is legal
307        let tc = raw_timecode(0, 10, 0, 0, 30, true);
308        let v = TimecodeValidator::default_validator();
309        let vios = v.validate(&tc);
310        assert!(!vios
311            .iter()
312            .any(|x| x.rule == ValidationRule::DropFramePositions));
313    }
314
315    #[test]
316    fn test_within_range_pass() {
317        let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).expect("valid timecode"); // 25 frames
318        let cfg = ValidatorConfig {
319            rules: vec![ValidationRule::WithinRange],
320            allowed_range: Some((0, 100)),
321        };
322        let v = TimecodeValidator::new(cfg);
323        assert!(v.validate(&tc).is_empty());
324    }
325
326    #[test]
327    fn test_within_range_fail() {
328        let tc = Timecode::new(0, 0, 10, 0, FrameRate::Fps25).expect("valid timecode"); // 250 frames
329        let cfg = ValidatorConfig {
330            rules: vec![ValidationRule::WithinRange],
331            allowed_range: Some((0, 100)),
332        };
333        let v = TimecodeValidator::new(cfg);
334        let vios = v.validate(&tc);
335        assert!(vios.iter().any(|x| x.rule == ValidationRule::WithinRange));
336    }
337
338    #[test]
339    fn test_validate_range_empty_slice() {
340        let v = TimecodeValidator::default_validator();
341        assert!(v.validate_range(&[]).is_empty());
342    }
343
344    #[test]
345    fn test_validate_range_all_valid() {
346        let tcs: Vec<Timecode> = (0u8..5)
347            .map(|f| Timecode::new(0, 0, 0, f, FrameRate::Fps25).expect("valid timecode"))
348            .collect();
349        let v = TimecodeValidator::default_validator();
350        assert!(v.validate_range(&tcs).is_empty());
351    }
352
353    #[test]
354    fn test_validate_range_with_violation() {
355        let good = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
356        let bad = raw_timecode(0, 0, 0, 25, 25, false); // frames == fps
357        let v = TimecodeValidator::default_validator();
358        let results = v.validate_range(&[good, bad]);
359        assert!(!results.is_empty());
360        assert_eq!(results[0].0, 1); // index of bad timecode
361    }
362
363    #[test]
364    fn test_rule_display() {
365        assert_eq!(ValidationRule::HoursInRange.to_string(), "hours-in-range");
366        assert_eq!(
367            ValidationRule::DropFramePositions.to_string(),
368            "drop-frame-positions"
369        );
370        assert_eq!(ValidationRule::WithinRange.to_string(), "within-range");
371    }
372
373    #[test]
374    fn test_violation_display() {
375        let v = TcViolation::new(ValidationRule::FramesInRange, "frames 30 >= fps 30");
376        let s = v.to_string();
377        assert!(s.contains("frames-in-range"));
378        assert!(s.contains("frames 30"));
379    }
380
381    #[test]
382    fn test_no_rules_produces_no_violations() {
383        let tc = raw_timecode(99, 99, 99, 99, 25, false); // everything out of range
384        let cfg = ValidatorConfig {
385            rules: vec![],
386            allowed_range: None,
387        };
388        let v = TimecodeValidator::new(cfg);
389        assert!(v.validate(&tc).is_empty());
390    }
391
392    #[test]
393    fn test_multiple_violations_accumulate() {
394        let tc = raw_timecode(24, 60, 60, 25, 25, false);
395        let v = TimecodeValidator::default_validator();
396        let vios = v.validate(&tc);
397        // Should find at least hours, minutes, seconds, frames violations
398        assert!(vios.len() >= 4);
399    }
400}