Skip to main content

oximedia_proxy/validation/
validator.rs

1//! Comprehensive validation for proxy workflows.
2
3use super::report::ValidationReport;
4use crate::{ProxyLinkManager, Result};
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8/// Comprehensive workflow validator.
9pub struct WorkflowValidator<'a> {
10    link_manager: &'a ProxyLinkManager,
11    strict_mode: bool,
12}
13
14impl<'a> WorkflowValidator<'a> {
15    /// Create a new workflow validator.
16    #[must_use]
17    pub const fn new(link_manager: &'a ProxyLinkManager) -> Self {
18        Self {
19            link_manager,
20            strict_mode: false,
21        }
22    }
23
24    /// Enable strict validation mode.
25    #[must_use]
26    pub const fn strict(mut self) -> Self {
27        self.strict_mode = true;
28        self
29    }
30
31    /// Validate all aspects of the proxy workflow.
32    pub fn validate_all(&self) -> Result<ValidationReport> {
33        let mut errors = Vec::new();
34        let mut warnings = Vec::new();
35        let all_links = self.link_manager.all_links();
36        let total_links = all_links.len();
37
38        // Check 1: File existence
39        for link in &all_links {
40            if !link.proxy_path.exists() {
41                errors.push(format!("Proxy file missing: {}", link.proxy_path.display()));
42            }
43
44            if !link.original_path.exists() {
45                errors.push(format!(
46                    "Original file missing: {}",
47                    link.original_path.display()
48                ));
49            }
50        }
51
52        // Check 2: Duplicate links
53        let duplicates = self.find_duplicate_links(&all_links);
54        for (path, count) in duplicates {
55            if count > 1 {
56                warnings.push(format!(
57                    "Duplicate proxy link for: {} ({} times)",
58                    path.display(),
59                    count
60                ));
61            }
62        }
63
64        // Check 3: Orphaned proxies
65        let orphaned = self.find_orphaned_proxies(&all_links)?;
66        for path in orphaned {
67            warnings.push(format!("Orphaned proxy file: {}", path.display()));
68        }
69
70        // Check 4: File integrity
71        for link in &all_links {
72            if let Err(e) = self.validate_file_integrity(&link.proxy_path) {
73                errors.push(format!(
74                    "Proxy file integrity error ({}): {}",
75                    link.proxy_path.display(),
76                    e
77                ));
78            }
79        }
80
81        // Check 5: Metadata consistency
82        for link in &all_links {
83            if link.duration == 0.0 {
84                warnings.push(format!("Zero duration for: {}", link.proxy_path.display()));
85            }
86
87            if link.scale_factor <= 0.0 || link.scale_factor > 1.0 {
88                errors.push(format!(
89                    "Invalid scale factor ({}) for: {}",
90                    link.scale_factor,
91                    link.proxy_path.display()
92                ));
93            }
94        }
95
96        // Check 6: Timecode consistency (strict mode)
97        if self.strict_mode {
98            for link in &all_links {
99                if link.timecode.is_none() {
100                    warnings.push(format!(
101                        "Missing timecode for: {}",
102                        link.proxy_path.display()
103                    ));
104                }
105            }
106        }
107
108        let valid_links = total_links - errors.len();
109
110        Ok(ValidationReport {
111            total_links,
112            valid_links,
113            errors,
114            warnings,
115        })
116    }
117
118    /// Validate EDL file references.
119    pub fn validate_edl_references(&self, edl_path: &Path) -> Result<EdlValidationResult> {
120        if !edl_path.exists() {
121            return Err(crate::ProxyError::FileNotFound(
122                edl_path.display().to_string(),
123            ));
124        }
125
126        // Placeholder: would parse EDL and check all referenced files
127        Ok(EdlValidationResult {
128            total_clips: 0,
129            found_clips: 0,
130            missing_clips: Vec::new(),
131            unlinked_clips: Vec::new(),
132        })
133    }
134
135    /// Validate proxy-original file size relationship.
136    fn validate_file_integrity(&self, path: &Path) -> Result<()> {
137        let metadata = std::fs::metadata(path)?;
138
139        // Check file is not empty
140        if metadata.len() == 0 {
141            return Err(crate::ProxyError::ValidationError(
142                "File is empty".to_string(),
143            ));
144        }
145
146        // Check file is readable
147        if metadata.permissions().readonly() {
148            return Err(crate::ProxyError::ValidationError(
149                "File is read-only".to_string(),
150            ));
151        }
152
153        Ok(())
154    }
155
156    /// Find duplicate proxy links.
157    fn find_duplicate_links(&self, links: &[crate::ProxyLink]) -> Vec<(PathBuf, usize)> {
158        let mut path_counts: std::collections::HashMap<PathBuf, usize> =
159            std::collections::HashMap::new();
160
161        for link in links {
162            *path_counts.entry(link.proxy_path.clone()).or_insert(0) += 1;
163        }
164
165        path_counts
166            .into_iter()
167            .filter(|(_, count)| *count > 1)
168            .collect()
169    }
170
171    /// Find orphaned proxy files (proxies without links).
172    fn find_orphaned_proxies(&self, _links: &[crate::ProxyLink]) -> Result<Vec<PathBuf>> {
173        // Placeholder: would scan proxy directories for unlisted files
174        Ok(Vec::new())
175    }
176}
177
178/// EDL validation result.
179#[derive(Debug, Clone)]
180pub struct EdlValidationResult {
181    /// Total clips referenced in EDL.
182    pub total_clips: usize,
183
184    /// Clips found on disk.
185    pub found_clips: usize,
186
187    /// Missing clip files.
188    pub missing_clips: Vec<String>,
189
190    /// Clips without proxy links.
191    pub unlinked_clips: Vec<String>,
192}
193
194impl EdlValidationResult {
195    /// Check if all clips are valid.
196    #[must_use]
197    pub const fn is_valid(&self) -> bool {
198        self.missing_clips.is_empty() && self.unlinked_clips.is_empty()
199    }
200
201    /// Get validation percentage.
202    #[must_use]
203    pub fn validation_percentage(&self) -> f64 {
204        if self.total_clips == 0 {
205            100.0
206        } else {
207            (self.found_clips as f64 / self.total_clips as f64) * 100.0
208        }
209    }
210}
211
212/// Path validator for checking path-related issues.
213pub struct PathValidator;
214
215impl PathValidator {
216    /// Validate a file path for proxy use.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if the path is invalid.
221    pub fn validate_path(path: &Path) -> Result<()> {
222        // Check path is not empty
223        if path.as_os_str().is_empty() {
224            return Err(crate::ProxyError::ValidationError(
225                "Path is empty".to_string(),
226            ));
227        }
228
229        // Check for invalid characters
230        if let Some(path_str) = path.to_str() {
231            if path_str.contains('\0') {
232                return Err(crate::ProxyError::ValidationError(
233                    "Path contains null characters".to_string(),
234                ));
235            }
236        }
237
238        // Check parent directory exists
239        if let Some(parent) = path.parent() {
240            if !parent.exists() && !parent.as_os_str().is_empty() {
241                return Err(crate::ProxyError::ValidationError(format!(
242                    "Parent directory does not exist: {}",
243                    parent.display()
244                )));
245            }
246        }
247
248        Ok(())
249    }
250
251    /// Validate a directory for proxy storage.
252    pub fn validate_directory(dir: &Path) -> Result<DirectoryValidation> {
253        if !dir.exists() {
254            return Ok(DirectoryValidation {
255                exists: false,
256                writable: false,
257                available_space: 0,
258                total_space: 0,
259            });
260        }
261
262        if !dir.is_dir() {
263            return Err(crate::ProxyError::ValidationError(
264                "Path is not a directory".to_string(),
265            ));
266        }
267
268        // Check if writable
269        let writable = is_writable(dir);
270
271        // Get disk space info (placeholder)
272        let available_space = 0u64; // Would use system calls
273        let total_space = 0u64;
274
275        Ok(DirectoryValidation {
276            exists: true,
277            writable,
278            available_space,
279            total_space,
280        })
281    }
282
283    /// Check for path conflicts.
284    pub fn check_path_conflicts(paths: &[PathBuf]) -> Vec<PathBuf> {
285        let mut seen = HashSet::new();
286        let mut conflicts = Vec::new();
287
288        for path in paths {
289            if !seen.insert(path.clone()) {
290                conflicts.push(path.clone());
291            }
292        }
293
294        conflicts
295    }
296}
297
298/// Directory validation result.
299#[derive(Debug, Clone)]
300pub struct DirectoryValidation {
301    /// Directory exists.
302    pub exists: bool,
303
304    /// Directory is writable.
305    pub writable: bool,
306
307    /// Available space in bytes.
308    pub available_space: u64,
309
310    /// Total space in bytes.
311    pub total_space: u64,
312}
313
314impl DirectoryValidation {
315    /// Check if directory is usable.
316    #[must_use]
317    pub const fn is_usable(&self) -> bool {
318        self.exists && self.writable
319    }
320
321    /// Get usage percentage.
322    #[must_use]
323    pub fn usage_percentage(&self) -> f64 {
324        if self.total_space == 0 {
325            0.0
326        } else {
327            ((self.total_space - self.available_space) as f64 / self.total_space as f64) * 100.0
328        }
329    }
330}
331
332fn is_writable(dir: &Path) -> bool {
333    // Try to create a temporary file
334    let test_file = dir.join(".write_test");
335    std::fs::write(&test_file, b"test").is_ok() && std::fs::remove_file(&test_file).is_ok()
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[tokio::test]
343    async fn test_workflow_validator() {
344        let temp_dir = std::env::temp_dir();
345        let db_path = temp_dir.join("test_validator.json");
346
347        let manager = ProxyLinkManager::new(&db_path)
348            .await
349            .expect("should succeed in test");
350        let validator = WorkflowValidator::new(&manager);
351
352        let report = validator.validate_all();
353        assert!(report.is_ok());
354
355        // Clean up
356        let _ = std::fs::remove_file(db_path);
357    }
358
359    #[test]
360    fn test_path_validator() {
361        let temp_dir = std::env::temp_dir();
362        let valid_path = temp_dir.join("test.mp4");
363
364        let result = PathValidator::validate_path(&valid_path);
365        assert!(result.is_ok());
366
367        let empty_path = Path::new("");
368        let result = PathValidator::validate_path(empty_path);
369        assert!(result.is_err());
370    }
371
372    #[test]
373    fn test_directory_validation() {
374        let temp_dir = std::env::temp_dir();
375        let result = PathValidator::validate_directory(&temp_dir);
376
377        assert!(result.is_ok());
378        let validation = result.expect("should succeed in test");
379        assert!(validation.exists);
380    }
381
382    #[test]
383    fn test_path_conflicts() {
384        let paths = vec![
385            PathBuf::from("file1.mp4"),
386            PathBuf::from("file2.mp4"),
387            PathBuf::from("file1.mp4"),
388        ];
389
390        let conflicts = PathValidator::check_path_conflicts(&paths);
391        assert_eq!(conflicts.len(), 1);
392        assert_eq!(conflicts[0], PathBuf::from("file1.mp4"));
393    }
394
395    #[test]
396    fn test_edl_validation_result() {
397        let result = EdlValidationResult {
398            total_clips: 10,
399            found_clips: 8,
400            missing_clips: vec!["clip1.mov".to_string(), "clip2.mov".to_string()],
401            unlinked_clips: Vec::new(),
402        };
403
404        assert!(!result.is_valid());
405        assert_eq!(result.validation_percentage(), 80.0);
406    }
407}