//! Directory walk + image detection (M1). //! //! Plain recursive scan of the roots (Pictures + DCIM on Android). This is //! what makes the app testable without MediaStore; MediaStore integration is //! a later refinement (HANDOFF.md §7.4). use std::path::{Path, PathBuf}; /// One image on disk. `mtime` is nanoseconds since the Unix epoch (i64). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Entry { pub path: String, pub mtime: i64, pub size: i64, } /// Lower-case image extensions we index. HEIC is the Android camera default. pub const IMAGE_EXTS: &[&str] = &[ "jpg", "jpeg", "png", "webp", "heic", "heif", "bmp", "tif", "tiff", ]; /// Scan roots. `GALLERY_SCAN_DIRS` (colon-separated) overrides — test hook. pub fn roots() -> Vec { if let Ok(d) = std::env::var("GALLERY_SCAN_DIRS") { return d .split(':') .filter(|s| !s.is_empty()) .map(PathBuf::from) .collect(); } #[cfg(target_os = "android")] { let ext = PathBuf::from("/storage/emulated/0"); vec![ext.join("Pictures"), ext.join("DCIM")] } #[cfg(not(target_os = "android"))] { let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default(); vec![home.join("Pictures"), home.join("DCIM")] } } /// Recursively collect all image entries below `roots`. /// `on_progress(n)` is called every 512 files found. pub fn walk(roots: &[PathBuf], on_progress: &dyn Fn(usize)) -> Vec { walk_stream(roots, &|batch| on_progress(batch.len())) } /// Like `walk`, but streams progress: `on_batch(&entries)` receives ALL /// entries collected so far every 512 new files, so the UI can show results /// while the scan is still running (first launch must not wait for the full /// DB build). pub fn walk_stream(roots: &[PathBuf], on_batch: &dyn Fn(&[Entry])) -> Vec { let mut out = Vec::new(); for r in roots { if r.is_dir() { walk_dir(r, &mut out, on_batch); } } out } fn walk_dir(dir: &Path, out: &mut Vec, on_batch: &dyn Fn(&[Entry])) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for ent in rd.flatten() { let p = ent.path(); let Ok(ft) = ent.file_type() else { continue }; if ft.is_dir() { // Skip hidden dirs (.thumbnails, .trash, …) — metadata, not photos. if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.')) { continue; } // Don't follow symlinked dirs (loop protection). if !p.is_symlink() { walk_dir(&p, out, on_batch); } continue; } if !ft.is_file() { continue; } let Some(ext) = p.extension().and_then(|e| e.to_str()) else { continue }; if !IMAGE_EXTS.contains(&ext.to_ascii_lowercase().as_str()) { continue; } let Ok(md) = std::fs::metadata(&p) else { continue }; let mtime = md .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_nanos() as i64) .unwrap_or(0); out.push(Entry { path: p.to_string_lossy().into_owned(), mtime, size: md.len() as i64, }); if out.len() % 512 == 0 { on_batch(out); } } } #[cfg(test)] mod tests { use super::*; use std::io::Write; fn make_tree(root: &Path, n_files: usize, subdirs: usize) { for d in 0..subdirs { let dir = root.join(format!("dir{d}")); std::fs::create_dir_all(&dir).unwrap(); } for i in 0..n_files { let dir = if i % 2 == 0 { root.join(format!("dir{}", i % subdirs)) } else { root.to_path_buf() }; let mut f = std::fs::File::create(dir.join(format!("img{i}.jpg"))).unwrap(); f.write_all(&vec![0u8; i % 64]).unwrap(); } } #[test] fn finds_images_skips_others() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("a.jpg"), b"x").unwrap(); std::fs::write(dir.path().join("B.PNG"), b"x").unwrap(); std::fs::write(dir.path().join("c.txt"), b"x").unwrap(); std::fs::create_dir_all(dir.path().join("d")).unwrap(); std::fs::write(dir.path().join("d").join("e.heic"), b"x").unwrap(); let out = walk(&[dir.path().to_path_buf()], &|_| {}); assert_eq!(out.len(), 3); assert!(out.iter().all(|e| e.size == 1)); } #[test] fn accepts_5k_files_under_2s() { let dir = tempfile::tempdir().unwrap(); make_tree(dir.path(), 5000, 8); let t = std::time::Instant::now(); let out = walk(&[dir.path().to_path_buf()], &|_| {}); let elapsed = t.elapsed(); assert_eq!(out.len(), 5000); assert!( elapsed.as_millis() < 2000, "scan took {:?} (> 2 s)", elapsed ); } }