Skip to main content

oximedia_transcode/
validation.rs

1//! Input and output validation for transcode operations.
2
3use std::path::Path;
4use thiserror::Error;
5
6/// Validation errors.
7#[derive(Debug, Clone, Error)]
8pub enum ValidationError {
9    /// Input file does not exist.
10    #[error("Input file does not exist: {0}")]
11    InputNotFound(String),
12
13    /// Input file is not readable.
14    #[error("Input file is not readable: {0}")]
15    InputNotReadable(String),
16
17    /// Input file has no video stream.
18    #[error("Input file has no video stream")]
19    NoVideoStream,
20
21    /// Input file has no audio stream.
22    #[error("Input file has no audio stream")]
23    NoAudioStream,
24
25    /// Invalid input format.
26    #[error("Invalid input format: {0}")]
27    InvalidInputFormat(String),
28
29    /// Output path is invalid.
30    #[error("Invalid output path: {0}")]
31    InvalidOutputPath(String),
32
33    /// Output directory is not writable.
34    #[error("Output directory is not writable: {0}")]
35    OutputNotWritable(String),
36
37    /// Output file already exists.
38    #[error("Output file already exists: {0}")]
39    OutputExists(String),
40
41    /// Invalid codec selection.
42    #[error("Invalid codec: {0}")]
43    InvalidCodec(String),
44
45    /// Invalid resolution.
46    #[error("Invalid resolution: {0}")]
47    InvalidResolution(String),
48
49    /// Invalid bitrate.
50    #[error("Invalid bitrate: {0}")]
51    InvalidBitrate(String),
52
53    /// Invalid frame rate.
54    #[error("Invalid frame rate: {0}")]
55    InvalidFrameRate(String),
56
57    /// Unsupported operation.
58    #[error("Unsupported operation: {0}")]
59    Unsupported(String),
60}
61
62/// Input file validator.
63pub struct InputValidator;
64
65impl InputValidator {
66    /// Validates an input file path.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the input file is invalid or inaccessible.
71    pub fn validate_path(path: &str) -> Result<(), ValidationError> {
72        let path_obj = Path::new(path);
73
74        if !path_obj.exists() {
75            return Err(ValidationError::InputNotFound(path.to_string()));
76        }
77
78        if !path_obj.is_file() {
79            return Err(ValidationError::InvalidInputFormat(
80                "Path is not a file".to_string(),
81            ));
82        }
83
84        // Check if file is readable
85        match std::fs::metadata(path_obj) {
86            Ok(metadata) => {
87                if metadata.len() == 0 {
88                    return Err(ValidationError::InvalidInputFormat(
89                        "File is empty".to_string(),
90                    ));
91                }
92            }
93            Err(_) => {
94                return Err(ValidationError::InputNotReadable(path.to_string()));
95            }
96        }
97
98        Ok(())
99    }
100
101    /// Validates input format based on file extension.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if the format is not supported.
106    pub fn validate_format(path: &str) -> Result<String, ValidationError> {
107        let path_obj = Path::new(path);
108        let extension = path_obj
109            .extension()
110            .and_then(|e| e.to_str())
111            .ok_or_else(|| ValidationError::InvalidInputFormat("No file extension".to_string()))?;
112
113        let ext_lower = extension.to_lowercase();
114
115        // Check if extension is in supported formats
116        match ext_lower.as_str() {
117            "mp4" | "mkv" | "webm" | "avi" | "mov" | "flv" | "wmv" | "m4v" | "mpg" | "mpeg"
118            | "ts" | "mts" | "m2ts" | "ogv" | "3gp" => Ok(ext_lower),
119            _ => Err(ValidationError::InvalidInputFormat(format!(
120                "Unsupported format: {extension}"
121            ))),
122        }
123    }
124
125    /// Validates that input has required streams.
126    pub fn validate_streams(
127        has_video: bool,
128        has_audio: bool,
129        require_video: bool,
130        require_audio: bool,
131    ) -> Result<(), ValidationError> {
132        if require_video && !has_video {
133            return Err(ValidationError::NoVideoStream);
134        }
135
136        if require_audio && !has_audio {
137            return Err(ValidationError::NoAudioStream);
138        }
139
140        Ok(())
141    }
142}
143
144/// Output configuration validator.
145pub struct OutputValidator;
146
147impl OutputValidator {
148    /// Validates an output file path.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the output path is invalid or not writable.
153    pub fn validate_path(path: &str, overwrite: bool) -> Result<(), ValidationError> {
154        let path_obj = Path::new(path);
155
156        // Check if file already exists
157        if path_obj.exists() && !overwrite {
158            return Err(ValidationError::OutputExists(path.to_string()));
159        }
160
161        // Check if parent directory exists and is writable
162        if let Some(parent) = path_obj.parent() {
163            if !parent.exists() {
164                return Err(ValidationError::InvalidOutputPath(
165                    "Parent directory does not exist".to_string(),
166                ));
167            }
168
169            // Try to check if directory is writable
170            if let Err(_e) = std::fs::metadata(parent) {
171                return Err(ValidationError::OutputNotWritable(
172                    parent.display().to_string(),
173                ));
174            }
175        }
176
177        Ok(())
178    }
179
180    /// Validates output format based on file extension.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if the format is not supported.
185    pub fn validate_format(path: &str) -> Result<String, ValidationError> {
186        let path_obj = Path::new(path);
187        let extension = path_obj
188            .extension()
189            .and_then(|e| e.to_str())
190            .ok_or_else(|| ValidationError::InvalidOutputPath("No file extension".to_string()))?;
191
192        let ext_lower = extension.to_lowercase();
193
194        match ext_lower.as_str() {
195            "mp4" | "mkv" | "webm" | "avi" | "mov" | "m4v" | "ogv" => Ok(ext_lower),
196            _ => Err(ValidationError::InvalidOutputPath(format!(
197                "Unsupported output format: {extension}"
198            ))),
199        }
200    }
201
202    /// Validates codec selection.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if the codec is not supported.
207    pub fn validate_codec(codec: &str) -> Result<(), ValidationError> {
208        let codec_lower = codec.to_lowercase();
209
210        match codec_lower.as_str() {
211            "vp8" | "vp9" | "av1" | "h264" | "h265" | "theora" | "opus" | "vorbis" | "aac"
212            | "mp3" | "flac" => Ok(()),
213            _ => Err(ValidationError::InvalidCodec(format!(
214                "Unsupported codec: {codec}"
215            ))),
216        }
217    }
218
219    /// Validates resolution.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if the resolution is invalid.
224    pub fn validate_resolution(width: u32, height: u32) -> Result<(), ValidationError> {
225        if width == 0 || height == 0 {
226            return Err(ValidationError::InvalidResolution(
227                "Width and height must be greater than 0".to_string(),
228            ));
229        }
230
231        if width > 7680 || height > 4320 {
232            return Err(ValidationError::InvalidResolution(
233                "Resolution exceeds maximum (7680x4320)".to_string(),
234            ));
235        }
236
237        if width % 2 != 0 || height % 2 != 0 {
238            return Err(ValidationError::InvalidResolution(
239                "Width and height must be even numbers".to_string(),
240            ));
241        }
242
243        Ok(())
244    }
245
246    /// Validates bitrate.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if the bitrate is invalid.
251    pub fn validate_bitrate(bitrate: u64, min: u64, max: u64) -> Result<(), ValidationError> {
252        if bitrate < min {
253            return Err(ValidationError::InvalidBitrate(format!(
254                "Bitrate too low (minimum: {min})"
255            )));
256        }
257
258        if bitrate > max {
259            return Err(ValidationError::InvalidBitrate(format!(
260                "Bitrate too high (maximum: {max})"
261            )));
262        }
263
264        Ok(())
265    }
266
267    /// Validates frame rate.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if the frame rate is invalid.
272    pub fn validate_frame_rate(num: u32, den: u32) -> Result<(), ValidationError> {
273        if num == 0 || den == 0 {
274            return Err(ValidationError::InvalidFrameRate(
275                "Numerator and denominator must be greater than 0".to_string(),
276            ));
277        }
278
279        let fps = f64::from(num) / f64::from(den);
280
281        if fps < 1.0 {
282            return Err(ValidationError::InvalidFrameRate(
283                "Frame rate must be at least 1 fps".to_string(),
284            ));
285        }
286
287        if fps > 240.0 {
288            return Err(ValidationError::InvalidFrameRate(
289                "Frame rate exceeds maximum (240 fps)".to_string(),
290            ));
291        }
292
293        Ok(())
294    }
295}
296
297/// Validates codec compatibility with container format.
298pub fn validate_codec_container_compatibility(
299    codec: &str,
300    container: &str,
301) -> Result<(), ValidationError> {
302    let codec_lower = codec.to_lowercase();
303    let container_lower = container.to_lowercase();
304
305    let compatible = match container_lower.as_str() {
306        "mp4" | "m4v" => matches!(codec_lower.as_str(), "h264" | "h265" | "av1" | "aac"),
307        "webm" => matches!(
308            codec_lower.as_str(),
309            "vp8" | "vp9" | "av1" | "opus" | "vorbis"
310        ),
311        "mkv" => true, // MKV supports everything
312        "ogv" => matches!(codec_lower.as_str(), "theora" | "vorbis" | "opus"),
313        _ => false,
314    };
315
316    if compatible {
317        Ok(())
318    } else {
319        Err(ValidationError::Unsupported(format!(
320            "Codec '{codec}' is not compatible with container '{container}'"
321        )))
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_validate_format_valid() {
331        assert_eq!(
332            InputValidator::validate_format("test.mp4").expect("should succeed in test"),
333            "mp4"
334        );
335        assert_eq!(
336            InputValidator::validate_format("test.MKV").expect("should succeed in test"),
337            "mkv"
338        );
339        assert_eq!(
340            InputValidator::validate_format("test.webm").expect("should succeed in test"),
341            "webm"
342        );
343    }
344
345    #[test]
346    fn test_validate_format_invalid() {
347        assert!(InputValidator::validate_format("test.xyz").is_err());
348        assert!(InputValidator::validate_format("test").is_err());
349    }
350
351    #[test]
352    fn test_validate_codec_valid() {
353        assert!(OutputValidator::validate_codec("vp9").is_ok());
354        assert!(OutputValidator::validate_codec("h264").is_ok());
355        assert!(OutputValidator::validate_codec("opus").is_ok());
356    }
357
358    #[test]
359    fn test_validate_codec_invalid() {
360        assert!(OutputValidator::validate_codec("unknown").is_err());
361        assert!(OutputValidator::validate_codec("divx").is_err());
362    }
363
364    #[test]
365    fn test_validate_resolution_valid() {
366        assert!(OutputValidator::validate_resolution(1920, 1080).is_ok());
367        assert!(OutputValidator::validate_resolution(1280, 720).is_ok());
368        assert!(OutputValidator::validate_resolution(3840, 2160).is_ok());
369    }
370
371    #[test]
372    fn test_validate_resolution_invalid() {
373        // Zero dimensions
374        assert!(OutputValidator::validate_resolution(0, 1080).is_err());
375        assert!(OutputValidator::validate_resolution(1920, 0).is_err());
376
377        // Odd dimensions
378        assert!(OutputValidator::validate_resolution(1921, 1080).is_err());
379        assert!(OutputValidator::validate_resolution(1920, 1081).is_err());
380
381        // Too large
382        assert!(OutputValidator::validate_resolution(10000, 10000).is_err());
383    }
384
385    #[test]
386    fn test_validate_bitrate_valid() {
387        assert!(OutputValidator::validate_bitrate(5_000_000, 100_000, 50_000_000).is_ok());
388        assert!(OutputValidator::validate_bitrate(100_000, 100_000, 50_000_000).is_ok());
389        assert!(OutputValidator::validate_bitrate(50_000_000, 100_000, 50_000_000).is_ok());
390    }
391
392    #[test]
393    fn test_validate_bitrate_invalid() {
394        assert!(OutputValidator::validate_bitrate(50_000, 100_000, 50_000_000).is_err());
395        assert!(OutputValidator::validate_bitrate(100_000_000, 100_000, 50_000_000).is_err());
396    }
397
398    #[test]
399    fn test_validate_frame_rate_valid() {
400        assert!(OutputValidator::validate_frame_rate(30, 1).is_ok());
401        assert!(OutputValidator::validate_frame_rate(60, 1).is_ok());
402        assert!(OutputValidator::validate_frame_rate(24000, 1001).is_ok());
403    }
404
405    #[test]
406    fn test_validate_frame_rate_invalid() {
407        assert!(OutputValidator::validate_frame_rate(0, 1).is_err());
408        assert!(OutputValidator::validate_frame_rate(30, 0).is_err());
409        assert!(OutputValidator::validate_frame_rate(1, 10).is_err()); // 0.1 fps
410        assert!(OutputValidator::validate_frame_rate(300, 1).is_err()); // 300 fps
411    }
412
413    #[test]
414    fn test_codec_container_compatibility() {
415        // Valid combinations
416        assert!(validate_codec_container_compatibility("h264", "mp4").is_ok());
417        assert!(validate_codec_container_compatibility("vp9", "webm").is_ok());
418        assert!(validate_codec_container_compatibility("opus", "webm").is_ok());
419        assert!(validate_codec_container_compatibility("theora", "ogv").is_ok());
420        assert!(validate_codec_container_compatibility("h264", "mkv").is_ok());
421        assert!(validate_codec_container_compatibility("vp9", "mkv").is_ok());
422
423        // Invalid combinations
424        assert!(validate_codec_container_compatibility("vp9", "mp4").is_err());
425        assert!(validate_codec_container_compatibility("h264", "webm").is_err());
426        assert!(validate_codec_container_compatibility("aac", "webm").is_err());
427    }
428
429    #[test]
430    fn test_validate_streams() {
431        assert!(InputValidator::validate_streams(true, true, true, true).is_ok());
432        assert!(InputValidator::validate_streams(true, false, true, false).is_ok());
433        assert!(InputValidator::validate_streams(false, true, false, true).is_ok());
434        assert!(InputValidator::validate_streams(true, true, false, false).is_ok());
435
436        assert!(InputValidator::validate_streams(false, true, true, true).is_err());
437        assert!(InputValidator::validate_streams(true, false, true, true).is_err());
438    }
439
440    #[test]
441    fn test_validate_path_nonexistent() {
442        let result = InputValidator::validate_path("/nonexistent/file.mp4");
443        assert!(result.is_err());
444        assert!(matches!(
445            result.unwrap_err(),
446            ValidationError::InputNotFound(_)
447        ));
448    }
449
450    #[test]
451    fn test_output_path_validation() {
452        // Test with /tmp which should exist and be writable
453        let result = OutputValidator::validate_path("/tmp/test_output.mp4", false);
454        assert!(result.is_ok());
455    }
456
457    #[test]
458    fn test_output_format_validation() {
459        assert_eq!(
460            OutputValidator::validate_format("output.mp4").expect("should succeed in test"),
461            "mp4"
462        );
463        assert_eq!(
464            OutputValidator::validate_format("output.webm").expect("should succeed in test"),
465            "webm"
466        );
467        assert!(OutputValidator::validate_format("output.xyz").is_err());
468    }
469}