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