Summary
ScanUseCase writes file_locations.relative_path relative to the scan root, but writes volume_mounts.mount_path as the volume mount point. Any scan where those two differ produces rows whose absolute path cannot be reconstructed.
Root cause
Scanner::walk takes the two as distinct parameters, and documents the distinction (crates/core/src/ports/scanner.rs:42):
fn walk<'a>(
&'a self,
root: &Path,
volume_root: &Path, // "used to compute each file's relative path"
) -> Result<Box<dyn Iterator<Item = DiscoveredFile> + Send + 'a>, CoreError>;
crates/app/src/scan.rs:480 passes the same value for both:
self.scanner.walk(&canonical_root, &canonical_root)?
Yet ~10 lines earlier (scan.rs:466-471) the real mount point is already resolved and persisted:
let detected = perima_fs::detect_volume(&canonical_root)?;
let vol_id = self.volumes.find_or_create(&detected.identifiers, device_id)?;
self.volumes.record_mount(vol_id, device_id, &detected.mount_point)?;
So the two halves of the reconstruction in crates/desktop/src/payloads.rs:247 disagree:
let absolute_path = mount_path.as_ref().map(|mp| {
let mut p = std::path::PathBuf::from(mp);
p.push(loc.relative_path.as_str()); // relative to scan root, not mount_path
p.to_string_lossy().into_owned()
});
Reproduction
Scan a subdirectory of a mount (the normal case — the mount point is rarely the directory you want indexed):
- mount point →
/mnt/data
- scan root →
/mnt/data/Videos
- file →
/mnt/data/Videos/clip.mp4
relative_path recorded → clip.mp4 (should be Videos/clip.mp4)
- reconstructed →
/mnt/data/clip.mp4 ❌
Observed in practice: of 419 indexed rows, 417 resolved to nonexistent paths. Surfaced downstream as a transcription failure, which is misleading — the transcriber is fine:
Could not decode audio from source: stat input: No such file or directory (os error 2)
relativize() itself (crates/fs/src/paths.rs:24) is correct and well-tested; only its caller is wrong.
Fix
Pass the resolved mount point as volume_root. volume_info is already computed before the walk, so no reordering is needed — dry-run (which detects no volume) falls back to the scan root:
let volume_root = volume_info
.as_ref()
.map_or(canonical_root.as_path(), |(_, _, mount)| mount.as_path());
let discovered: Vec<DiscoveredFile> = self
.scanner
.walk(&canonical_root, volume_root)?
Why the tests missed it
Every scan test sets scan root == volume root (tempfile roots are their own mount), so walk(root, root) is correct in the fixture and wrong in production. The bug is invisible to any test that doesn't scan a strict subdirectory of a mount.
A regression test must scan a subdirectory and assert the recorded relative_path includes the intermediate segment.
Blocks
Blocks the location verify-sweep work (feat/location-verify-and-prune). A verify sweep stats each reconstructed absolute path and marks unreachable rows Missing — run against a catalogue built by the current scan, it would mark almost every row Missing and hand a prune button a full-catalogue delete. This must land first.
Summary
ScanUseCasewritesfile_locations.relative_pathrelative to the scan root, but writesvolume_mounts.mount_pathas the volume mount point. Any scan where those two differ produces rows whose absolute path cannot be reconstructed.Root cause
Scanner::walktakes the two as distinct parameters, and documents the distinction (crates/core/src/ports/scanner.rs:42):crates/app/src/scan.rs:480passes the same value for both:Yet ~10 lines earlier (
scan.rs:466-471) the real mount point is already resolved and persisted:So the two halves of the reconstruction in
crates/desktop/src/payloads.rs:247disagree:Reproduction
Scan a subdirectory of a mount (the normal case — the mount point is rarely the directory you want indexed):
/mnt/data/mnt/data/Videos/mnt/data/Videos/clip.mp4relative_pathrecorded →clip.mp4(should beVideos/clip.mp4)/mnt/data/clip.mp4❌Observed in practice: of 419 indexed rows, 417 resolved to nonexistent paths. Surfaced downstream as a transcription failure, which is misleading — the transcriber is fine:
relativize()itself (crates/fs/src/paths.rs:24) is correct and well-tested; only its caller is wrong.Fix
Pass the resolved mount point as
volume_root.volume_infois already computed before the walk, so no reordering is needed — dry-run (which detects no volume) falls back to the scan root:Why the tests missed it
Every scan test sets scan root == volume root (
tempfileroots are their own mount), sowalk(root, root)is correct in the fixture and wrong in production. The bug is invisible to any test that doesn't scan a strict subdirectory of a mount.A regression test must scan a subdirectory and assert the recorded
relative_pathincludes the intermediate segment.Blocks
Blocks the location verify-sweep work (
feat/location-verify-and-prune). A verify sweep stats each reconstructed absolute path and marks unreachable rowsMissing— run against a catalogue built by the current scan, it would mark almost every rowMissingand hand a prune button a full-catalogue delete. This must land first.