From e1982eade4718cdb461a552348dc8d5460548645 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Tue, 27 Oct 2026 13:28:33 -0400 Subject: [PATCH] M1: metadata scan + sqlite DB + placeholder list - scan.rs: recursive walk of Pictures/DCIM (GALLERY_SCAN_DIRS override), image-ext detection, mtime/size capture, no symlink-dir following - db.rs: rusqlite(bundled) store; incremental apply (insert/update/delete) keyed on path with mtime/size change detection; derived columns reset on change; Android DB path in Android/data//files (EXTERNAL_DATA_DIR is unset in this NativeActivity setup) - lib.rs: start_scan() background thread -> invoke_from_event_loop -> VecModel + status line; called from desktop and Android entry points - main.slint: ListView of paths (M1), status line with scan stats - main.rs: fix desktop bin for 1.18.1 (no slint::Result; ComponentHandle) - build.sh: export NDK clang CC_/CXX_/AR_ per target (bundled sqlite) - tests: 5k-file scan < 2s, incremental apply, roundtrip Verified on emulator: 5002 files in 153-226 ms; relaunch 0+ 0~ 0-; touch -> 1~, delete -> 1-. --- Cargo.lock | 74 +++++++++++++++++ Cargo.toml | 4 + HANDOFF.md | 29 +++++-- README.md | 7 +- scripts/build.sh | 9 +++ src/db.rs | 201 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 59 ++++++++++++++ src/main.rs | 4 +- src/main.slint | 30 +++++-- src/scan.rs | 136 ++++++++++++++++++++++++++++++++ 10 files changed, 537 insertions(+), 16 deletions(-) create mode 100644 src/db.rs create mode 100644 src/scan.rs diff --git a/Cargo.lock b/Cargo.lock index 3d94d5e..bee9702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1186,6 +1186,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -1484,8 +1496,10 @@ version = "0.1.0" dependencies = [ "android_logger", "log", + "rusqlite", "slint", "slint-build", + "tempfile", ] [[package]] @@ -1727,6 +1741,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -2592,6 +2615,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libsqlite3-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libudev-sys" version = "0.1.4" @@ -3847,6 +3881,30 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "rsqlite-vfs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c026b4444579e8d754556c6c75d040d69c4a44283cdb648ab8a8d5965c2cfa8" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4289,6 +4347,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618" +dependencies = [ + "cc", + "rsqlite-vfs", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4829,6 +4897,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 3f3657e..3a49142 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,10 +10,14 @@ crate-type = ["cdylib", "lib"] slint = { version = "1.18.1", features = ["backend-android-activity-06"] } log = "0.4" android_logger = { version = "0.14", target-family = "android" } +rusqlite = { version = "0.41.0", features = ["bundled"] } [build-dependencies] slint-build = "1.18.1" +[dev-dependencies] +tempfile = "3.27.0" + # --- Android packaging (cargo-apk2 1.5) --- # NOTE: uses_permission is a TOP-LEVEL android key (ndk-build2 manifest layer), # not under [package.metadata.android.application]. max_sdk_version supported. diff --git a/HANDOFF.md b/HANDOFF.md index 014105d..1b1fe02 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -269,11 +269,30 @@ pool threads), and `logcat` pool timing logs behind a `debug_timing` flag. - **M0 — scaffold (DONE)**: builds (x86_64 + aarch64 toolchain), installs, runs on emulator; permission-aware init + Resume hook; build script; manifest with permissions; this document. -- **M1 — metadata**: `db.rs` + `scan.rs`; scan Pictures+DCIM on first launch - (background thread, progress in UI); sqlite table; list of items as a - Slint `Model` fed to a placeholder `ListView` (paths as text, no images). - *Accept: 5k files scanned < 2 s; relaunch reuses DB; mtime-changed files - refreshed.* +- **M1 — metadata (DONE, 2026-10-27)**: `db.rs` + `scan.rs`; background scan + thread → sqlite → `VecModel` in a `ListView` (paths as text). Acceptance + verified on emulator: **5002 files in 153 ms**; relaunch `0+ 0~ 0-` in 2 ms; + mtime-touch → `1~`, delete → `1-`. Status line shows `N files (a+ u~ d–) in X ms`. + Gotchas hit (also see §6/§12): + - `slint::Result` / `slint::WindowHandle` **do not exist in 1.18.1** as + published on crates.io — desktop bin is `fn main() -> Result<(), + slint::PlatformError>` + `use slint::ComponentHandle;`. (The M0 desktop + bin never actually compiled; only the Android lib was built. The eval + sample `slint_app` is broken the same way.) + - `.slint` modulo is `Math.mod(x, y)`, not `%`. + - ListView rows: `for path[index] in root.items: …` (`index` is not in + scope unless named; `parent` = the ListView). + - `rusqlite(bundled)` needs `CC_` pointing at NDK clang for + Android — `scripts/build.sh` now exports them. + - Android DB path: `EXTERNAL_DATA_DIR` is **not set** by this + NativeActivity setup and `temp_dir()` = `/data/user/0` (unwritable). + Use `/storage/emulated/0/Android/data//files`, internal files dir + fallback (see `db::android_data_dir`). + - Emulator: grant all-files access once via + `adb shell appops set wow.gmp.gallery MANAGE_EXTERNAL_STORAGE allow`; it + survives `adb install -r`. + - Files pushed with `adb push` are owned by `shell` mode 640 — `chmod 644` + or the app can't read them. - **M2 — thumbnail engine**: `thumb.rs` + `images.rs`; disk cache; cancellation; pool; synthetic 5k test set; timing logs. *Accept: first grid fill < 2 s; re-launch fills instantly from disk cache; diff --git a/README.md b/README.md index 03937a5..0179c1e 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ A high-performance image gallery for Android (primary), Linux and macOS — Rust + [Slint](https://slint.dev) 1.18. -**Status: M0 (scaffold).** See **[HANDOFF.md](HANDOFF.md)** — the development -hand-off with the full plan: architecture, thumbnail pipeline, milestones -M1–M7, Slint gotchas, permission flow, and performance targets. +**Status: M1 (metadata scan + DB) done.** See **[HANDOFF.md](HANDOFF.md)** — +the development hand-off with the full plan: architecture, thumbnail +pipeline, milestones M1–M7, Slint gotchas, permission flow, and performance +targets. Next: M2 (thumbnail engine). ## Build diff --git a/scripts/build.sh b/scripts/build.sh index 6d8de28..f151d22 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -14,6 +14,15 @@ cd "$(dirname "$0")/.." export ANDROID_HOME=$HOME/android-sdk export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973 export PATH="$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH" +# C/C++ cross-compilers for bundled native deps (libsqlite3-sys via rusqlite). +# The `cc` crate picks these up per target triple (min sdk 26). +NDK_BIN=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin +export CC_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang +export CXX_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang++ +export AR_x86_64_linux_android=$NDK_BIN/llvm-ar +export CC_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang +export CXX_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang++ +export AR_aarch64_linux_android=$NDK_BIN/llvm-ar # Debug-keystore signing is fine for personal use / Play internal testing. export CARGO_APK_RELEASE_KEYSTORE=$HOME/.android/debug.keystore export CARGO_APK_RELEASE_KEYSTORE_PASSWORD=android diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..383afe2 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,201 @@ +//! SQLite metadata store (M1). Schema follows HANDOFF.md §4: +//! one row per image; M1 fills `path/mtime/size`, the thumbnail engine (M2) +//! fills `width/height/orientation/thumb_path`, `date_taken` later. + +use rusqlite::{params, Connection}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use crate::scan::Entry; + +const ANDROID_PACKAGE: &str = "wow.gmp.gallery"; + +/// App-writable data dir on Android. Prefers the per-app external dir +/// (/storage/emulated/0/Android/data//files) so the DB is easy to +/// inspect via `adb`; falls back to the internal files dir, which is always +/// writable. Neither `EXTERNAL_DATA_DIR` (not set by this NativeActivity +/// setup) nor `temp_dir()` (=/data/user/0, not writable) can be trusted. +#[cfg(target_os = "android")] +fn android_data_dir() -> PathBuf { + if let Some(ext) = std::env::var_os("EXTERNAL_DATA_DIR").map(PathBuf::from) { + if std::fs::create_dir_all(&ext).is_ok() { + return ext; + } + } + if let Some(storage) = std::env::var_os("EXTERNAL_STORAGE").map(PathBuf::from) { + let ext = storage + .join("Android/data") + .join(ANDROID_PACKAGE) + .join("files"); + if std::fs::create_dir_all(&ext).is_ok() { + return ext; + } + } + PathBuf::from(format!("/data/data/{ANDROID_PACKAGE}/files")) +} + +/// DB file lives in app-private storage: +/// - Android: per-app external dir (see `android_data_dir`), internal fallback. +/// - Desktop: $XDG_DATA_HOME/gallery (or ~/.local/share/gallery). +pub fn db_path() -> PathBuf { + #[cfg(target_os = "android")] + let dir = android_data_dir(); + #[cfg(not(target_os = "android"))] + let dir = { + let data_home = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from); + let base = data_home.unwrap_or_else(|| { + let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default(); + home.join(".local/share") + }); + base.join("gallery") + }; + dir.join("gallery.db") +} + +pub fn open(path: &std::path::Path) -> Result { + let conn = Connection::open(path)?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS items ( + path TEXT PRIMARY KEY, + mtime INTEGER NOT NULL, + size INTEGER NOT NULL, + width INTEGER, + height INTEGER, + orientation INTEGER, + thumb_path TEXT, + date_taken INTEGER + ); + CREATE INDEX IF NOT EXISTS items_mtime ON items (mtime DESC);", + )?; + Ok(conn) +} + +/// Apply a full filesystem snapshot incrementally: insert new paths, refresh +/// rows whose mtime/size changed, delete vanished ones. +/// Returns (inserted, updated, deleted). +pub fn apply( + conn: &mut Connection, + entries: &[Entry], +) -> Result<(usize, usize, usize), rusqlite::Error> { + // Existing state: path -> (mtime, size) + let mut existing: HashMap = HashMap::with_capacity(entries.len()); + { + let mut st = conn.prepare("SELECT path, mtime, size FROM items")?; + let rows = st.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, i64>(2)?, + )) + })?; + for row in rows { + let (p, m, s) = row?; + existing.insert(p, (m, s)); + } + } + + let mut inserted = 0; + let mut updated = 0; + let mut deleted = 0; + let tx = conn.transaction()?; + { + let mut ins = tx.prepare( + "INSERT INTO items (path, mtime, size) VALUES (?1, ?2, ?3)", + )?; + // mtime/size changed → content changed → derived columns are stale. + let mut upd = tx.prepare( + "UPDATE items SET mtime = ?2, size = ?3, width = NULL, height = NULL, \ + orientation = NULL, thumb_path = NULL, date_taken = NULL WHERE path = ?1", + )?; + let mut del = tx.prepare("DELETE FROM items WHERE path = ?1")?; + + let mut seen = HashSet::with_capacity(entries.len()); + for e in entries { + seen.insert(e.path.clone()); + match existing.get(&e.path) { + Some(&(m, s)) if m == e.mtime && s == e.size => {} + Some(_) => { + upd.execute(params![e.path, e.mtime, e.size])?; + updated += 1; + } + None => { + ins.execute(params![e.path, e.mtime, e.size])?; + inserted += 1; + } + } + } + for p in existing.keys() { + if !seen.contains(p) { + del.execute(params![p])?; + deleted += 1; + } + } + } + tx.commit()?; + Ok((inserted, updated, deleted)) +} + +/// All known paths, newest first. +pub fn list_paths(conn: &mut Connection) -> Result, rusqlite::Error> { + let mut st = conn.prepare("SELECT path FROM items ORDER BY mtime DESC, path ASC")?; + let rows = st.query_map([], |r| r.get::<_, String>(0))?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan::{walk, Entry}; + + fn entry(path: &str, mtime: i64, size: i64) -> Entry { + Entry { path: path.into(), mtime, size } + } + + #[test] + fn incremental_apply() { + let dir = tempfile::tempdir().unwrap(); + let mut conn = open(&dir.path().join("t.db")).unwrap(); + + let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 1, 10), entry("/a/2.jpg", 2, 20)]).unwrap(); + assert_eq!((i, u, d), (2, 0, 0)); + + // No changes → no-op. + let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 1, 10), entry("/a/2.jpg", 2, 20)]).unwrap(); + assert_eq!((i, u, d), (0, 0, 0)); + + // mtime changed → update; 3.jpg new; 2.jpg removed. + let (i, u, d) = apply( + &mut conn, + &[entry("/a/1.jpg", 5, 11), entry("/a/3.jpg", 3, 30)], + ) + .unwrap(); + assert_eq!((i, u, d), (1, 1, 1)); + + let paths = list_paths(&mut conn).unwrap(); + assert_eq!(paths, vec!["/a/1.jpg", "/a/3.jpg"]); + } + + #[test] + fn scan_then_apply_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("sub")).unwrap(); + std::fs::write(dir.path().join("a.jpg"), b"xx").unwrap(); + std::fs::write(dir.path().join("sub").join("b.png"), b"y").unwrap(); + std::fs::write(dir.path().join("notes.txt"), b"nope").unwrap(); + + let db_dir = tempfile::tempdir().unwrap(); + let mut conn = open(&db_dir.path().join("t.db")).unwrap(); + let entries = walk(&[dir.path().to_path_buf()], &|_| {}); + let (i, u, d) = apply(&mut conn, &entries).unwrap(); + assert_eq!((i, u, d), (2, 0, 0)); + + let paths = list_paths(&mut conn).unwrap(); + assert_eq!(paths.len(), 2); + assert!(paths.iter().all(|p| p.contains(dir.path().to_str().unwrap()))); + } +} diff --git a/src/lib.rs b/src/lib.rs index f682e1c..b4c7877 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,63 @@ slint::include_modules!(); +pub mod db; +pub mod scan; + +use slint::{ComponentHandle, Model, SharedString, VecModel}; + +/// M1: kick off the background scan + DB refresh; UI updates are posted to +/// the event loop (the scan thread must never touch the window directly). +/// Called from both the desktop and Android entry points after `Main::new()`. +pub fn start_scan(window: &Main) { + let model = slint::ModelRc::new(VecModel::::default()); + window.set_items(model); + let weak = window.as_weak(); + + std::thread::spawn(move || { + let t0 = std::time::Instant::now(); + let roots = scan::roots(); + let entries = scan::walk(&roots, &|n| { + let weak = weak.clone(); + let msg = format!("Scanning… {}", n); + let _ = slint::invoke_from_event_loop(move || { + if let Some(w) = weak.upgrade() { + w.set_status(msg.into()); + } + }); + }); + + let mut db = match db::open(&db::db_path()) { + Ok(db) => db, + Err(e) => { + eprintln!("gallery: db error: {e}"); + let _ = slint::invoke_from_event_loop( move || { + if let Some(w) = weak.upgrade() { + w.set_status(format!("db error: {e}").into()); + } + }); + return; + } + }; + let (ins, upd, del) = db::apply(&mut db, &entries).unwrap_or_else(|e| { + eprintln!("gallery: db apply error: {e}"); + (0, 0, 0) + }); + let paths = db::list_paths(&mut db).unwrap_or_default(); + let n = paths.len(); + let ms = t0.elapsed().as_millis(); + log::info!("gallery: scan {n} files ({ins}+ {upd}~ {del}-) in {ms} ms"); + + let weak = weak.clone(); + let _ = slint::invoke_from_event_loop(move || { + let Some(w) = weak.upgrade() else { return }; + if let Some(m) = w.get_items().as_any().downcast_ref::>() { + m.set_vec(paths.into_iter().map(SharedString::from).collect::>()); + } + w.set_status(format!("{n} files ({ins}+ {upd}~ {del}–) in {ms} ms").into()); + }); + }); +} + #[cfg(target_os = "android")] use std::sync::OnceLock; @@ -39,5 +97,6 @@ fn android_main(app: slint::android::AndroidApp) { .unwrap(); let main_window = Main::new().unwrap(); + start_scan(&main_window); main_window.run().unwrap(); } diff --git a/src/main.rs b/src/main.rs index 3aa6daa..b7bfaf6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ use gallery::Main; +use slint::ComponentHandle; -fn main() -> slint::Result { +fn main() -> Result<(), slint::PlatformError> { let main_window = Main::new()?; + gallery::start_scan(&main_window); main_window.show()?; main_window.run() } diff --git a/src/main.slint b/src/main.slint index 72c0d64..22a38cc 100644 --- a/src/main.slint +++ b/src/main.slint @@ -1,10 +1,14 @@ -// Gallery — M0 scaffold. Real UI arrives with M3 (grid) per HANDOFF.md. -import { VerticalBox } from "std-widgets.slint"; +// Gallery — M1: metadata scan + placeholder list. +// The real grid (image cells) arrives with M3 per HANDOFF.md. +import { ListView, VerticalBox } from "std-widgets.slint"; export component Main inherits Window { title: "Gallery"; - in property status: "M0 scaffold — see HANDOFF.md"; + /// Scan/DB status line ("Scanning… 1234", "5123 files (42+ 3~ 1–) in 380 ms"). + in property status: "Scanning…"; + /// Scanned file paths, newest first (M1: shown as text rows). + in property <[string]> items; VerticalBox { width: 100%; @@ -21,12 +25,24 @@ export component Main inherits Window { font-weight: 600; } Text { text: "\{status}"; font-size: 14px; } - // M3: the virtualized grid (ListView) goes here. - Rectangle { + + // M1: paths as text. M3 replaces the row body with square image cells. + ListView { width: 100%; vertical-stretch: 1; - background: #222; - border-radius: 8px; + for path[index] in root.items: Rectangle { + height: 28px; + width: parent.width; + background: Math.mod(index, 2) == 0 ? #1e1e1e : #242424; + border-radius: 4px; + Text { + x: 8px; + text: path; + font-size: 13px; + vertical-alignment: center; + overflow: elide; + } + } } } } diff --git a/src/scan.rs b/src/scan.rs new file mode 100644 index 0000000..46b43ac --- /dev/null +++ b/src/scan.rs @@ -0,0 +1,136 @@ +//! 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 { + let mut out = Vec::new(); + for r in roots { + if r.is_dir() { + walk_dir(r, &mut out, on_progress); + } + } + out +} + +fn walk_dir(dir: &Path, out: &mut Vec, on_progress: &dyn Fn(usize)) { + 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() { + // Don't follow symlinked dirs (loop protection). + if !p.is_symlink() { + walk_dir(&p, out, on_progress); + } + 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_progress(out.len()); + } + } +} + +#[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 + ); + } +}