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
//! `separate` and `combine` — the two halves of a document's embedding shape.
//!
//! A combined document is one file (prose under a frontmatter block); a
//! separated one is two (a whole-file metadata node pointing at a body file
//! through `content`). Both verbs move the *structural* document from one path
//! to the other, so both retarget every inbound link and carry the registered
//! id across — the same maintenance [`rename`](super::rename) does, for a move
//! the caller never spells as one.
use std::path::{Path, PathBuf};
use fig::Segment;
use crate::identity::IdentityPolicy;
use crate::workspace::Workspace;
use prov_graph::document::MetaCarrier;
use prov_graph::error::{Error, Result};
use prov_graph::link;
use prov_graph::meta::Value;
use prov_store::edit::MetaEditor;
use prov_store::fs::Storage;
use prov_store::index::IndexStore;
use super::maintain::content_target;
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
/// Split the combined document at `path` into two linked plain-text files: a
/// whole-file **metadata** document (in the document's own frontmatter
/// format) that becomes the structural node, and a **body** file holding its
/// prose, joined by a `content` attribute on the metadata file. Every inbound
/// link to the document is retargeted to the new metadata file, and a
/// registered ID follows it. Returns the metadata file's path. The inverse of
/// [`combine`](Workspace::combine).
pub async fn separate(&mut self, path: &Path) -> Result<PathBuf> {
// Retargeting inbound links costs a census plus a load of every source
// it turns up — see `rename`, which shares the collector.
let _scope = self.read_scope();
let path = link::normalize(path);
if !self.exists(&path).await? {
return Err(Error::NotFound(path.to_path_buf()));
}
let (_, doc) = self.load(&path).await?;
let Some(MetaCarrier::Fenced(kind)) = doc.carrier else {
return Err(Error::Structure(format!(
"{} is not a combined document (nothing to separate)",
path.display()
)));
};
if doc.content_attr().is_some() {
return Err(Error::Structure(format!(
"{} is already separated",
path.display()
)));
}
let Some(mapping) = doc.meta.as_mapping() else {
return Err(Error::Structure(format!(
"{} has no metadata to separate",
path.display()
)));
};
let format = kind.inner_format();
let meta_path = path.with_extension(prov_graph::document::whole_file_extension(format));
if meta_path == path {
return Err(Error::Structure(format!(
"{} already has a metadata-file extension",
path.display()
)));
}
if self.exists(&meta_path).await? {
return Err(Error::AlreadyExists(meta_path.to_path_buf()));
}
// `meta_path` is only an extension swap of `path` — derived, not freshly
// minted, so it is not provably free of a registration nobody has a file
// for (the on-disk check above cannot see one). Same guard as `rename`.
let moving_id = self.index().id_for_path(&path);
if let Some(id) = &moving_id
&& let Some(conflict) = self.move_conflict(id, &meta_path)
{
return Err(conflict.into());
}
let body_ref = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| Error::Structure(format!("{} has no filename", path.display())))?
.to_string();
// The metadata file = the document's mapping + a `content` pointer at the
// body file (a sibling, so just its name).
let mut map = mapping.clone();
map.insert("content".into(), Value::String(body_ref));
let meta_text = prov_graph::meta::serialize_mapping(&map, format)?;
let body_text = doc.body.clone();
let mut cs = self.change();
// Inbound links now point at the metadata file (the structural node).
let inbound = self.collect_inbound_rewrites(&path, &meta_path).await?;
cs.write(&meta_path, meta_text);
cs.write(&path, body_text);
for (source, text) in inbound {
cs.write(source, text);
}
if let Some(id) = moving_id {
self.index_mut().set_path(&id, &meta_path);
}
self.commit(cs).await?;
Ok(meta_path)
}
/// Fold the separated document whose metadata file is `path` back into one
/// combined file: the body file regains its metadata as frontmatter (in the
/// metadata file's format), the metadata file is removed, and inbound links
/// are retargeted to the combined file. Returns the combined file's path. The
/// inverse of [`separate`](Workspace::separate).
pub async fn combine(&mut self, path: &Path) -> Result<PathBuf> {
// As in `separate`: retargeting the inbound links costs a census plus a
// load of every source it turns up.
let _scope = self.read_scope();
let path = link::normalize(path);
let (_, doc) = self.load(&path).await?;
let Some(content) = content_target(&doc, &path) else {
return Err(Error::Structure(format!(
"{} is not a separated document (no `content` attribute)",
path.display()
)));
};
let Some(MetaCarrier::WholeFile(format)) = doc.carrier else {
return Err(Error::Structure(format!(
"{} is not a whole-file metadata document",
path.display()
)));
};
let Some(mapping) = doc.meta.as_mapping() else {
return Err(Error::Structure(format!(
"{} has no metadata",
path.display()
)));
};
if !self.exists(&content).await? {
return Err(Error::Structure(format!(
"{}'s content file {} is missing",
path.display(),
content.display()
)));
}
// Unlike `meta_path` in `separate`, `content` already has a file behind
// it — but that is no proof the *registry* agrees it is free: a stray
// frontmatter (tolerated below) can carry its own `id`, distinct from
// `path`'s. Same guard as `rename`/`separate`, before the merge is built.
let moving_id = self.index().id_for_path(&path);
if let Some(id) = &moving_id
&& let Some(conflict) = self.move_conflict(id, &content)
{
return Err(conflict.into());
}
let (body_raw, body_doc) = self.load(&content).await?;
// Normally the body file is pure prose; tolerate a stray frontmatter.
let body = match body_doc.carrier {
Some(_) => body_doc.body,
None => body_raw,
};
// Rebuild the combined document: a fresh frontmatter block (the metadata
// format) carrying every key except `content`, then the body.
let carrier = prov_graph::document::frontmatter_carrier(format);
let mut editor = MetaEditor::open_or_init(&body, Some(carrier))?;
for (key, value) in mapping {
if key.as_str() == "content" {
continue;
}
editor.set_value(&[Segment::Key(key)], fig::Value::from(value))?;
}
let combined = editor.render()?;
let mut cs = self.change();
// Inbound links point back at the (now combined) content file.
let inbound = self.collect_inbound_rewrites(&path, &content).await?;
cs.write(&content, combined);
cs.remove(&path);
for (source, text) in inbound {
cs.write(source, text);
}
if let Some(id) = moving_id {
self.index_mut().set_path(&id, &content);
}
self.commit(cs).await?;
Ok(content)
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::super::support::*;
use super::*;
use prov_graph::index::IdIndex;
#[test]
fn separate_refuses_to_take_a_path_the_registry_binds_to_a_different_id() {
// `separate`'s metadata-file path is only an extension swap of the
// combined document's own path — derived, not freshly minted, and no
// more provably free of a live foreign registration than `rename`'s
// destination is.
let dir = tempdir("separate-path-collision");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- doc.md\n---\n",
);
write(
&dir,
"doc.md",
"---\ntitle: Doc\npart_of: index.md\n---\nbody prose\n",
);
let mut w = id_ws(&dir);
let a = prov_graph::identity::Id("aaaaaaa".into());
let b = prov_graph::identity::Id("bbbbbbb".into());
w.index_mut().register(&a, Path::new("doc.md"));
// The metadata file `separate` would create is already bound to a
// different id, though nothing has ever put a file there.
w.index_mut().register(&b, Path::new("doc.yaml"));
let err = block_on(w.separate(Path::new("doc.md"))).unwrap_err();
assert!(
matches!(
err,
Error::Collision(prov_graph::index::Collision::Path { .. })
),
"{err:?}"
);
assert!(err.to_string().contains("doc.yaml"), "{err}");
assert!(dir.join("doc.md").exists());
assert!(!dir.join("doc.yaml").exists());
assert_eq!(w.index().resolve(&a), Some(PathBuf::from("doc.md")));
assert_eq!(w.index().resolve(&b), Some(PathBuf::from("doc.yaml")));
}
#[test]
fn combine_refuses_to_take_a_path_the_registry_binds_to_a_different_id() {
// Unlike `separate`'s destination, `combine`'s (the body file) already
// has a file behind it — but that is no proof the *registry* agrees it
// is free: a stray frontmatter can carry its own `id`, distinct from the
// node's. Guarded the same way, before the merge is built.
let dir = tempdir("combine-path-collision");
write(
&dir,
"index.md",
"---\ntitle: Home\ncontents:\n- doc.yaml\n---\n",
);
write(
&dir,
"doc.yaml",
"title: Doc\npart_of: index.md\ncontent: doc.md\n",
);
write(&dir, "doc.md", "the prose\n");
let mut w = id_ws(&dir);
let a = prov_graph::identity::Id("aaaaaaa".into());
let b = prov_graph::identity::Id("bbbbbbb".into());
w.index_mut().register(&a, Path::new("doc.yaml"));
// The body file already carries a registration of its own, under a
// different id than the node being folded into it.
w.index_mut().register(&b, Path::new("doc.md"));
let err = block_on(w.combine(Path::new("doc.yaml"))).unwrap_err();
assert!(
matches!(
err,
Error::Collision(prov_graph::index::Collision::Path { .. })
),
"{err:?}"
);
assert!(err.to_string().contains("doc.md"), "{err}");
assert!(dir.join("doc.yaml").exists());
assert_eq!(read(&dir, "doc.md"), "the prose\n", "unmerged, untouched");
assert_eq!(w.index().resolve(&a), Some(PathBuf::from("doc.yaml")));
assert_eq!(w.index().resolve(&b), Some(PathBuf::from("doc.md")));
}
}