Skip to main content

smugmug_cli/scanner/
mod.rs

1use anyhow::{Context, Result};
2use std::path::{Path, PathBuf};
3
4pub mod walker;
5
6#[derive(Debug)]
7pub struct ScannedFile {
8    pub path: PathBuf,
9}
10
11/// Scan a directory recursively for supported image and video files
12pub fn scan_directory(path: &Path) -> Result<Vec<ScannedFile>> {
13    let entries = walker::walk_directory(path).context("Failed to walk directory")?;
14
15    let mut scanned_files = Vec::new();
16
17    for entry in entries {
18        let file_path = entry.path();
19
20        // Skip files that aren't supported
21        if !is_supported_file(file_path) {
22            continue;
23        }
24
25        scanned_files.push(ScannedFile {
26            path: file_path.to_path_buf(),
27        });
28    }
29
30    Ok(scanned_files)
31}
32
33/// Check if a file has a supported extension
34pub fn is_supported_file(path: &Path) -> bool {
35    let extension = match path.extension() {
36        Some(ext) => ext.to_string_lossy().to_lowercase(),
37        None => return false,
38    };
39
40    matches!(
41        extension.as_str(),
42        // Image formats
43        "jpg" | "jpeg" | "png" | "heif" | "heic" | "gif" | "bmp" | "tiff" | "tif" |
44        // RAW formats
45        "raw" | "dng" | "cr2" | "cr3" | "nef" | "arw" | "orf" | "raf" |
46        "rw2" | "pef" | "srw" | "erf" | "mrw" | "3fr" | "fff" | "iiq" |
47        // Video formats
48        "mp4" | "mov" | "avi" | "m4v" | "mkv"
49    )
50}
51
52/// Check if a file is a RAW format (requires SmugMug Source subscription)
53pub fn is_raw_file(path: &Path) -> bool {
54    let extension = match path.extension() {
55        Some(ext) => ext.to_string_lossy().to_lowercase(),
56        None => return false,
57    };
58
59    matches!(
60        extension.as_str(),
61        "raw"
62            | "dng"
63            | "cr2"
64            | "cr3"
65            | "nef"
66            | "arw"
67            | "orf"
68            | "raf"
69            | "rw2"
70            | "pef"
71            | "srw"
72            | "erf"
73            | "mrw"
74            | "3fr"
75            | "fff"
76            | "iiq"
77    )
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use std::fs::{self, File};
84    use tempfile::TempDir;
85
86    // Helper function to create a temporary directory with test files
87    fn create_test_directory() -> Result<TempDir> {
88        let temp_dir = TempDir::new()?;
89        let base_path = temp_dir.path();
90
91        // Create various supported image files
92        File::create(base_path.join("photo1.jpg"))?;
93        File::create(base_path.join("photo2.jpeg"))?;
94        File::create(base_path.join("photo3.JPG"))?; // Test case insensitivity
95        File::create(base_path.join("image.png"))?;
96        File::create(base_path.join("IMG_1234.HEIC"))?;
97        File::create(base_path.join("raw_photo.raw"))?;
98        File::create(base_path.join("canon.cr2"))?;
99        File::create(base_path.join("nikon.nef"))?;
100        File::create(base_path.join("sony.arw"))?;
101        File::create(base_path.join("apple.heif"))?;
102        File::create(base_path.join("adobe.dng"))?;
103
104        // Create various supported video files
105        File::create(base_path.join("video1.mp4"))?;
106        File::create(base_path.join("video2.mov"))?;
107        File::create(base_path.join("old_video.avi"))?;
108        File::create(base_path.join("VIDEO.MP4"))?; // Test case insensitivity
109
110        // Create unsupported files
111        File::create(base_path.join("document.txt"))?;
112        File::create(base_path.join("spreadsheet.xlsx"))?;
113        File::create(base_path.join("archive.zip"))?;
114        File::create(base_path.join("no_extension"))?;
115        File::create(base_path.join("script.sh"))?;
116
117        // Create nested directory with files
118        let nested_dir = base_path.join("nested");
119        fs::create_dir(&nested_dir)?;
120        File::create(nested_dir.join("nested_photo.jpg"))?;
121        File::create(nested_dir.join("nested_video.mp4"))?;
122        File::create(nested_dir.join("nested_doc.txt"))?;
123
124        // Create deeply nested directory
125        let deep_dir = nested_dir.join("deep").join("deeper");
126        fs::create_dir_all(&deep_dir)?;
127        File::create(deep_dir.join("deep_photo.png"))?;
128
129        Ok(temp_dir)
130    }
131
132    #[test]
133    fn test_is_supported_file_jpg() {
134        assert!(is_supported_file(Path::new("photo.jpg")));
135        assert!(is_supported_file(Path::new("photo.jpeg")));
136        assert!(is_supported_file(Path::new("PHOTO.JPG"))); // Case insensitive
137        assert!(is_supported_file(Path::new("PHOTO.JPEG")));
138    }
139
140    #[test]
141    fn test_is_supported_file_png() {
142        assert!(is_supported_file(Path::new("image.png")));
143        assert!(is_supported_file(Path::new("IMAGE.PNG")));
144    }
145
146    #[test]
147    fn test_is_supported_file_heif_heic() {
148        assert!(is_supported_file(Path::new("photo.heif")));
149        assert!(is_supported_file(Path::new("photo.heic")));
150        assert!(is_supported_file(Path::new("PHOTO.HEIF")));
151        assert!(is_supported_file(Path::new("PHOTO.HEIC")));
152    }
153
154    #[test]
155    fn test_is_supported_file_raw_formats() {
156        assert!(is_supported_file(Path::new("photo.raw")));
157        assert!(is_supported_file(Path::new("photo.dng")));
158        assert!(is_supported_file(Path::new("photo.cr2")));
159        assert!(is_supported_file(Path::new("photo.nef")));
160        assert!(is_supported_file(Path::new("photo.arw")));
161        assert!(is_supported_file(Path::new("PHOTO.RAW")));
162        assert!(is_supported_file(Path::new("PHOTO.DNG")));
163    }
164
165    #[test]
166    fn test_is_supported_file_video_formats() {
167        assert!(is_supported_file(Path::new("video.mp4")));
168        assert!(is_supported_file(Path::new("video.mov")));
169        assert!(is_supported_file(Path::new("video.avi")));
170        assert!(is_supported_file(Path::new("VIDEO.MP4")));
171        assert!(is_supported_file(Path::new("VIDEO.MOV")));
172    }
173
174    #[test]
175    fn test_is_supported_file_unsupported_formats() {
176        assert!(!is_supported_file(Path::new("document.txt")));
177        assert!(!is_supported_file(Path::new("document.pdf")));
178        assert!(!is_supported_file(Path::new("archive.zip")));
179        assert!(!is_supported_file(Path::new("script.sh")));
180        assert!(!is_supported_file(Path::new("data.json")));
181        assert!(!is_supported_file(Path::new("config.toml")));
182        assert!(!is_supported_file(Path::new("spreadsheet.xlsx")));
183    }
184
185    #[test]
186    fn test_is_supported_file_no_extension() {
187        assert!(!is_supported_file(Path::new("no_extension")));
188        assert!(!is_supported_file(Path::new("file_without_ext")));
189    }
190
191    #[test]
192    fn test_is_supported_file_with_path() {
193        assert!(is_supported_file(Path::new("/path/to/photo.jpg")));
194        assert!(is_supported_file(Path::new("relative/path/photo.png")));
195        assert!(!is_supported_file(Path::new("/path/to/document.txt")));
196    }
197
198    #[test]
199    fn test_scan_directory_finds_supported_files() {
200        let temp_dir = create_test_directory().expect("Failed to create test directory");
201        let scanned = scan_directory(temp_dir.path()).expect("Failed to scan directory");
202
203        // We created 15 supported files (11 images + 4 videos)
204        // - 11 image files: jpg, jpeg, JPG, png, HEIC, raw, cr2, nef, arw, heif, dng
205        // - 4 video files: mp4, mov, avi, MP4
206        // Plus 3 more in nested directories (nested_photo.jpg, nested_video.mp4, deep_photo.png)
207        assert_eq!(scanned.len(), 18);
208
209        // Check that all scanned files are actually supported
210        for file in &scanned {
211            assert!(
212                is_supported_file(&file.path),
213                "File {:?} should be supported",
214                file.path
215            );
216        }
217    }
218
219    #[test]
220    fn test_scan_directory_filters_unsupported_files() {
221        let temp_dir = create_test_directory().expect("Failed to create test directory");
222        let scanned = scan_directory(temp_dir.path()).expect("Failed to scan directory");
223
224        // Check that no unsupported files are in the results
225        let unsupported_extensions = ["txt", "xlsx", "zip", "sh"];
226        for file in &scanned {
227            let ext = file.path.extension().and_then(|e| e.to_str()).unwrap_or("");
228            assert!(
229                !unsupported_extensions.contains(&ext.to_lowercase().as_str()),
230                "Found unsupported file: {:?}",
231                file.path
232            );
233        }
234    }
235
236    #[test]
237    fn test_scan_directory_empty_directory() {
238        let temp_dir = TempDir::new().expect("Failed to create temp directory");
239        let scanned = scan_directory(temp_dir.path()).expect("Failed to scan empty directory");
240
241        assert_eq!(scanned.len(), 0, "Empty directory should return no files");
242    }
243
244    #[test]
245    fn test_scan_directory_only_unsupported_files() {
246        let temp_dir = TempDir::new().expect("Failed to create temp directory");
247        let base_path = temp_dir.path();
248
249        // Create only unsupported files
250        File::create(base_path.join("document.txt")).expect("Failed to create file");
251        File::create(base_path.join("data.json")).expect("Failed to create file");
252        File::create(base_path.join("no_extension")).expect("Failed to create file");
253
254        let scanned = scan_directory(temp_dir.path()).expect("Failed to scan directory");
255
256        assert_eq!(
257            scanned.len(),
258            0,
259            "Directory with only unsupported files should return no files"
260        );
261    }
262
263    #[test]
264    fn test_scan_directory_nested_structure() {
265        let temp_dir = create_test_directory().expect("Failed to create test directory");
266        let scanned = scan_directory(temp_dir.path()).expect("Failed to scan directory");
267
268        // Check that nested files are found
269        let nested_files: Vec<_> = scanned
270            .iter()
271            .filter(|f| f.path.to_string_lossy().contains("nested"))
272            .collect();
273
274        // Should find nested_photo.jpg, nested_video.mp4, and deep_photo.png
275        assert_eq!(
276            nested_files.len(),
277            3,
278            "Should find files in nested directories"
279        );
280    }
281
282    #[test]
283    fn test_scan_directory_invalid_path() {
284        let invalid_path = Path::new("/this/path/does/not/exist/hopefully/xyz123");
285        let result = scan_directory(invalid_path);
286
287        // walkdir may return Ok with empty results for non-existent paths
288        // or it may return an error - either is acceptable behavior
289        match result {
290            Ok(files) => assert_eq!(
291                files.len(),
292                0,
293                "Should return empty results for invalid path"
294            ),
295            Err(_) => {} // Error is also acceptable
296        }
297    }
298
299    #[test]
300    fn test_scan_directory_file_not_directory() {
301        let temp_dir = TempDir::new().expect("Failed to create temp directory");
302        let file_path = temp_dir.path().join("test.txt");
303        File::create(&file_path).expect("Failed to create file");
304
305        // Scanning a file instead of a directory should still work (walkdir handles it)
306        let result = scan_directory(&file_path);
307
308        // This might succeed with 0 results or fail depending on walkdir behavior
309        // The important thing is it doesn't panic
310        assert!(result.is_ok() || result.is_err());
311    }
312
313    #[test]
314    fn test_scanned_file_debug_trait() {
315        let scanned = ScannedFile {
316            path: PathBuf::from("/test/path/photo.jpg"),
317        };
318
319        let debug_str = format!("{:?}", scanned);
320        assert!(debug_str.contains("ScannedFile"));
321        assert!(debug_str.contains("photo.jpg"));
322    }
323
324    #[test]
325    fn test_scan_directory_case_insensitive_extensions() {
326        let temp_dir = TempDir::new().expect("Failed to create temp directory");
327        let base_path = temp_dir.path();
328
329        // Create files with various case combinations
330        File::create(base_path.join("photo1.jpg")).expect("Failed to create file");
331        File::create(base_path.join("photo2.JPG")).expect("Failed to create file");
332        File::create(base_path.join("photo3.Jpg")).expect("Failed to create file");
333        File::create(base_path.join("photo4.JpG")).expect("Failed to create file");
334
335        let scanned = scan_directory(temp_dir.path()).expect("Failed to scan directory");
336
337        assert_eq!(
338            scanned.len(),
339            4,
340            "Should find all files regardless of extension case"
341        );
342    }
343
344    #[test]
345    fn test_scan_directory_mixed_content() {
346        let temp_dir = TempDir::new().expect("Failed to create temp directory");
347        let base_path = temp_dir.path();
348
349        // Create a mix of supported and unsupported files
350        File::create(base_path.join("photo.jpg")).expect("Failed to create file");
351        File::create(base_path.join("document.txt")).expect("Failed to create file");
352        File::create(base_path.join("video.mp4")).expect("Failed to create file");
353        File::create(base_path.join("data.json")).expect("Failed to create file");
354        File::create(base_path.join("image.png")).expect("Failed to create file");
355
356        let scanned = scan_directory(temp_dir.path()).expect("Failed to scan directory");
357
358        assert_eq!(scanned.len(), 3, "Should find only the 3 supported files");
359    }
360
361    #[cfg(unix)]
362    #[test]
363    fn test_scan_directory_with_symlink() {
364        use std::os::unix::fs::symlink;
365
366        let temp_dir = TempDir::new().expect("Failed to create temp directory");
367        let base_path = temp_dir.path();
368
369        // Create a real file
370        let real_file = base_path.join("real_photo.jpg");
371        File::create(&real_file).expect("Failed to create file");
372
373        // Create a symlink to the file
374        let symlink_file = base_path.join("linked_photo.jpg");
375        symlink(&real_file, &symlink_file).expect("Failed to create symlink");
376
377        let scanned = scan_directory(temp_dir.path()).expect("Failed to scan directory");
378
379        // Should find both the real file and the symlink (walker follows links)
380        assert_eq!(scanned.len(), 2, "Should find both real file and symlink");
381    }
382
383    #[test]
384    fn test_is_supported_file_edge_cases() {
385        // Test hidden files
386        assert!(is_supported_file(Path::new(".hidden.jpg")));
387        assert!(!is_supported_file(Path::new(".hidden.txt")));
388
389        // Test files with multiple dots
390        assert!(is_supported_file(Path::new("my.photo.backup.jpg")));
391        assert!(!is_supported_file(Path::new("my.photo.backup.txt")));
392
393        // Test very long filenames
394        let long_name = "a".repeat(200) + ".jpg";
395        assert!(is_supported_file(Path::new(&long_name)));
396    }
397
398    #[test]
399    fn test_scan_directory_preserves_full_paths() {
400        let temp_dir = create_test_directory().expect("Failed to create test directory");
401        let base_path = temp_dir.path();
402        let scanned = scan_directory(base_path).expect("Failed to scan directory");
403
404        // Verify that all paths are absolute and start with the base path
405        for file in &scanned {
406            assert!(
407                file.path.starts_with(base_path),
408                "File path {:?} should start with base path {:?}",
409                file.path,
410                base_path
411            );
412        }
413    }
414}