Skip to main content

oximedia_proxy/
utils.rs

1//! Utility functions for proxy workflows.
2
3use crate::Result;
4use std::path::{Path, PathBuf};
5
6/// File utilities for proxy operations.
7pub struct FileUtils;
8
9impl FileUtils {
10    /// Calculate file hash for verification.
11    ///
12    /// # Errors
13    ///
14    /// Returns an error if the file cannot be read.
15    pub fn calculate_hash(path: &Path) -> Result<String> {
16        // Placeholder: would calculate MD5 or SHA256 hash
17        let _path = path;
18        Ok("placeholder_hash".to_string())
19    }
20
21    /// Get file size in human-readable format.
22    #[must_use]
23    pub fn format_file_size(bytes: u64) -> String {
24        const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
25        let mut size = bytes as f64;
26        let mut unit_index = 0;
27
28        while size >= 1024.0 && unit_index < UNITS.len() - 1 {
29            size /= 1024.0;
30            unit_index += 1;
31        }
32
33        format!("{:.2} {}", size, UNITS[unit_index])
34    }
35
36    /// Get file modification time as Unix timestamp.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the file metadata cannot be read.
41    pub fn get_modification_time(path: &Path) -> Result<i64> {
42        let metadata = std::fs::metadata(path)?;
43        let modified = metadata.modified()?;
44        let duration = modified
45            .duration_since(std::time::UNIX_EPOCH)
46            .map_err(|e| {
47                crate::ProxyError::IoError(std::io::Error::new(std::io::ErrorKind::Other, e))
48            })?;
49
50        Ok(duration.as_secs() as i64)
51    }
52
53    /// Copy file with progress tracking.
54    pub fn copy_with_progress<F>(
55        source: &Path,
56        destination: &Path,
57        mut progress_callback: F,
58    ) -> Result<u64>
59    where
60        F: FnMut(u64, u64),
61    {
62        let total_size = std::fs::metadata(source)?.len();
63        let mut copied = 0u64;
64
65        // Placeholder: would implement chunked copying with progress
66        progress_callback(copied, total_size);
67
68        std::fs::copy(source, destination)?;
69        copied = total_size;
70        progress_callback(copied, total_size);
71
72        Ok(copied)
73    }
74
75    /// Create directory if it doesn't exist.
76    pub fn ensure_directory(path: &Path) -> Result<()> {
77        if !path.exists() {
78            std::fs::create_dir_all(path)?;
79        }
80        Ok(())
81    }
82
83    /// Get available disk space for a path.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if disk space cannot be determined.
88    pub fn get_available_space(_path: &Path) -> Result<u64> {
89        // Placeholder: would use platform-specific APIs
90        Ok(0)
91    }
92
93    /// Check if file is a video file based on extension.
94    #[must_use]
95    pub fn is_video_file(path: &Path) -> bool {
96        const VIDEO_EXTENSIONS: &[&str] = &[
97            "mp4", "mov", "avi", "mkv", "wmv", "flv", "webm", "m4v", "mpg", "mpeg", "3gp", "3g2",
98            "mxf", "ts", "m2ts", "mts",
99        ];
100
101        if let Some(ext) = path.extension() {
102            if let Some(ext_str) = ext.to_str() {
103                return VIDEO_EXTENSIONS.contains(&ext_str.to_lowercase().as_str());
104            }
105        }
106
107        false
108    }
109
110    /// Get codec name from file extension.
111    #[must_use]
112    pub fn guess_codec_from_extension(path: &Path) -> Option<String> {
113        if let Some(ext) = path.extension() {
114            match ext.to_str()?.to_lowercase().as_str() {
115                "mp4" | "m4v" => Some("h264".to_string()),
116                "webm" => Some("vp9".to_string()),
117                "mkv" => Some("h264".to_string()),
118                "mov" => Some("prores".to_string()),
119                "avi" => Some("h264".to_string()),
120                _ => None,
121            }
122        } else {
123            None
124        }
125    }
126}
127
128/// Path utilities for proxy workflows.
129pub struct PathUtils;
130
131impl PathUtils {
132    /// Normalize path separators for cross-platform compatibility.
133    #[must_use]
134    pub fn normalize_path(path: &Path) -> PathBuf {
135        // Convert to canonical form if possible
136        if let Ok(canonical) = path.canonicalize() {
137            canonical
138        } else {
139            path.to_path_buf()
140        }
141    }
142
143    /// Get relative path from base to target.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the paths don't share a common base.
148    pub fn get_relative_path(base: &Path, target: &Path) -> Result<PathBuf> {
149        target
150            .strip_prefix(base)
151            .map(|p| p.to_path_buf())
152            .map_err(|e| crate::ProxyError::InvalidInput(e.to_string()))
153    }
154
155    /// Make path absolute.
156    #[must_use]
157    pub fn make_absolute(path: &Path) -> PathBuf {
158        if path.is_absolute() {
159            path.to_path_buf()
160        } else if let Ok(current_dir) = std::env::current_dir() {
161            current_dir.join(path)
162        } else {
163            path.to_path_buf()
164        }
165    }
166
167    /// Get filename without extension.
168    #[must_use]
169    pub fn get_stem(path: &Path) -> String {
170        path.file_stem()
171            .and_then(|s| s.to_str())
172            .unwrap_or("")
173            .to_string()
174    }
175
176    /// Change file extension.
177    #[must_use]
178    pub fn change_extension(path: &Path, new_ext: &str) -> PathBuf {
179        let mut new_path = path.to_path_buf();
180        new_path.set_extension(new_ext);
181        new_path
182    }
183
184    /// Generate unique filename by appending number.
185    pub fn make_unique_filename(path: &Path) -> PathBuf {
186        if !path.exists() {
187            return path.to_path_buf();
188        }
189
190        let stem = Self::get_stem(path);
191        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
192        let parent = path.parent().unwrap_or_else(|| Path::new("."));
193
194        for i in 1..1000 {
195            let new_filename = if ext.is_empty() {
196                format!("{}_{}", stem, i)
197            } else {
198                format!("{}_{}.{}", stem, i, ext)
199            };
200
201            let new_path = parent.join(new_filename);
202            if !new_path.exists() {
203                return new_path;
204            }
205        }
206
207        path.to_path_buf()
208    }
209
210    /// Check if two paths point to the same file.
211    #[must_use]
212    pub fn are_same_file(path1: &Path, path2: &Path) -> bool {
213        if let (Ok(canon1), Ok(canon2)) = (path1.canonicalize(), path2.canonicalize()) {
214            canon1 == canon2
215        } else {
216            path1 == path2
217        }
218    }
219}
220
221/// Time utilities for proxy workflows.
222pub struct TimeUtils;
223
224impl TimeUtils {
225    /// Format duration in seconds to human-readable string.
226    #[must_use]
227    pub fn format_duration(seconds: f64) -> String {
228        let hours = (seconds / 3600.0).floor() as u64;
229        let minutes = ((seconds % 3600.0) / 60.0).floor() as u64;
230        let secs = (seconds % 60.0).floor() as u64;
231        let ms = ((seconds % 1.0) * 1000.0).floor() as u64;
232
233        if hours > 0 {
234            format!("{}:{:02}:{:02}.{:03}", hours, minutes, secs, ms)
235        } else if minutes > 0 {
236            format!("{}:{:02}.{:03}", minutes, secs, ms)
237        } else {
238            format!("{}.{:03}s", secs, ms)
239        }
240    }
241
242    /// Parse duration string to seconds.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if the format is invalid.
247    pub fn parse_duration(duration_str: &str) -> Result<f64> {
248        // Simple parser for formats like "01:23:45" or "1h 23m 45s"
249        let parts: Vec<&str> = duration_str.split(':').collect();
250
251        match parts.len() {
252            3 => {
253                // HH:MM:SS format
254                let hours: f64 = parts[0].parse().map_err(|e| {
255                    crate::ProxyError::InvalidInput(format!("Invalid hours: {}", e))
256                })?;
257                let minutes: f64 = parts[1].parse().map_err(|e| {
258                    crate::ProxyError::InvalidInput(format!("Invalid minutes: {}", e))
259                })?;
260                let seconds: f64 = parts[2].parse().map_err(|e| {
261                    crate::ProxyError::InvalidInput(format!("Invalid seconds: {}", e))
262                })?;
263
264                Ok(hours * 3600.0 + minutes * 60.0 + seconds)
265            }
266            2 => {
267                // MM:SS format
268                let minutes: f64 = parts[0].parse().map_err(|e| {
269                    crate::ProxyError::InvalidInput(format!("Invalid minutes: {}", e))
270                })?;
271                let seconds: f64 = parts[1].parse().map_err(|e| {
272                    crate::ProxyError::InvalidInput(format!("Invalid seconds: {}", e))
273                })?;
274
275                Ok(minutes * 60.0 + seconds)
276            }
277            1 => {
278                // Just seconds
279                parts[0].parse().map_err(|e| {
280                    crate::ProxyError::InvalidInput(format!("Invalid duration: {}", e))
281                })
282            }
283            _ => Err(crate::ProxyError::InvalidInput(
284                "Invalid duration format".to_string(),
285            )),
286        }
287    }
288
289    /// Get current Unix timestamp.
290    #[must_use]
291    pub fn current_timestamp() -> i64 {
292        std::time::SystemTime::now()
293            .duration_since(std::time::UNIX_EPOCH)
294            .expect("infallible: system clock is always after UNIX_EPOCH")
295            .as_secs() as i64
296    }
297
298    /// Format timestamp to ISO 8601 string.
299    #[must_use]
300    pub fn format_timestamp(_timestamp: i64) -> String {
301        // Placeholder: would format to ISO 8601
302        "2024-01-01T00:00:00Z".to_string()
303    }
304}
305
306/// String utilities for proxy workflows.
307pub struct StringUtils;
308
309impl StringUtils {
310    /// Sanitize filename by removing invalid characters.
311    #[must_use]
312    pub fn sanitize_filename(filename: &str) -> String {
313        const INVALID_CHARS: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
314
315        filename
316            .chars()
317            .map(|c| if INVALID_CHARS.contains(&c) { '_' } else { c })
318            .collect()
319    }
320
321    /// Generate a unique identifier.
322    #[must_use]
323    pub fn generate_id() -> String {
324        use std::time::SystemTime;
325
326        let now = SystemTime::now()
327            .duration_since(std::time::UNIX_EPOCH)
328            .expect("infallible: system clock is always after UNIX_EPOCH");
329
330        format!("{:x}", now.as_nanos())
331    }
332
333    /// Truncate string to maximum length.
334    #[must_use]
335    pub fn truncate(s: &str, max_len: usize) -> String {
336        if s.len() <= max_len {
337            s.to_string()
338        } else {
339            format!("{}...", &s[..max_len.saturating_sub(3)])
340        }
341    }
342
343    /// Convert bytes to hex string.
344    #[must_use]
345    pub fn bytes_to_hex(bytes: &[u8]) -> String {
346        bytes.iter().map(|b| format!("{:02x}", b)).collect()
347    }
348
349    /// Parse hex string to bytes.
350    pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>> {
351        let mut bytes = Vec::new();
352        let mut chars = hex.chars();
353
354        while let (Some(high), Some(low)) = (chars.next(), chars.next()) {
355            let byte = u8::from_str_radix(&format!("{}{}", high, low), 16)
356                .map_err(|e| crate::ProxyError::InvalidInput(e.to_string()))?;
357            bytes.push(byte);
358        }
359
360        Ok(bytes)
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_format_file_size() {
370        assert_eq!(FileUtils::format_file_size(500), "500.00 B");
371        assert_eq!(FileUtils::format_file_size(1024), "1.00 KB");
372        assert_eq!(FileUtils::format_file_size(1_048_576), "1.00 MB");
373        assert_eq!(FileUtils::format_file_size(1_073_741_824), "1.00 GB");
374    }
375
376    #[test]
377    fn test_is_video_file() {
378        assert!(FileUtils::is_video_file(Path::new("test.mp4")));
379        assert!(FileUtils::is_video_file(Path::new("test.mov")));
380        assert!(FileUtils::is_video_file(Path::new("test.mkv")));
381        assert!(!FileUtils::is_video_file(Path::new("test.txt")));
382    }
383
384    #[test]
385    fn test_guess_codec() {
386        assert_eq!(
387            FileUtils::guess_codec_from_extension(Path::new("test.mp4")),
388            Some("h264".to_string())
389        );
390        assert_eq!(
391            FileUtils::guess_codec_from_extension(Path::new("test.webm")),
392            Some("vp9".to_string())
393        );
394    }
395
396    #[test]
397    fn test_get_stem() {
398        assert_eq!(PathUtils::get_stem(Path::new("test.mp4")), "test");
399        assert_eq!(PathUtils::get_stem(Path::new("/path/to/file.ext")), "file");
400    }
401
402    #[test]
403    fn test_change_extension() {
404        let path = Path::new("test.mp4");
405        let new_path = PathUtils::change_extension(path, "mov");
406        assert_eq!(new_path, PathBuf::from("test.mov"));
407    }
408
409    #[test]
410    fn test_format_duration() {
411        assert_eq!(TimeUtils::format_duration(30.5), "30.500s");
412        assert_eq!(TimeUtils::format_duration(90.0), "1:30.000");
413        assert_eq!(TimeUtils::format_duration(3665.0), "1:01:05.000");
414    }
415
416    #[test]
417    fn test_parse_duration() {
418        assert_eq!(
419            TimeUtils::parse_duration("30").expect("should succeed in test"),
420            30.0
421        );
422        assert_eq!(
423            TimeUtils::parse_duration("1:30").expect("should succeed in test"),
424            90.0
425        );
426        assert_eq!(
427            TimeUtils::parse_duration("1:01:05").expect("should succeed in test"),
428            3665.0
429        );
430    }
431
432    #[test]
433    fn test_sanitize_filename() {
434        assert_eq!(
435            StringUtils::sanitize_filename("test/file:name.mp4"),
436            "test_file_name.mp4"
437        );
438    }
439
440    #[test]
441    fn test_truncate() {
442        assert_eq!(StringUtils::truncate("hello", 10), "hello");
443        assert_eq!(StringUtils::truncate("hello world", 8), "hello...");
444    }
445
446    #[test]
447    fn test_bytes_to_hex() {
448        assert_eq!(StringUtils::bytes_to_hex(&[0, 1, 255]), "0001ff");
449    }
450
451    #[test]
452    fn test_hex_to_bytes() {
453        let bytes = StringUtils::hex_to_bytes("0001ff").expect("should succeed in test");
454        assert_eq!(bytes, vec![0, 1, 255]);
455    }
456
457    #[test]
458    fn test_current_timestamp() {
459        let ts = TimeUtils::current_timestamp();
460        assert!(ts > 0);
461    }
462}