oximedia_proxy/validation/
validator.rs1use super::report::ValidationReport;
4use crate::{ProxyLinkManager, Result};
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8pub struct WorkflowValidator<'a> {
10 link_manager: &'a ProxyLinkManager,
11 strict_mode: bool,
12}
13
14impl<'a> WorkflowValidator<'a> {
15 #[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 #[must_use]
26 pub const fn strict(mut self) -> Self {
27 self.strict_mode = true;
28 self
29 }
30
31 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 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 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 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 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 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 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 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 Ok(EdlValidationResult {
128 total_clips: 0,
129 found_clips: 0,
130 missing_clips: Vec::new(),
131 unlinked_clips: Vec::new(),
132 })
133 }
134
135 fn validate_file_integrity(&self, path: &Path) -> Result<()> {
137 let metadata = std::fs::metadata(path)?;
138
139 if metadata.len() == 0 {
141 return Err(crate::ProxyError::ValidationError(
142 "File is empty".to_string(),
143 ));
144 }
145
146 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 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 fn find_orphaned_proxies(&self, _links: &[crate::ProxyLink]) -> Result<Vec<PathBuf>> {
173 Ok(Vec::new())
175 }
176}
177
178#[derive(Debug, Clone)]
180pub struct EdlValidationResult {
181 pub total_clips: usize,
183
184 pub found_clips: usize,
186
187 pub missing_clips: Vec<String>,
189
190 pub unlinked_clips: Vec<String>,
192}
193
194impl EdlValidationResult {
195 #[must_use]
197 pub const fn is_valid(&self) -> bool {
198 self.missing_clips.is_empty() && self.unlinked_clips.is_empty()
199 }
200
201 #[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
212pub struct PathValidator;
214
215impl PathValidator {
216 pub fn validate_path(path: &Path) -> Result<()> {
222 if path.as_os_str().is_empty() {
224 return Err(crate::ProxyError::ValidationError(
225 "Path is empty".to_string(),
226 ));
227 }
228
229 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 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 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 let writable = is_writable(dir);
270
271 let available_space = 0u64; let total_space = 0u64;
274
275 Ok(DirectoryValidation {
276 exists: true,
277 writable,
278 available_space,
279 total_space,
280 })
281 }
282
283 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#[derive(Debug, Clone)]
300pub struct DirectoryValidation {
301 pub exists: bool,
303
304 pub writable: bool,
306
307 pub available_space: u64,
309
310 pub total_space: u64,
312}
313
314impl DirectoryValidation {
315 #[must_use]
317 pub const fn is_usable(&self) -> bool {
318 self.exists && self.writable
319 }
320
321 #[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 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 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}