use std::path::{Path, PathBuf};
use fig::Segment;
use crate::identity::IdentityPolicy;
use crate::workspace::Workspace;
use prov_graph::document::Document;
use prov_graph::error::{Error, Result};
use prov_graph::graph::Target;
use prov_graph::link::{self, Link};
use prov_graph::meta::Value;
use prov_store::edit::MetaEditor;
use prov_store::fs::Storage;
use prov_store::index::IndexStore;
use crate::change::ChangeSet;
const LOG_TITLE: &str = "Deletions";
pub(crate) struct Deletion {
pub title: String,
pub id: Option<prov_graph::identity::Id>,
pub from: PathBuf,
pub parent: Option<PathBuf>,
pub body: Option<PathBuf>,
pub at: Option<String>,
}
impl Deletion {
fn to_record(&self) -> Value {
let mut record = prov_graph::meta::Mapping::new();
let mut put = |key: &str, path: &Path| {
record.insert(
key.into(),
Value::String(path.to_string_lossy().into_owned()),
);
};
put("from", &self.from);
if let Some(parent) = &self.parent {
put("parent", parent);
}
if let Some(body) = &self.body {
put("body", body);
}
record.insert("title".into(), Value::String(self.title.clone()));
if let Some(id) = &self.id {
record.insert("id".into(), Value::String(id.to_string()));
}
if let Some(at) = &self.at {
record.insert("at".into(), Value::String(at.clone()));
}
Value::Mapping(record)
}
}
struct Record {
parent: Option<PathBuf>,
body: Option<PathBuf>,
title: Option<String>,
id: Option<prov_graph::identity::Id>,
parked: Option<PathBuf>,
parked_body: Option<PathBuf>,
}
impl Record {
fn parse(value: &Value) -> Option<Self> {
let field = |key: &str| value.get(key).and_then(Value::as_str);
let path = |key: &str| field(key).map(PathBuf::from);
field("from")?;
Some(Self {
parent: path("parent"),
body: path("body").or_else(|| path("body_from")),
title: field("title").map(str::to_owned),
id: field("id").map(|s| prov_graph::identity::Id(s.to_string())),
parked: path("bin"),
parked_body: path("body_bin"),
})
}
}
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub(crate) async fn stage_deletion(
&self,
cs: &mut ChangeSet,
root: &Path,
deletion: &Deletion,
root_base: Option<String>,
) -> Result<Option<String>> {
let format = self.default_embed_format();
let ext = prov_graph::document::whole_file_extension(format);
let linked = self.deletions_pointer(root).await?;
let log = match &linked {
Some((path, _)) => path.clone(),
None => PathBuf::from("deletions").join(format!("index.{ext}")),
};
let present = linked.is_some() || self.exists(&log).await?;
let (read, mut records, title, part_of) = if present {
let (text, doc) = self.load(&log).await?;
let (records, title, part_of) = self.read_log(&log, &doc)?;
(Some(text), records, title, part_of)
} else {
(None, Vec::new(), LOG_TITLE.to_string(), None)
};
records.push(deletion.to_record());
match read {
Some(read) => {
cs.expect(&log, read);
}
None => {
cs.expect_absent(&log);
}
}
cs.write(
&log,
render_log(&title, part_of.as_deref(), records, format)?,
);
if linked.is_some() || root == deletion.from {
return Ok(None);
}
let base = match root_base {
Some(text) => text,
None => self.load(root).await?.0,
};
let root_doc = Document::parse(root, &base)?;
let relation = self
.relations()
.deletions_relation()
.ok_or_else(|| Error::Structure("no deletions relation configured".into()))?
.to_string();
let style = self.reference_style_for(&relation).path_style;
let pointer = link::path_text(style, root, &log);
Ok(Some(prov_store::edit::set_in_text(
&base,
root_doc.carrier,
&relation,
prov_store::edit::infer_scalar(&pointer),
)?))
}
pub async fn restore(&mut self, from: &Path, root_doc: &Path) -> Result<()> {
let from = link::normalize(from);
let (spanning, _) = self.spanning_pair()?;
let log = self
.deletions_path(root_doc)
.await?
.ok_or_else(|| Error::Structure("workspace has no deletion log".into()))?;
let (read, log_doc) = self.load(&log).await?;
let (records, log_title, part_of) = self.read_log(&log, &log_doc)?;
let from_str = from.to_string_lossy();
let pos = records
.iter()
.position(|r| r.get("from").and_then(Value::as_str) == Some(from_str.as_ref()))
.ok_or_else(|| {
Error::Structure(format!("{} is not in the deletion log", from.display()))
})?;
let record = Record::parse(&records[pos]).ok_or_else(|| {
Error::Structure(format!(
"the deletion record for {} has no `from` path",
from.display()
))
})?;
let title = record.title.unwrap_or_else(|| link::path_to_title(&from));
let parked = match &record.parked {
Some(parked) if self.exists(parked).await? => Some(parked.clone()),
_ => None,
};
match (&parked, self.exists(&from).await?) {
(Some(_), true) => {
return Err(Error::Structure(format!(
"{} already exists; cannot restore over it",
from.display()
)));
}
(None, false) => {
return Err(Error::Structure(format!(
"nothing is at {}, and prov did not keep its bytes — put the \
file back first (from version control or a backup), then \
restore to re-register its id and relink its parent",
from.display()
)));
}
_ => {}
}
let id = record.id;
if let Some(id) = &id {
if let Some(returned) = self.returned_id(&from, parked.as_deref()).await?
&& returned != *id
{
return Err(Error::Structure(format!(
"{} carries id {returned}, but the deletion record names {id} \
— restore it by hand, or remove the record",
from.display()
)));
}
if let Some(conflict) = self.registration_conflict(id, &from) {
return Err(conflict.into());
}
}
let mut remaining = records;
remaining.remove(pos);
let format = self.default_embed_format();
let log_text = render_log(&log_title, part_of.as_deref(), remaining, format)?;
let mut cs = self.change();
if let Some(id) = &id {
self.index_mut().register(id, &from);
}
cs.expect(&log, read);
if let Some(parked) = &parked {
cs.expect_absent(&from);
cs.rename(parked, &from);
if let (Some(body), Some(parked_body)) = (&record.body, &record.parked_body)
&& self.exists(parked_body).await?
{
cs.rename(parked_body, body);
}
}
cs.write(&log, log_text);
if let Some(parent) = &record.parent
&& self.exists(parent).await?
{
let (parent_text, parent_doc) = self.load(parent).await?;
let already = self
.relations()
.children(&fig::Value::from(&parent_doc.meta))
.iter()
.any(|t| self.resolve_link(parent, &Link::parse(t)) == Target::Path(from.clone()));
if !already {
let down = self
.authored_target(&spanning, parent, &from, &title, parked.is_none())
.await?;
let mut editor = MetaEditor::open_or_init(&parent_text, parent_doc.carrier)?;
let span_path = [Segment::Key(&spanning)];
if editor
.append_value(&span_path, fig::Value::Str(down.clone()))
.is_err()
{
editor.set_value(&span_path, fig::Value::Seq(vec![fig::Value::Str(down)]))?;
}
cs.write(parent.clone(), editor.render()?);
}
}
self.commit(cs).await
}
pub async fn clear_deletions(&mut self, root_doc: &Path) -> Result<usize> {
let log = self
.deletions_path(root_doc)
.await?
.ok_or_else(|| Error::Structure("workspace has no deletion log".into()))?;
let (read, log_doc) = self.load(&log).await?;
let (records, title, part_of) = self.read_log(&log, &log_doc)?;
let count = records.len();
let format = self.default_embed_format();
let log_text = render_log(&title, part_of.as_deref(), Vec::new(), format)?;
let mut cs = self.change();
cs.expect(&log, read);
for record in &records {
for key in ["bin", "body_bin"] {
if let Some(path) = record.get(key).and_then(Value::as_str) {
let parked = PathBuf::from(path);
if self.exists(&parked).await? {
cs.remove(parked);
}
}
}
}
cs.write(&log, log_text);
self.commit(cs).await?;
Ok(count)
}
fn read_log(
&self,
path: &Path,
doc: &Document,
) -> Result<(Vec<Value>, String, Option<String>)> {
if let Some(carrier) = doc.carrier {
prov_graph::document::require_whole_file(path, carrier)?;
}
let records = doc
.meta
.get("deleted")
.and_then(Value::as_sequence)
.map(<[Value]>::to_vec)
.unwrap_or_default();
Ok((records, title_of(doc), part_of_of(doc)))
}
async fn returned_id(
&self,
from: &Path,
parked: Option<&Path>,
) -> Result<Option<prov_graph::identity::Id>> {
let at = parked.unwrap_or(from);
let Ok((_, doc)) = self.load(at).await else {
return Ok(None);
};
Ok(doc
.meta
.get("id")
.and_then(Value::as_str)
.map(|s| prov_graph::identity::Id(s.to_string())))
}
}
fn title_of(doc: &Document) -> String {
doc.meta
.get("title")
.and_then(Value::as_str)
.unwrap_or(LOG_TITLE)
.to_string()
}
fn part_of_of(doc: &Document) -> Option<String> {
doc.meta
.get("part_of")
.and_then(Value::as_str)
.map(str::to_owned)
}
fn render_log(
title: &str,
part_of: Option<&str>,
records: Vec<Value>,
format: fig::Format,
) -> Result<String> {
let mut map = prov_graph::meta::Mapping::new();
map.insert("title".into(), Value::String(title.to_string()));
if let Some(part_of) = part_of {
map.insert("part_of".into(), Value::String(part_of.to_string()));
}
map.insert("deleted".into(), Value::Sequence(records));
prov_graph::meta::serialize_mapping(&map, format)
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::super::delete::Diagnosis;
use super::super::support::*;
use super::*;
use crate::validate::Finding;
use prov_graph::graph::LinkSite;
fn a_note(tag: &str) -> (PathBuf, &'static str) {
let dir = tempdir(tag);
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- note.md\n---\n",
);
let original = "---\ntitle: My Note\npart_of: index.md\n---\nbody text\n";
write(&dir, "note.md", original);
(dir, original)
}
#[test]
fn a_delete_destroys_the_file_and_records_what_it_destroyed() {
let (dir, _) = a_note("delete-records");
let danglers = block_on(ws(&dir).delete_with(
Path::new("note.md"),
false,
Some("2026-07-16T10:00:00Z"),
Diagnosis::Report,
))
.unwrap();
assert!(danglers.is_empty(), "{danglers:?}");
assert!(!dir.join("note.md").exists());
assert!(!dir.join("recyclebin").exists(), "nothing is parked");
let index = read(&dir, "index.md");
assert!(
!index.contains("- note.md"),
"parent entry removed: {index}"
);
assert!(index.contains("deletions"), "root links the log: {index}");
let log = read(&dir, "deletions/index.yaml");
assert!(log.contains("My Note"), "records the title: {log}");
assert!(log.contains("note.md"), "records the origin: {log}");
assert!(log.contains("index.md"), "records the parent: {log}");
assert!(log.contains("2026-07-16T10:00:00Z"), "records when: {log}");
let findings = block_on(ws(&dir).check(Path::new("index.md"))).unwrap();
assert!(
findings.is_empty(),
"a delete leaves check clean: {findings:?}"
);
}
#[test]
fn a_workspace_that_records_nothing_writes_no_log() {
let (dir, _) = a_note("delete-unrecorded");
let mut w = Workspace::builder(StdFs)
.root(&dir)
.record_deletions(false)
.build();
block_on(w.delete(Path::new("note.md"), false)).unwrap();
assert!(!dir.join("note.md").exists());
assert!(!dir.join("deletions").exists(), "no log was written");
let index = read(&dir, "index.md");
assert!(!index.contains("deletions"), "no pointer authored: {index}");
}
#[test]
fn the_bytes_come_back_from_elsewhere_and_restore_puts_the_graph_around_them() {
let (dir, original) = a_note("restore-roundtrip");
block_on(ws(&dir).delete(Path::new("note.md"), false)).unwrap();
assert!(!dir.join("note.md").exists());
write(&dir, "note.md", original);
block_on(ws(&dir).restore(Path::new("note.md"), Path::new("index.md"))).unwrap();
assert_eq!(read(&dir, "note.md"), original);
let index = read(&dir, "index.md");
assert!(index.contains("note.md"), "parent re-links it: {index}");
let log = read(&dir, "deletions/index.yaml");
assert!(!log.contains("My Note"), "the record is spent: {log}");
let findings = block_on(ws(&dir).check(Path::new("index.md"))).unwrap();
assert!(
findings.is_empty(),
"a restore leaves check clean: {findings:?}"
);
}
#[test]
fn restore_refuses_when_the_bytes_are_not_back_and_says_what_to_do() {
let (dir, _) = a_note("restore-no-bytes");
block_on(ws(&dir).delete(Path::new("note.md"), false)).unwrap();
let err =
block_on(ws(&dir).restore(Path::new("note.md"), Path::new("index.md"))).unwrap_err();
let text = err.to_string();
assert!(text.contains("nothing is at note.md"), "{text}");
assert!(text.contains("put the file back first"), "{text}");
assert!(read(&dir, "deletions/index.yaml").contains("My Note"));
}
#[test]
fn restore_refuses_a_file_whose_own_id_is_not_the_records() {
let dir = tempdir("restore-wrong-file");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- note.md\n---\n",
);
write(
&dir,
"note.md",
"---\ntitle: My Note\npart_of: index.md\nid: b7k2m\n---\nbody\n",
);
let mut w = id_ws(&dir);
w.index_mut().register(
&prov_graph::identity::Id("b7k2m".into()),
Path::new("note.md"),
);
block_on(w.delete(Path::new("note.md"), false)).unwrap();
write(
&dir,
"note.md",
"---\ntitle: Something Else\npart_of: index.md\nid: zzzzzzz\n---\n",
);
let err = block_on(w.restore(Path::new("note.md"), Path::new("index.md"))).unwrap_err();
let text = err.to_string();
assert!(text.contains("carries id zzzzzzz"), "{text}");
assert!(
text.contains("b7k2m"),
"names the id the record expects: {text}"
);
}
#[test]
fn restore_refuses_to_take_an_id_from_the_document_that_now_holds_it() {
let dir = tempdir("restore-id-collision");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- note.md\n- other.md\n---\n",
);
let original = "---\ntitle: My Note\npart_of: index.md\nid: b7k2m\n---\nbody\n";
write(&dir, "note.md", original);
write(
&dir,
"other.md",
"---\ntitle: Other\npart_of: index.md\nid: b7k2m\n---\n",
);
let mut w = id_ws(&dir);
let id = prov_graph::identity::Id("b7k2m".into());
w.index_mut().register(&id, Path::new("note.md"));
block_on(w.delete(Path::new("note.md"), false)).unwrap();
w.index_mut().register(&id, Path::new("other.md"));
write(&dir, "note.md", original);
let err = block_on(w.restore(Path::new("note.md"), Path::new("index.md"))).unwrap_err();
assert!(
matches!(
err,
Error::Collision(prov_graph::index::Collision::Id { .. })
),
"{err:?}"
);
assert!(
err.to_string().contains("other.md"),
"the message must name what holds the id: {err}"
);
assert!(!read(&dir, "index.md").contains("- note.md"));
w.index_mut().unregister(&id);
block_on(w.restore(Path::new("note.md"), Path::new("index.md"))).unwrap();
assert!(read(&dir, "index.md").contains("note.md"));
}
#[test]
fn restore_refuses_when_another_id_already_claims_the_path() {
let dir = tempdir("restore-path-collision");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- note.md\n---\n",
);
write(
&dir,
"note.md",
"---\ntitle: My Note\npart_of: index.md\nid: b7k2m\n---\nbody\n",
);
let mut w = id_ws(&dir);
w.index_mut().register(
&prov_graph::identity::Id("b7k2m".into()),
Path::new("note.md"),
);
block_on(w.delete(Path::new("note.md"), false)).unwrap();
w.index_mut().register(
&prov_graph::identity::Id("zzzzzzz".into()),
Path::new("note.md"),
);
write(
&dir,
"note.md",
"---\ntitle: My Note\npart_of: index.md\n---\n",
);
let err = block_on(w.restore(Path::new("note.md"), Path::new("index.md"))).unwrap_err();
assert!(
matches!(
err,
Error::Collision(prov_graph::index::Collision::Path { .. })
),
"{err:?}"
);
assert!(
!read(&dir, "index.md").contains("- note.md"),
"nothing relinked"
);
}
#[test]
fn a_second_deletion_appends_and_the_pointer_is_authored_once() {
let dir = tempdir("delete-append");
write(&dir, "index.md", "---\ncontents:\n- a.md\n- b.md\n---\n");
write(&dir, "a.md", "---\ntitle: Aye\npart_of: index.md\n---\n");
write(&dir, "b.md", "---\ntitle: Bee\npart_of: index.md\n---\n");
block_on(ws(&dir).delete(Path::new("a.md"), false)).unwrap();
block_on(ws(&dir).delete(Path::new("b.md"), false)).unwrap();
let log = read(&dir, "deletions/index.yaml");
assert!(
log.contains("Aye") && log.contains("Bee"),
"both recorded: {log}"
);
let index = read(&dir, "index.md");
assert_eq!(
index.matches("deletions:").count(),
1,
"pointer authored once: {index}"
);
let findings = block_on(ws(&dir).check(Path::new("index.md"))).unwrap();
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn deleting_a_parentless_document_records_it_against_the_real_root() {
let dir = tempdir("delete-parentless");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
write(&dir, "loose.md", "---\ntitle: Loose\n---\nno parent\n");
block_on(ws(&dir).delete(Path::new("loose.md"), false)).unwrap();
assert!(!dir.join("loose.md").exists(), "gone, and it stays gone");
assert!(read(&dir, "deletions/index.yaml").contains("Loose"));
let index = read(&dir, "index.md");
assert!(
index.contains("deletions:"),
"linked from the root: {index}"
);
}
#[test]
fn deleting_the_document_that_is_the_root_does_not_resurrect_it() {
let dir = tempdir("delete-is-root");
write(&dir, "solo.md", "---\ntitle: Solo\n---\nthe only one\n");
block_on(ws(&dir).delete(Path::new("solo.md"), false)).unwrap();
assert!(!dir.join("solo.md").exists(), "gone, and it stays gone");
assert!(read(&dir, "deletions/index.yaml").contains("Solo"));
}
#[test]
fn an_unlinked_log_is_adopted_rather_than_collided_with() {
let dir = tempdir("delete-adopt");
write(&dir, "solo.md", "---\ntitle: Solo\n---\n");
block_on(ws(&dir).delete(Path::new("solo.md"), false)).unwrap();
assert!(read(&dir, "deletions/index.yaml").contains("Solo"));
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- a.md\n---\n",
);
write(&dir, "a.md", "---\ntitle: Aye\npart_of: index.md\n---\n");
block_on(ws(&dir).delete(Path::new("a.md"), false)).unwrap();
let log = read(&dir, "deletions/index.yaml");
assert!(
log.contains("Solo") && log.contains("Aye"),
"both kept: {log}"
);
assert!(
read(&dir, "index.md").contains("deletions:"),
"and now linked"
);
}
#[test]
fn clear_deletions_forgets_the_records_but_keeps_the_log() {
let dir = tempdir("clear-deletions");
write(&dir, "index.md", "---\ncontents:\n- a.md\n---\n");
write(&dir, "a.md", "---\ntitle: Aye\npart_of: index.md\n---\n");
block_on(ws(&dir).delete(Path::new("a.md"), false)).unwrap();
assert_eq!(
block_on(ws(&dir).clear_deletions(Path::new("index.md"))).unwrap(),
1
);
let log = read(&dir, "deletions/index.yaml");
assert!(!log.contains("Aye"), "records cleared: {log}");
assert!(read(&dir, "index.md").contains("deletions"));
let findings = block_on(ws(&dir).check(Path::new("index.md"))).unwrap();
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn delete_refuses_a_separated_body_and_names_its_node() {
let dir = tempdir("delete-separated-body");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- b.yaml\n---\n",
);
write(
&dir,
"b.yaml",
"title: B\npart_of: index.md\ncontent: b.md\n",
);
write(&dir, "b.md", "B body.\n");
let err = block_on(ws(&dir).delete(Path::new("b.md"), false)).unwrap_err();
assert!(err.to_string().contains("is the body of b.yaml"), "{err}");
assert!(dir.join("b.md").exists(), "nothing was destroyed");
let danglers = block_on(ws(&dir).delete(Path::new("b.md"), true)).unwrap();
assert!(!dir.join("b.md").exists());
assert!(
danglers.iter().any(|f| matches!(f,
Finding::BrokenLink { doc, site: LinkSite::Relation(r), target }
if doc == &PathBuf::from("b.yaml") && r == "content" && target == "b.md")),
"{danglers:?}"
);
}
#[test]
fn a_skipped_diagnosis_still_records_everything_restore_needs() {
let dir = tempdir("delete-skip");
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- note.md\n- sub/linker.md\n---\n",
);
let original = "---\ntitle: Note\npart_of: index.md\n---\nbody\n";
write(&dir, "note.md", original);
write(
&dir,
"sub/linker.md",
"---\npart_of: /index.md\nlinks:\n- /note.md\n---\n",
);
let fs = crate::fs_faults::CountingFs::default();
let mut workspace = Workspace::builder(fs.clone()).root(&dir).build();
let danglers = block_on(workspace.delete_with(
Path::new("note.md"),
false,
Some("2026-08-18T00:00:00Z"),
Diagnosis::Skip,
))
.unwrap();
assert!(danglers.is_empty(), "{danglers:?}");
assert_eq!(
fs.doc_reads(&dir, "sub/linker.md"),
0,
"a skipped diagnosis still censused the workspace"
);
assert!(
!read(&dir, "index.md").contains("note.md"),
"parent entry removed"
);
write(&dir, "note.md", original);
block_on(workspace.restore(Path::new("note.md"), Path::new("index.md"))).unwrap();
assert!(read(&dir, "index.md").contains("note.md"), "re-linked");
}
#[test]
fn a_failed_delete_leaves_the_workspace_untouched() {
let (dir, _) = a_note("delete-atomic");
let before = snapshot(&dir);
let mut w = Workspace::builder(FailAtWrite::nth(0)).root(&dir).build();
let err = block_on(w.delete(Path::new("note.md"), false)).unwrap_err();
assert!(err.to_string().contains("disk full"), "{err}");
assert_eq!(snapshot(&dir), before, "a failed delete tore the workspace");
}
#[test]
fn a_legacy_bin_record_still_restores_by_moving_its_parked_bytes_home() {
let dir = tempdir("legacy-restore");
write(
&dir,
"index.md",
"---\ntitle: Home\nrecycle_bin: recyclebin/index.yaml\n---\n",
);
write(
&dir,
"recyclebin/index.yaml",
"title: Recycle Bin\ndeleted:\n- from: note.md\n title: My Note\n bin: recyclebin/items/note.md\n parent: index.md\n",
);
let original = "---\ntitle: My Note\npart_of: index.md\n---\nbody\n";
write(&dir, "recyclebin/items/note.md", original);
block_on(ws(&dir).restore(Path::new("note.md"), Path::new("index.md"))).unwrap();
assert_eq!(
read(&dir, "note.md"),
original,
"the parked bytes came home"
);
assert!(!dir.join("recyclebin/items/note.md").exists());
assert!(
read(&dir, "index.md").contains("note.md"),
"and it is relinked"
);
assert!(!read(&dir, "recyclebin/index.yaml").contains("My Note"));
}
#[test]
fn a_legacy_bin_is_emptied_by_clearing_its_deletions() {
let dir = tempdir("legacy-clear");
write(
&dir,
"index.md",
"---\ntitle: Home\nrecycle_bin: recyclebin/index.yaml\n---\n",
);
write(
&dir,
"recyclebin/index.yaml",
"title: Recycle Bin\ndeleted:\n- from: note.md\n title: My Note\n bin: recyclebin/items/note.md\n",
);
write(
&dir,
"recyclebin/items/note.md",
"---\ntitle: My Note\n---\n",
);
assert_eq!(
block_on(ws(&dir).clear_deletions(Path::new("index.md"))).unwrap(),
1
);
assert!(
!dir.join("recyclebin/items/note.md").exists(),
"bytes purged"
);
assert!(!read(&dir, "recyclebin/index.yaml").contains("My Note"));
}
}