Files
DarkRoom/core/dr-sync/src/scan.rs
T
dtourolleandClaude Opus 5 7418b040da Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan`
stepped over a NotFound or PermissionDenied the way it does for a child
that vanished mid-walk -- correct for a child, wrong for the root, where
it ended the walk, returned Ok with nothing in it, and reported a
successful scan of a library that was no longer there.

A lost root is now its own error. The images under it are marked
Availability::Offline per FR-CAT-9 and no catalog row is deleted;
`library::persist` clears the mark per file as each one is listed again,
so a root that comes back needs no repair step.

Partly satisfied rather than closed, and the gap is worth stating.
The recovery half is real and reachable on Android today, because
`map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how
a phone actually gets a library in this build. The causes the
requirement names -- revocation, reinstall, a removed card -- are
properties of a persisted tree permission, and there is none: SAF does
not exist here, `SourceRef::Document` is constructed only in test
modules, and `LocalStorage` rejects the variant outright. When SAF
lands it becomes a third producer of this error and nothing above it
changes, which is why the discovery belongs in the connector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:57 +02:00

871 lines
32 KiB
Rust

//! Recursive discovery of images under a chosen remote folder.
//!
//! The library-setup path: the user picks a folder, ticks the formats they
//! shoot, and this walks the tree finding matching files (FR-CAT-1, M-5).
//!
//! Depth:1 per directory, never `Depth: infinity` — the latter is frequently
//! disabled and prohibitively expensive where it is not (ARCH §8.4). Where the
//! backend propagates directory ETags, an unchanged subtree is skipped whole,
//! which is what keeps a re-scan proportional to what changed rather than to
//! library size.
use std::collections::HashMap;
use dr_types::FormatFilter;
use crate::{
Capabilities, ChangeDetection, EntryKind, RemoteBackend, RemoteEntry, RemoteError, RemotePath,
Validator,
};
/// Progress during a scan, so the UI can show something on a large library.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ScanProgress {
pub directories_listed: usize,
/// Directories skipped because their ETag was unchanged. The value of
/// pruning, made visible.
pub directories_pruned: usize,
pub images_found: usize,
}
/// The result of a scan.
#[derive(Debug, Clone, Default)]
pub struct ScanResult {
/// Files matching the format filter.
pub images: Vec<RemoteEntry>,
/// Directories whose contents were **actually listed**, with the ETag
/// observed at that moment.
///
/// **Must be persisted**, or every scan is a full walk (ARCH §6.6).
///
/// Only listed directories appear here, and that distinction is
/// load-bearing. A directory discovered as a child of another is *known*
/// but not yet *read*: storing its ETag then would let the next scan prune
/// a subtree whose contents were never seen, hiding every file beneath it
/// permanently. Storing it only after listing means an interrupted scan
/// re-reads that folder next time — slower, and correct.
pub directories: Vec<(RemotePath, Validator)>,
pub progress: ScanProgress,
}
/// How deep to recurse before giving up.
///
/// A symlink loop or a pathological tree would otherwise walk forever. Real
/// photo libraries are nowhere near this deep.
const MAX_DEPTH: usize = 32;
/// TRACES: FR-CAT-15
/// Directory name holding soft-deleted images.
///
/// Inside the library root rather than beside it: the root is the only place the
/// user granted access to, and on Nextcloud a `MOVE` out of it may cross a share
/// boundary the account cannot write to.
///
/// Leading dot so other tools treat it as hidden, and a name specific enough not
/// to collide with a photographer's own folder — "Trash" alone is a plausible
/// album title.
pub const TRASH_DIR: &str = ".darkroom-trash";
/// TRACES: FR-CAT-3
/// Directory name holding derived state pushed to the server — thumbnail
/// shards and the catalog snapshot.
///
/// Excluded from the scan for the same reason as the trash, though for a
/// different failure: its contents are `.sqlite` files that no format filter
/// would match, so nothing would be *indexed*, but the walk would still pay a
/// listing for it on every sync of every device.
pub const DERIVED_DIR: &str = ".darkroom-derived";
/// Whether a directory should be skipped by the scan.
///
/// **The trash must be excluded or the soft delete does not hold.** Trashed
/// images live in a real folder under the library root, so a scan that walked it
/// would re-index them as ordinary photographs and they would reappear in the
/// grid — the delete undone by the next refresh.
///
/// Matched on the final path component, not a prefix: a photograph in
/// `2019/.darkroom-trash/` (a nested library moved in wholesale) is excluded on
/// the same grounds as one at the root.
pub fn is_excluded(dir: &RemotePath) -> bool {
matches!(dir.name(), TRASH_DIR | DERIVED_DIR)
}
/// TRACES: FR-CAT-1 | FR-NC-4 | M-5 | M-7
/// Walk `root` recursively, collecting files the filter accepts.
///
/// `known` maps previously seen directories to their ETags. Pass an empty map
/// for a first scan; pass the stored ETags to prune unchanged subtrees.
///
/// `on_progress` is called after each directory so a long scan can report
/// rather than appear hung.
pub async fn scan<B, F>(
backend: &B,
root: &RemotePath,
filter: &FormatFilter,
known: &HashMap<RemotePath, Validator>,
mut on_progress: F,
) -> Result<ScanResult, RemoteError>
where
B: RemoteBackend + ?Sized,
F: FnMut(ScanProgress),
{
let prunable = supports_pruning(backend.capabilities());
let mut result = ScanResult::default();
// Explicit stack rather than recursion: an async recursive fn needs
// boxing, and a deep tree could overflow.
//
// Each item carries the validator its *parent* reported, so it can be
// recorded once the directory has actually been listed. The root has none
// until it is probed.
let mut stack: Vec<(RemotePath, usize, Option<Validator>)> = vec![(root.clone(), 0, None)];
while let Some((dir, depth, seen_validator)) = stack.pop() {
if depth > MAX_DEPTH {
log::warn!("scan: depth limit at {dir}, not descending further");
continue;
}
// The trash, skipped before it is listed or even probed. Trashed images
// are real files in a real folder under the root (FR-CAT-15), so
// walking it would re-index them and undo the delete on the next scan.
//
// Deliberately *not* recorded in `result.directories`: an excluded
// directory has no ETag worth storing, and storing one would let a
// later scan believe it had been read.
if is_excluded(&dir) {
log::debug!("scan: skipping {dir}");
continue;
}
// Prune: if the directory's ETag is unchanged, nothing anywhere
// beneath it changed either, because Nextcloud propagates upward.
let mut current_validator = seen_validator;
if prunable {
let known_validator = known.get(&dir);
// Probe when there is something to compare against, and also when
// this directory arrived without a validator — the root, which no
// parent listing described. Without that second case the root's
// ETag is never recorded, and the one-request no-op sync that
// ETag pruning exists for can never fire.
if known_validator.is_some() || current_validator.is_none() {
match backend.dir_validator(&dir).await {
Ok(current) => {
if known_validator == Some(&current) {
result.progress.directories_pruned += 1;
// Re-record it: the entry must survive this scan,
// or the next one has nothing to compare against
// and walks the whole subtree again.
result.directories.push((dir.clone(), current));
on_progress(result.progress);
continue;
}
current_validator = Some(current);
}
// A probe failure is not fatal — fall through to listing,
// which is correct, just not free.
Err(e) => log::debug!("scan: validator probe failed for {dir}: {e}"),
}
}
}
let entries = match backend.list(&dir, None).await {
Ok(e) => e,
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
// The root is the one directory the walk may not step over, and
// `depth == 0` is the only place it can be — nothing is ever
// pushed at that depth but the root itself.
//
// Below, a directory that has gone is a directory that went away
// between its parent being listed and it being reached, and
// continuing is right. At the root the identical error means the
// *library* is gone, and continuing is catastrophic in a way that
// is completely silent: the walk ends, the scan succeeds having
// found nothing, and the app reports a healthy library with no new
// images while every path in the catalog now points nowhere.
//
// Refused rather than reclassified. Only these two causes are —
// a `Network` failure at the root is still a network failure, and
// must stay one or an unplugged network cable would present itself
// as a revoked permission and offline mode would never engage.
Err(RemoteError::NotFound(_)) if depth == 0 => {
return Err(RemoteError::RootUnavailable(format!(
"{root} is no longer there"
)));
}
Err(RemoteError::PermissionDenied) if depth == 0 => {
// Deliberately not the variant's own message, which describes
// a Nextcloud share that refuses to *update* a sidecar. At the
// root nothing has been read at all.
return Err(RemoteError::RootUnavailable(format!(
"{root} can no longer be read"
)));
}
Err(RemoteError::NotFound(_)) => {
// Deleted between listing its parent and reaching it.
log::debug!("scan: {dir} vanished during the walk");
continue;
}
Err(e) => return Err(e),
};
result.progress.directories_listed += 1;
// Only now that the contents have been read is it safe to record this
// directory's ETag. Recording it at discovery time would let a later
// scan prune a subtree that was never actually listed, hiding every
// file beneath it.
if let Some(v) = current_validator {
result.directories.push((dir.clone(), v));
}
for entry in entries {
match entry.kind {
EntryKind::Directory => {
// The validator travels with it, to be recorded when the
// child is listed — not here.
stack.push((entry.path, depth + 1, Some(entry.validator)));
}
EntryKind::File => {
if filter.allows_name(entry.path.name()) {
result.images.push(entry);
result.progress.images_found += 1;
}
}
}
}
on_progress(result.progress);
}
// Sort so a scan is reproducible and the grid has a stable order.
result.images.sort_by(|a, b| a.path.cmp(&b.path));
result.directories.sort_by(|a, b| a.0.cmp(&b.0));
Ok(result)
}
/// Whether pruning is worth attempting against this backend.
///
/// Only propagating ETags make an unchanged parent prove an unchanged
/// subtree. With per-entry ETags the probe costs a request and proves
/// nothing about children, so it is pure overhead.
fn supports_pruning(caps: &Capabilities) -> bool {
matches!(caps.change_detection, ChangeDetection::PropagatingEtags)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{RemoteId, ServerPreviews};
use async_trait::async_trait;
use std::cell::RefCell;
use std::ops::Range;
/// A backend over an in-memory tree, counting requests so tests can
/// assert that pruning actually avoids work.
struct FakeBackend {
tree: HashMap<String, Vec<RemoteEntry>>,
etags: HashMap<String, &'static str>,
caps: Capabilities,
lists: RefCell<usize>,
probes: RefCell<usize>,
/// Directories whose listing fails, and how.
///
/// An absent directory is not enough to model this: the fake answers
/// an unknown path with an empty listing, which is exactly the shape
/// the walk must *not* confuse with a library that has gone away.
deny: HashMap<String, Deny>,
}
/// The two ways a real backend refuses a directory that is still named in
/// the catalog: it is not there, or it may not be read.
#[derive(Clone, Copy)]
enum Deny {
Missing,
Forbidden,
}
// The fake is single-threaded; tests never share it across threads.
unsafe impl Sync for FakeBackend {}
fn dir(path: &str, etag: &str) -> RemoteEntry {
RemoteEntry {
id: RemoteId::Path(RemotePath::new(path)),
path: RemotePath::new(path),
kind: EntryKind::Directory,
validator: Validator::new(etag),
size: 0,
modified: None,
has_preview: false,
materialised: true,
}
}
fn file(path: &str) -> RemoteEntry {
RemoteEntry {
id: RemoteId::Path(RemotePath::new(path)),
path: RemotePath::new(path),
kind: EntryKind::File,
validator: Validator::new("f"),
size: 1000,
modified: None,
has_preview: false,
materialised: true,
}
}
impl FakeBackend {
/// Photos/{2025/{a.CR2,b.jpg}, 2026/{c.NEF,notes.txt}}
fn sample(change_detection: ChangeDetection) -> Self {
let mut tree = HashMap::new();
tree.insert(
"Photos".into(),
vec![dir("Photos/2025", "e2025"), dir("Photos/2026", "e2026")],
);
tree.insert(
"Photos/2025".into(),
vec![file("Photos/2025/a.CR2"), file("Photos/2025/b.jpg")],
);
tree.insert(
"Photos/2026".into(),
vec![file("Photos/2026/c.NEF"), file("Photos/2026/notes.txt")],
);
let mut etags = HashMap::new();
etags.insert("Photos".to_string(), "root");
etags.insert("Photos/2025".to_string(), "e2025");
etags.insert("Photos/2026".to_string(), "e2026");
Self {
tree,
etags,
caps: Capabilities {
change_detection,
stable_ids: true,
range_reads: true,
chunked_upload: None,
bulk_upload: false,
conditional_write: true,
server_previews: ServerPreviews::None,
materialisation: crate::Materialisation::Always,
},
lists: RefCell::new(0),
probes: RefCell::new(0),
deny: HashMap::new(),
}
}
/// Make one directory refuse to be listed.
fn denying(mut self, path: &str, how: Deny) -> Self {
self.deny.insert(path.to_string(), how);
self
}
}
#[async_trait]
impl RemoteBackend for FakeBackend {
fn capabilities(&self) -> &Capabilities {
&self.caps
}
fn name(&self) -> &str {
"fake"
}
async fn list(
&self,
dir: &RemotePath,
_since: Option<&Validator>,
) -> Result<Vec<RemoteEntry>, RemoteError> {
*self.lists.borrow_mut() += 1;
match self.deny.get(dir.as_str()) {
Some(Deny::Missing) => return Err(RemoteError::NotFound(dir.to_string())),
Some(Deny::Forbidden) => return Err(RemoteError::PermissionDenied),
None => {}
}
Ok(self.tree.get(dir.as_str()).cloned().unwrap_or_default())
}
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError> {
*self.probes.borrow_mut() += 1;
self.etags
.get(dir.as_str())
.map(|e| Validator::new(*e))
.ok_or_else(|| RemoteError::NotFound(dir.to_string()))
}
async fn delta(
&self,
_c: &crate::Cursor,
) -> Result<(Vec<crate::RemoteChange>, crate::Cursor), RemoteError> {
Err(RemoteError::Unsupported("fake"))
}
async fn get(
&self,
_id: &RemoteId,
_r: Option<Range<u64>>,
) -> Result<Vec<u8>, RemoteError> {
Ok(Vec::new())
}
async fn put(
&self,
_p: &RemotePath,
_b: Vec<u8>,
_pc: Option<crate::Precondition>,
) -> Result<Validator, RemoteError> {
Err(RemoteError::Unsupported("fake"))
}
async fn delete(
&self,
_id: &RemoteId,
_pc: Option<crate::Precondition>,
) -> Result<(), RemoteError> {
Err(RemoteError::Unsupported("fake"))
}
async fn move_to(&self, _from: &RemoteId, _to: &RemotePath) -> Result<(), RemoteError> {
Err(RemoteError::Unsupported("fake"))
}
async fn create_dir(&self, _path: &RemotePath) -> Result<(), RemoteError> {
Err(RemoteError::Unsupported("fake"))
}
}
#[test]
fn the_derived_folder_is_excluded_like_the_trash() {
// Its contents are .sqlite files no format filter would match, so
// nothing would be *indexed* — but the walk would still pay a listing
// for it on every sync of every device.
assert!(is_excluded(&RemotePath::new("Photos/.darkroom-derived")));
assert!(is_excluded(&RemotePath::new("Photos/.darkroom-trash")));
assert!(!is_excluded(&RemotePath::new("Photos/2026")));
// Matched on the final component, so a nested library moved in
// wholesale is excluded on the same grounds.
assert!(is_excluded(&RemotePath::new("a/b/.darkroom-derived")));
}
#[tokio::test]
async fn finds_images_recursively() {
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
// notes.txt is not an image; the other three are.
assert_eq!(r.images.len(), 3);
assert_eq!(r.progress.images_found, 3);
assert_eq!(r.progress.directories_listed, 3);
}
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
#[tokio::test]
async fn a_root_that_is_gone_is_a_failure_and_not_an_empty_library() {
// The silent one. A vanished directory below the root is stepped over,
// and before this the root was stepped over on the same terms — which
// ended the walk immediately, returned `Ok` with nothing in it, and
// let the app report a successful scan of a library that no longer
// exists. Nothing in that path is ever told the library went away, so
// nothing marks it offline and nothing tells the user.
let b =
FakeBackend::sample(ChangeDetection::PropagatingEtags).denying("Photos", Deny::Missing);
let e = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.expect_err("a library that is not there is not a library with no photographs");
assert!(e.indicates_lost_root(), "got {e:?}");
assert!(!e.indicates_offline(), "waiting will not bring this back");
assert!(e.to_string().contains("Photos"), "names the library: {e}");
}
/// TRACES: FR-PLAT-AND-2
#[tokio::test]
async fn a_root_that_may_not_be_read_reports_the_root_and_not_the_share_advice() {
// A Nextcloud share withdrawn, a directory the process may no longer
// read — and the shape a revoked Android tree grant will have when one
// can be held at all. Reported as plain `PermissionDenied` it would
// have carried that variant's message, which is several lines about a
// Nextcloud share refusing to *update* an existing sidecar: advice for
// a case where reads work, offered to a user whose reads have stopped
// entirely.
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags)
.denying("Photos", Deny::Forbidden);
let e = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.expect_err("a root that cannot be read is a failed scan");
assert!(e.indicates_lost_root(), "got {e:?}");
assert!(
!e.to_string().contains("sidecar"),
"the share-permission advice does not belong here: {e}"
);
}
/// TRACES: FR-PLAT-AND-2
#[tokio::test]
async fn a_folder_that_goes_away_below_the_root_is_still_stepped_over() {
// The other side of the split, and the reason the root is keyed on
// depth rather than on the error. A subfolder deleted between its
// parent being listed and it being reached is ordinary, and failing
// the scan over it would abandon every photograph beside it.
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags)
.denying("Photos/2025", Deny::Missing);
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.expect("one folder going away is not the library going away");
assert_eq!(r.images.len(), 1, "2026 was still walked");
}
/// A library with a trash folder holding a soft-deleted image.
fn with_trash() -> FakeBackend {
let mut b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
b.tree.insert(
"Photos".into(),
vec![
dir("Photos/2025", "e2025"),
dir("Photos/2026", "e2026"),
dir("Photos/.darkroom-trash", "etrash"),
],
);
b.tree.insert(
"Photos/.darkroom-trash".into(),
vec![file("Photos/.darkroom-trash/9-deleted.CR2")],
);
b.etags
.insert("Photos/.darkroom-trash".to_string(), "etrash");
b
}
#[tokio::test]
async fn the_trash_folder_is_never_scanned() {
// TRACES: FR-CAT-15
// The other half of the soft delete: a scan that walked the trash would
// re-index every trashed photograph and undo the delete on the next
// refresh.
let b = with_trash();
let result = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::default(),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
assert!(
!result
.images
.iter()
.any(|i| i.path.as_str().contains(".darkroom-trash")),
"a trashed image must not come back as an ordinary one"
);
// The real photographs are still found.
assert!(result
.images
.iter()
.any(|i| i.path.as_str().ends_with("a.CR2")));
}
#[tokio::test]
async fn the_trash_folder_costs_no_requests() {
// Not merely filtered out of the results — never listed and never
// probed. A folder whose contents are deliberately invisible must not
// cost a round trip on every scan.
let b = with_trash();
scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::default(),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
// Photos, Photos/2025, Photos/2026 — and not the trash, which would
// make four.
assert_eq!(*b.lists.borrow(), 3, "the trash was listed");
}
#[tokio::test]
async fn the_trash_folder_gets_no_stored_etag() {
// Storing one would let a later scan believe the folder had been read,
// which is the failure the `directories` doc comment warns about.
let b = with_trash();
let result = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::default(),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
assert!(!result
.directories
.iter()
.any(|(p, _)| p.as_str().contains(".darkroom-trash")));
}
#[test]
fn exclusion_matches_the_folder_name_at_any_depth() {
// A nested library moved in wholesale carries its own trash.
assert!(is_excluded(&RemotePath::new("Photos/.darkroom-trash")));
assert!(is_excluded(&RemotePath::new("a/b/c/.darkroom-trash")));
assert!(is_excluded(&RemotePath::new(".darkroom-trash")));
// And nothing else is swept up by it.
assert!(!is_excluded(&RemotePath::new("Photos/2025")));
assert!(!is_excluded(&RemotePath::new("Photos/trash")));
assert!(!is_excluded(&RemotePath::new("Photos/.darkroom-trash-old")));
}
#[tokio::test]
async fn the_format_filter_is_applied() {
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::from_formats([dr_types::Format::Cr2]),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
assert_eq!(r.images.len(), 1);
assert_eq!(r.images[0].path.name(), "a.CR2");
}
#[tokio::test]
async fn unchanged_subtrees_are_pruned() {
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
let mut known = HashMap::new();
known.insert(RemotePath::new("Photos/2025"), Validator::new("e2025"));
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&known,
|_| {},
)
.await
.unwrap();
// 2025 was proven unchanged by a single probe, so it was never listed
// and its files were not re-enumerated.
assert_eq!(r.progress.directories_pruned, 1);
assert_eq!(r.progress.directories_listed, 2);
assert!(r.images.iter().all(|i| !i.path.as_str().contains("2025")));
}
#[tokio::test]
async fn a_changed_etag_defeats_pruning() {
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
let mut known = HashMap::new();
known.insert(RemotePath::new("Photos/2025"), Validator::new("stale"));
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&known,
|_| {},
)
.await
.unwrap();
assert_eq!(r.progress.directories_pruned, 0);
assert_eq!(r.images.len(), 3);
}
#[tokio::test]
async fn pruning_is_not_attempted_without_propagating_etags() {
// Per-entry ETags say nothing about children, so probing would cost a
// request and prove nothing.
let b = FakeBackend::sample(ChangeDetection::LocalEtags);
let mut known = HashMap::new();
known.insert(RemotePath::new("Photos/2025"), Validator::new("e2025"));
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&known,
|_| {},
)
.await
.unwrap();
assert_eq!(*b.probes.borrow(), 0, "must not probe");
assert_eq!(r.progress.directories_pruned, 0);
assert_eq!(r.images.len(), 3);
}
#[tokio::test]
async fn directory_etags_are_returned_for_persistence() {
// Without these the next scan has nothing to compare and prunes
// nothing (ARCH §6.6).
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
// Three: the root and its two children. The root matters most — its
// ETag is what turns the next no-op sync into a single request.
assert_eq!(r.directories.len(), 3);
assert!(r
.directories
.iter()
.any(|(p, v)| p.as_str() == "Photos/2025" && v.as_str() == "e2025"));
assert!(
r.directories.iter().any(|(p, _)| p.as_str() == "Photos"),
"the scan root must be recorded, or pruning can never start there"
);
}
#[tokio::test]
async fn only_listed_directories_are_recorded() {
// The bug this guards against permanently hid files: recording a
// directory's ETag when it was *discovered* rather than when it was
// *listed* meant a scan that stopped early still stored ETags for
// subtrees it never read. The next scan then probed those ETags,
// found them unchanged, and pruned folders whose contents had never
// been seen — so their images never entered the catalog at all.
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
// Every recorded directory must be one that was actually listed.
for (path, _) in &r.directories {
assert!(
b.tree.contains_key(path.as_str()),
"{path} was recorded without being listed"
);
}
assert_eq!(r.progress.directories_listed, r.directories.len());
}
#[tokio::test]
async fn a_pruned_directory_keeps_its_recorded_etag() {
// Pruning must not drop the entry: without it the next scan has
// nothing to compare and re-walks the whole subtree, turning every
// sync back into a full walk.
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
let mut known = HashMap::new();
known.insert(RemotePath::new("Photos/2025"), Validator::new("e2025"));
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&known,
|_| {},
)
.await
.unwrap();
assert_eq!(r.progress.directories_pruned, 1);
assert!(
r.directories
.iter()
.any(|(p, v)| p.as_str() == "Photos/2025" && v.as_str() == "e2025"),
"a pruned directory must still be recorded for the next scan"
);
}
#[tokio::test]
async fn results_are_ordered_reproducibly() {
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
let paths: Vec<&str> = r.images.iter().map(|i| i.path.as_str()).collect();
let mut sorted = paths.clone();
sorted.sort();
assert_eq!(paths, sorted);
}
#[tokio::test]
async fn progress_is_reported_per_directory() {
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
let mut updates = Vec::new();
scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|p| updates.push(p),
)
.await
.unwrap();
// One per directory visited, so a long scan never looks hung.
assert_eq!(updates.len(), 3);
assert_eq!(updates.last().unwrap().images_found, 3);
}
#[tokio::test]
async fn an_empty_filter_finds_nothing_but_still_walks() {
let b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::from_formats([]),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
assert!(r.images.is_empty());
// The walk still happened, so directory ETags are still collected —
// the root plus its two children.
assert_eq!(r.directories.len(), 3);
}
}