6b863d2d2d
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
116 lines
4.6 KiB
Rust
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: ¬escraft_vault::Watcher, timeout: Duration) -> Option<VaultEvent> {
|
|
watcher.receiver().recv_timeout(timeout).ok()
|
|
}
|
|
|
|
/// Collects events until the channel stays quiet for [`QUIET`].
|
|
fn drain(watcher: ¬escraft_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());
|
|
}
|