ANW-40 Watcher: emit both deletes for a vanished .md path

A directory can be named *.md too, and a vanished path cannot be probed,
so the suffix test alone stranded its notes in the index. Emit the note
delete and the prefix delete together; the two key sets are disjoint.
This commit is contained in:
Andreas Brenner 2026-07-24 21:32:18 +03:00
parent 275c6ecfa0
commit bbb052fdc7
2 changed files with 66 additions and 9 deletions

View file

@ -194,6 +194,29 @@ mod tests {
assert!(s.get("Other/d.md").is_some());
}
#[test]
fn note_delete_and_prefix_delete_of_one_path_are_disjoint() {
// A vanished `*.md` path sends both, since it may have been a
// directory [ANW-40]. The note delete takes the key `Archive.md`,
// the prefix delete takes the keys under `Archive.md/`; neither
// reaches anything else.
let s = NoteStore::new();
s.replace(vec![
note("Archive.md"),
note("Archive.md/inner.md"),
note("Keep.md"),
]);
let dropped = s.apply_batch(
vec![],
&["Archive.md".to_string()],
&["Archive.md".to_string()],
);
assert_eq!(dropped, 1);
assert!(s.get("Archive.md").is_none());
assert!(s.get("Archive.md/inner.md").is_none());
assert!(s.get("Keep.md").is_some());
}
#[test]
fn apply_batch_prefix_delete_precedes_upserts() {
let s = NoteStore::new();

View file

@ -124,13 +124,22 @@ fn appear(vault_root: &Path, abs: &Path) -> Option<WatchAction> {
/// A path that is gone: a note to drop, or a directory whose notes are all
/// gone with it. The filesystem cannot be asked -- it no longer holds the
/// entry -- so the decision rests on the path's own shape.
fn vanish(vault_root: &Path, abs: &Path) -> Option<WatchAction> {
if is_markdown(abs) {
note_relative(vault_root, abs).map(WatchAction::Delete)
} else {
tree_relative(vault_root, abs).map(WatchAction::DeleteTree)
/// entry -- so both are emitted and the index decides which one matches.
///
/// A `.md` suffix does not prove the path was a file: a directory may carry
/// it too, and then the suffix test alone strands its notes in the index
/// [ANW-40]. The two key sets are disjoint -- the note delete removes the key
/// `dir`, the prefix delete removes keys under `dir/` -- so emitting both is
/// always safe. It costs one range scan per deleted note.
fn vanish(vault_root: &Path, abs: &Path) -> Vec<WatchAction> {
let mut actions = Vec::new();
if let Some(rel) = note_relative(vault_root, abs) {
actions.push(WatchAction::Delete(rel));
}
if let Some(rel) = tree_relative(vault_root, abs) {
actions.push(WatchAction::DeleteTree(rel));
}
actions
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -454,7 +463,10 @@ mod tests {
);
assert_eq!(
map_event(&e, &vault()),
vec![WatchAction::Delete("a.md".into())]
vec![
WatchAction::Delete("a.md".into()),
WatchAction::DeleteTree("a.md".into())
]
);
}
@ -480,6 +492,7 @@ mod tests {
map_event(&e, &vault()),
vec![
WatchAction::Delete("a.md".into()),
WatchAction::DeleteTree("a.md".into()),
WatchAction::Upsert("b.md".into())
]
);
@ -490,7 +503,25 @@ mod tests {
let e = ev(EventKind::Remove(RemoveKind::File), &["/v/a.md"]);
assert_eq!(
map_event(&e, &vault()),
vec![WatchAction::Delete("a.md".into())]
vec![
WatchAction::Delete("a.md".into()),
WatchAction::DeleteTree("a.md".into())
]
);
}
#[test]
fn removed_md_directory_drops_the_notes_under_it() {
// A directory may be named `*.md` too. The vanished path cannot be
// probed, so the note delete and the prefix delete both go out and
// the index applies whichever matches [ANW-40].
let e = ev(EventKind::Remove(RemoveKind::Folder), &["/v/Archive.md"]);
assert_eq!(
map_event(&e, &vault()),
vec![
WatchAction::Delete("Archive.md".into()),
WatchAction::DeleteTree("Archive.md".into())
]
);
}
@ -588,7 +619,10 @@ mod tests {
let absent = ev(kind, &[]).add_path(root.path().join("gone.md"));
assert_eq!(
map_event(&absent, root.path()),
vec![WatchAction::Delete("gone.md".into())]
vec![
WatchAction::Delete("gone.md".into()),
WatchAction::DeleteTree("gone.md".into())
]
);
}