Files
Marcos Paulo 6b863d2d2d feat(vault): wave 1 implementation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:59:06 -03:00

116 lines
4.6 KiB
Rust

//! The debounced file-system watcher (SPEC §4.2).
mod common;
use std::fs;
use std::time::Duration;
use notescraft_vault::{VaultEvent, WriteOutcome};
const SETTLE: Duration = Duration::from_secs(3);
const QUIET: Duration = Duration::from_millis(1200);
fn recv(watcher: &notescraft_vault::Watcher, timeout: Duration) -> Option<VaultEvent> {
watcher.receiver().recv_timeout(timeout).ok()
}
/// Collects events until the channel stays quiet for [`QUIET`].
fn drain(watcher: &notescraft_vault::Watcher) -> Vec<VaultEvent> {
let mut out = Vec::new();
while let Ok(event) = watcher.receiver().recv_timeout(QUIET) {
out.push(event);
}
out
}
#[test]
fn an_external_change_is_reported_with_a_vault_relative_path() {
let (_dir, vault) = common::vault("watch-external");
let watcher = vault.watch().unwrap();
fs::create_dir_all(vault.root().join("sub")).unwrap();
fs::write(vault.root().join("sub/note.md"), b"from outside").unwrap();
fs::remove_file(vault.root().join("sub/note.md")).unwrap();
let events = drain(&watcher);
let created = events
.iter()
.find(|e| matches!(e, VaultEvent::Created(p) if p == "sub/note.md"))
.unwrap_or_else(|| panic!("no Created event for sub/note.md in {events:?}"));
assert_eq!(created, &VaultEvent::Created("sub/note.md".to_string()));
assert!(events.iter().any(|e| matches!(e, VaultEvent::Removed(p) if p == "sub/note.md")), "no Removed event in {events:?}");
assert!(
events.iter().all(|e| !matches!(e, VaultEvent::Created(p) | VaultEvent::Modified(p) | VaultEvent::Removed(p) if p.starts_with('/'))),
"paths must be vault relative: {events:?}"
);
}
#[test]
fn a_write_through_the_vault_does_not_echo_back() {
let (_dir, vault) = common::vault("watch-self");
let watcher = vault.watch().unwrap();
assert_eq!(vault.write("note.md", b"written by us").unwrap(), WriteOutcome::Written);
assert_eq!(vault.write("note.md", b"written by us").unwrap(), WriteOutcome::Unchanged);
assert_eq!(vault.write("sub/deep.md", b"also ours").unwrap(), WriteOutcome::Written);
std::thread::sleep(SETTLE);
let echoed = drain(&watcher);
assert!(echoed.is_empty(), "our own writes echoed back: {echoed:?}");
// The watcher is still live for genuine external changes.
fs::write(vault.root().join("note.md"), b"changed outside").unwrap();
let event = recv(&watcher, SETTLE).expect("external change");
assert!(matches!(&event, VaultEvent::Modified(p) | VaultEvent::Created(p) if p == "note.md"), "{event:?}");
}
#[test]
fn hidden_folders_are_ignored() {
let (_dir, vault) = common::vault("watch-hidden");
let watcher = vault.watch().unwrap();
fs::create_dir_all(vault.root().join(".notescraft/cache")).unwrap();
fs::write(vault.root().join(".notescraft/cache/blob.bin"), b"x").unwrap();
fs::write(vault.root().join(".hidden.md"), b"x").unwrap();
std::thread::sleep(SETTLE);
let events = drain(&watcher);
assert!(events.is_empty(), "hidden folders must be ignored: {events:?}");
}
#[test]
fn an_external_rename_is_reported_as_one_event() {
let (_dir, vault) = common::vault("watch-rename");
fs::write(vault.root().join("before.md"), b"body").unwrap();
let watcher = vault.watch().unwrap();
fs::rename(vault.root().join("before.md"), vault.root().join("after.md")).unwrap();
let events = drain(&watcher);
let renamed = events.iter().find_map(|e| match e {
VaultEvent::Renamed { from, to } => Some((from.clone(), to.clone())),
_ => None,
});
// Backends that cannot pair a rename report the two ends separately.
let ends = events
.iter()
.filter_map(|e| match e {
VaultEvent::Created(p) | VaultEvent::Modified(p) | VaultEvent::Removed(p) => Some(p.clone()),
VaultEvent::Renamed { .. } => None,
})
.collect::<Vec<_>>();
assert!(
renamed == Some(("before.md".to_string(), "after.md".to_string())) || ends.contains(&"before.md".to_string()) && ends.contains(&"after.md".to_string()),
"no rename reported: {events:?}"
);
}
/// The vault keeps working with the watcher in place: guard, lock and IO are unaffected.
#[test]
fn a_watched_vault_still_writes_atomically() {
let (_dir, vault) = common::vault("watch-and-write");
let _watcher = vault.watch().unwrap();
assert_eq!(vault.write("note.md", b"body").unwrap(), WriteOutcome::Written);
assert_eq!(vault.read("note.md").unwrap(), b"body");
let list: Vec<String> = vault.list().unwrap().into_iter().map(|e| e.path).collect();
assert_eq!(list, vec!["note.md".to_string()]);
assert!(vault.resolve("../x").is_err());
}