1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
//! `delete` — the hard delete, and the only one that destroys bytes outright.
//!
//! The parent's spanning entry goes with the document; every *other* inbound
//! reference is reported rather than rewritten, because a link records intent
//! and there is no new target to send it to. [`recycle`](super::recycle) is the
//! recoverable counterpart, and the CLI's default.
use std::path::{Path, PathBuf};
use fig::Segment;
use crate::identity::IdentityPolicy;
use crate::validate::Finding;
use crate::workspace::Workspace;
use prov_graph::error::{Error, Result};
use prov_graph::graph::{LinkSite, Resolution};
use prov_graph::link::{self, Link};
use prov_store::edit::MetaEditor;
use prov_store::fs::Storage;
use prov_store::index::IndexStore;
use super::maintain::paired_file;
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
/// Delete the document at `path`, removing the parent's spanning entry for
/// it. Refuses when the document has spanning children (they would be
/// orphaned) unless `force` is set. A registered ID is retired — with a
/// tombstoning store it is never reissued, so dangling references stay
/// diagnosable.
///
/// Also refuses the *body* half of a separated document unless forced — the
/// prose file some other node reaches through its `content` pointer. Naming
/// it is almost always a mistake for the node beside it: the node is what
/// carries the id, the links and the title, and deleting its body leaves it
/// pointing at nothing. The error names the node to delete instead. Under
/// `force` the delete proceeds and the orphaned `content` pointer is
/// reported like any other dangler. (Deleting the *node* takes its body with
/// it, below — the pair is handled in both directions.)
///
/// Returns the inbound references *left* dangling by the delete: every
/// other document's overlay link or body wikilink that resolved to `path`
/// (as [`Finding::BrokenLink`]), plus any `colophon:<id>` reference now
/// pointing at the tombstone (as [`Finding::DanglingId`]), plus the
/// `content` pointer of a node whose body was forced away. The parent's
/// spanning entry is *not* reported — it is removed here — and a delete that
/// nothing pointed at returns an empty list. Unlike `rename`, these are not
/// rewritten: a link records intent, and there is no new target to send it
/// to; the caller decides what to do with the diagnosis.
pub async fn delete(&mut self, path: &Path, force: bool) -> Result<Vec<Finding>> {
let path = link::normalize(path);
let (spanning, inverse) = self.spanning_pair()?;
let (_, doc) = self.load(&path).await?;
let children: Vec<String> = self
.relations()
.children(&fig::Value::from(&doc.meta))
.iter()
.map(|raw| Link::parse(raw).target)
.collect();
if !children.is_empty() && !force {
return Err(Error::Structure(format!(
"{} contains {} document(s) ({}); delete them first or force",
path.display(),
children.len(),
children.join(", ")
)));
}
let parent = self.single_target(&doc, &inverse, &path);
let root = self.spanning_root(&path, &inverse).await?;
// The body half of a separated pair: refuse, naming the node instead.
let owner = self.content_owner(&path).await?;
if let Some(owner) = &owner
&& !force
{
return Err(Error::Structure(format!(
"{} is the body of {}; delete that instead, or force to destroy \
the body and leave {} pointing at nothing",
path.display(),
owner.display(),
owner.display()
)));
}
// Diagnose inbound references that will dangle: census the tree and keep
// every link resolving to `path`, except the parent's spanning entry
// (removed below) and any self-reference in the doomed document itself.
let mut danglers: Vec<Finding> = self
.census(&root)
.await?
.into_iter()
.filter(|e| e.resolution.resolved_path() == Some(&path))
.filter(|e| {
e.source != path
&& !(Some(&e.source) == parent.as_ref()
&& matches!(&e.site, LinkSite::Relation(r) if *r == spanning))
})
.map(|e| match e.resolution {
Resolution::Id { id, .. } => Finding::DanglingId {
doc: e.source,
site: e.site,
id,
tombstoned: true,
},
_ => Finding::BrokenLink {
doc: e.source,
site: e.site,
target: e.target_text,
},
})
.collect();
// A forced body delete strands its node's `content` pointer. The census
// above cannot report it — `content` is not a relation and not a body
// link — so it is added here rather than left for `check` to discover,
// which is what makes this verb's promise ("returns the inbound
// references left dangling") true of the separated shape too.
if let Some(owner) = owner {
let target = self
.load(&owner)
.await
.ok()
.and_then(|(_, doc)| doc.content_attr().map(str::to_string))
.unwrap_or_else(|| path.to_string_lossy().into_owned());
danglers.push(Finding::BrokenLink {
doc: owner,
site: LinkSite::Relation("content".to_string()),
target,
});
}
let mut parent_write: Option<(PathBuf, String)> = None;
if let Some(parent) = &parent {
let (parent_text, parent_doc) = self.load(parent).await?;
if let (Some(index), Some(carrier)) = (
self.entry_index(&parent_doc, &spanning, parent, &path),
parent_doc.carrier,
) {
let mut editor = MetaEditor::open(&parent_text, carrier)?;
editor.remove_item(&[Segment::Key(&spanning)], index)?;
parent_write = Some((parent.clone(), editor.render()?));
}
}
// The file that travels with the node — a separated body, an attachment
// payload, or a manifest — is deleted with it.
//
// The directory a manifest *covers* is not touched. A body is the node's
// content and goes where it goes; a manifest is a description of files
// that exist on their own terms, so deleting the description must not
// delete ten thousand photographs. What is left behind is an uncovered
// directory, which is exactly what it was before anything described it.
let body_file = paired_file(&doc, &path);
let body_exists = match &body_file {
Some(body) => self.exists(body).await?,
None => false,
};
let mut cs = self.change();
cs.remove(&path);
if let (Some(body), true) = (&body_file, body_exists) {
cs.remove(body);
}
if let Some((parent, text)) = parent_write {
cs.write(parent, text);
}
// Identity hook — retire the ID (a tombstoning store keeps it known
// forever, so it is never minted again to mean something else).
if let Some(id) = self.index().id_for_path(&path) {
self.index_mut().unregister(&id);
}
self.commit(cs).await?;
Ok(danglers)
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::super::support::*;
use super::*;
use crate::identity::Trigger;
use prov_graph::document::Document;
use prov_graph::index::IdIndex;
#[test]
fn delete_refuses_children_then_forces() {
let dir = tempdir("delete");
write(&dir, "index.md", "---\ncontents:\n- a.md\n- b.md\n---\n");
write(
&dir,
"a.md",
"---\npart_of: index.md\ncontents:\n- b.md\n---\n",
);
write(&dir, "b.md", "---\npart_of: index.md\n---\n");
let err = block_on(ws(&dir).delete(Path::new("a.md"), false)).unwrap_err();
assert!(err.to_string().contains("contains 1 document"), "{err}");
block_on(ws(&dir).delete(Path::new("a.md"), true)).unwrap();
assert!(!dir.join("a.md").exists());
let index = read(&dir, "index.md");
assert!(!index.contains("a.md"), "parent entry removed: {index}");
assert!(index.contains("- b.md"), "sibling kept: {index}");
}
#[test]
fn delete_diagnoses_inbound_references_left_dangling() {
// A sibling links the doomed document two ways (overlay `links` + a body
// wikilink). Delete removes the parent's spanning entry silently, but
// reports the sibling's references it cannot rewrite — there is no new
// target to send them to.
let dir = tempdir("delete-inbound");
write(&dir, "index.md", "---\ncontents:\n- a.md\n- b.md\n---\n");
write(&dir, "a.md", "---\npart_of: index.md\n---\n");
write(
&dir,
"b.md",
"---\npart_of: index.md\nlinks:\n- a.md\n---\nSee [[a.md]].\n",
);
let danglers = block_on(ws(&dir).delete(Path::new("a.md"), false)).unwrap();
assert_eq!(danglers.len(), 2, "{danglers:?}");
assert!(
danglers.iter().any(|f| matches!(f,
Finding::BrokenLink { doc, site: LinkSite::Relation(r), target }
if doc == &PathBuf::from("b.md") && r == "links" && target == "a.md")),
"{danglers:?}"
);
assert!(
danglers.iter().any(|f| matches!(f,
Finding::BrokenLink { doc, site: LinkSite::Body(_), target }
if doc == &PathBuf::from("b.md") && target == "a.md")),
"{danglers:?}"
);
// The parent's spanning entry was removed, not reported.
assert!(
!read(&dir, "index.md").contains("a.md"),
"parent entry cleaned"
);
}
#[test]
fn delete_refuses_a_separated_body_and_names_its_node() {
// The pair, handled in both directions. Deleting the *node* takes its
// body with it (proven elsewhere in this file); naming the *body* is
// almost always a mistake for the node beside it, so it is refused with
// the node named — the node is what carries the id, the links and the
// title, and it is what the user meant.
//
// Found by the generated sequences in `super::properties`, which reached
// `separate` then `delete` on the resulting body in two operations.
let dir = tempdir("delete-separated-body");
write(&dir, "index.md", "---\ncontents:\n- b.yaml\n---\n");
write(&dir, "b.yaml", "part_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(), "and nothing was destroyed");
// Forced, it proceeds — and says what it stranded, rather than leaving
// `check` to discover it later.
let danglers = block_on(ws(&dir).delete(Path::new("b.md"), true)).unwrap();
assert!(!dir.join("b.md").exists(), "the body is gone");
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:?}"
);
// And what the verb reported is exactly what `check` goes on to find.
let findings = block_on(ws(&dir).check("index.md")).unwrap();
for reported in &danglers {
assert!(
findings.contains(reported),
"{reported:?} not in {findings:?}"
);
}
}
#[test]
fn delete_tombstones_and_check_diagnoses_the_dangler() {
let dir = tempdir("id-delete");
write(&dir, "index.md", "---\ncontents:\n- a.md\n---\n");
write(&dir, "a.md", "---\npart_of: index.md\n---\n");
let mut w = id_ws(&dir);
let id = block_on(w.register(Path::new("a.md"), Trigger::Link)).unwrap();
let text = read(&dir, "index.md");
let carrier = Document::parse("index.md", &text).unwrap().carrier;
let updated = prov_store::edit::set_in_text(
&text,
carrier,
"contents.0",
fig::Value::Str(link::id_target(&id)),
)
.unwrap();
std::fs::write(dir.join("index.md"), &updated).unwrap();
block_on(w.delete(Path::new("a.md"), false)).unwrap();
// Deleting removed the parent's entry too (matched through the registry
// before the tombstone landed)… so re-add a dangling reference by hand
// to simulate the out-of-band case.
let text = read(&dir, "index.md");
let carrier = Document::parse("index.md", &text).unwrap().carrier;
let updated = prov_store::edit::set_in_text(
&text,
carrier,
"contents",
fig::Value::Str(link::id_target(&id)),
)
.unwrap();
std::fs::write(dir.join("index.md"), &updated).unwrap();
assert!(w.index().resolve(&id).is_none(), "tombstoned");
assert!(w.index().is_known(&id), "but never forgotten");
let findings = block_on(w.check("index.md")).unwrap();
assert!(
findings.iter().any(|f| matches!(
f,
crate::validate::Finding::DanglingId {
tombstoned: true,
..
}
)),
"{findings:?}"
);
}
#[test]
fn a_failed_delete_restores_the_document_it_removed() {
let dir = linked_tree("atomic-delete");
let before = snapshot(&dir);
// `delete` removes the file, then rewrites the parent's entry. Failing
// that write must bring the document back, not leave the parent pointing
// at a hole.
let mut w = failing_ws(&dir, 0);
let err = block_on(w.delete(Path::new("a.md"), true)).unwrap_err();
assert!(err.to_string().contains("disk full"), "{err}");
assert_eq!(snapshot(&dir), before, "a failed delete lost a document");
}
}