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
//! `duplicate` — a document copied as a fresh sibling under the same parent.
//!
//! A *shallow* copy, and deliberately so: no cloned identity (an ID names one
//! document), no cloned children (link-shaped containment would leave each child
//! claimed by two parents), and a name bumped until both the node and — for a
//! separated pair — its body file are free.
use std::path::{Path, PathBuf};
use fig::Segment;
use crate::identity::{IdentityPolicy, Trigger};
use crate::workspace::Workspace;
use prov_graph::error::{Error, Result};
use prov_graph::link;
use prov_store::edit::MetaEditor;
use prov_store::fs::Storage;
use prov_store::index::IndexStore;
use super::maintain::{body_sibling, content_target};
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
/// Duplicate the document at `source` as a fresh sibling under the same
/// parent, returning the new file's path. The copy carries `source`'s title,
/// body, and metadata verbatim, with three deliberate exceptions:
///
/// - **No cloned identity.** A stamped frontmatter `id` (frontmatter storage,
/// DESIGN §5) is dropped — an ID names *one* document, never two — so the
/// copy is unregistered until something links or publishes it. When the
/// workspace authors id links, attaching the copy below mints it a *new* ID.
/// - **No cloned children.** The spanning field is dropped, so the copy is a
/// childless duplicate of just this node. Deep-copying the subtree would
/// leave every child claimed by two parents (its inverse still names
/// `source`); prov's link-shaped containment — unlike diaryx's
/// directory-shaped copy, which recurses the folder — makes the shallow
/// copy the only unambiguous one.
/// - **A unique name.** `foo.md` → `foo-copy.md`, then `foo-copy-2.md`, ….
///
/// The copy inherits `source`'s parent: its inverse link (copied verbatim, and
/// still valid — the copy sits in the same directory) already points there,
/// and the parent's spanning field gains the copy — the same bidirectional
/// link `create`/`adopt` author. A `source` with no parent (the spanning root,
/// or an orphan) is copied without attaching. A **separated** node (a
/// whole-file node with a `content` pointer) duplicates its body file too, and
/// the copy points at *its own* body.
pub async fn duplicate(&mut self, source: &Path) -> Result<PathBuf> {
let source = link::normalize(source);
if !self.exists(&source).await? {
return Err(Error::NotFound(source.to_path_buf()));
}
let (source_text, doc) = self.load(&source).await?;
// A manifest node has no meaningful copy. Sharing the manifest would put
// two nodes' checksums over one record store; copying it would put two
// records over one archive, free to drift apart with no rule about which
// is right — the very state `attach --manifest` refuses to create. The
// archive is the archive, and describing it twice describes nothing.
if doc.is_manifest_node() {
return Err(Error::Structure(format!(
"{} covers a directory with a manifest, which has no copy — \
`attach --manifest` covers a different directory",
source.display()
)));
}
let meta = fig::Value::from(&doc.meta);
let (spanning, inverse) = self.spanning_pair()?;
// A separated node carries its prose/payload in a sibling file; the copy
// needs its own. Resolve the source body up front so the unique-name
// search can keep the node *and* its body collision-free together.
let body_from = content_target(&doc, &source);
let (dest, body_dest) = self.unique_copy_path(&source, body_from.as_deref()).await?;
// The copy's text: `source`'s metadata and body verbatim, minus the cloned
// `id` (identity is per-document) and the spanning field (no cloned
// children), and — for a separated node — repointed `content` at its own
// new body file. A carrier-less file (pure prose, not a linked node) is
// copied byte-for-byte.
let copy_text = if let Some(carrier) = doc.carrier {
let mut editor = MetaEditor::open(&source_text, carrier)?;
let _ = editor.delete(&[Segment::Key("id")]);
let _ = editor.delete(&[Segment::Key(&spanning)]);
if let Some((_, new_ref)) = &body_dest {
editor
.replace_value(&[Segment::Key("content")], fig::Value::Str(new_ref.clone()))?;
}
editor.render()?
} else {
source_text.clone()
};
// The parent gains a spanning entry for the copy (going "down"). The copy
// is not yet on disk, so `authored_target` mints its id directly rather
// than register-by-path — exactly as `create`'s down-link does. The copy's
// own inverse link "up" is already present (copied verbatim) and resolves
// unchanged, so it is not re-authored. A parentless source just skips this.
let parent = self.single_target(&doc, &inverse, &source);
let mut cs = self.change();
let parent_write = if let Some(parent) = &parent {
let (parent_text, parent_doc) = self.load(parent).await?;
let copy_title = meta
.get("title")
.and_then(fig::Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| link::path_to_title(&dest));
let down = self
.authored_target(&spanning, parent, &dest, ©_title, false)
.await?;
let mut parent_editor = MetaEditor::open_or_init(&parent_text, parent_doc.carrier)?;
let span_path = [Segment::Key(&spanning)];
if parent_editor
.append_value(&span_path, fig::Value::Str(down.clone()))
.is_err()
{
parent_editor
.set_value(&span_path, fig::Value::Seq(vec![fig::Value::Str(down)]))?;
}
Some((parent.clone(), parent_editor.render()?))
} else {
None
};
// All edits computed; stage them. Node first, then its body copy (opaque
// payload bytes carried verbatim), then the parent's updated entry.
cs.write(&dest, copy_text);
if let (Some(body_from), Some((body_to, _))) = (&body_from, &body_dest) {
let bytes = self.read_bytes(body_from).await?;
cs.write(body_to, bytes);
}
if let Some((parent, text)) = parent_write {
cs.write(parent, text);
}
// Identity hook — an eager policy assigns the copy an ID from birth
// (idempotent: the down-link above already registered it under `id_links`).
if self.identity().registration().fires_on(Trigger::Create)
&& self.index().id_for_path(&dest).is_none()
{
let id = self.mint_unique(&dest);
self.index_mut().register(&id, &dest);
}
self.commit(cs).await?;
Ok(dest)
}
/// The first free `stem-copy[-N].ext` beside `source`, paired with its body
/// file's destination when `source` is a separated node (`body_from` is its
/// current body). The suffix is bumped until *both* the node and its body are
/// free, so a duplicated pair never half-collides with an existing one.
async fn unique_copy_path(
&self,
source: &Path,
body_from: Option<&Path>,
) -> Result<(PathBuf, Option<(PathBuf, String)>)> {
let stem = source.file_stem().and_then(|s| s.to_str()).ok_or_else(|| {
Error::Structure(format!("{} has no filename to copy", source.display()))
})?;
let ext = source.extension().and_then(|e| e.to_str());
for n in 1.. {
let suffix = if n == 1 {
"-copy".to_string()
} else {
format!("-copy-{n}")
};
let name = match ext {
Some(ext) => format!("{stem}{suffix}.{ext}"),
None => format!("{stem}{suffix}"),
};
let node = match source.parent() {
Some(dir) => dir.join(name),
None => PathBuf::from(name),
};
let body = body_from.map(|b| body_sibling(&node, b));
let node_free = !self.exists(&node).await?;
let body_free = match &body {
Some((body_to, _)) => !self.exists(body_to).await?,
None => true,
};
if node_free && body_free {
return Ok((node, body));
}
}
unreachable!("the copy-suffix search is unbounded")
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::super::support::*;
use super::*;
#[test]
fn duplicate_copies_under_the_same_parent_and_links_both_ways() {
let dir = tempdir("duplicate");
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- a.md\n---\n",
);
write(
&dir,
"a.md",
"---\ntitle: A\npart_of: index.md\n---\nA body, copied.\n",
);
let copy = block_on(ws(&dir).duplicate(Path::new("a.md"))).unwrap();
assert_eq!(copy, PathBuf::from("a-copy.md"));
// The copy carries the source's title and body verbatim, and keeps its
// inverse link up (same directory → unchanged).
let copied = read(&dir, "a-copy.md");
assert!(copied.contains("title: A"), "{copied}");
assert!(copied.contains("part_of: index.md"), "{copied}");
assert!(copied.contains("A body, copied."), "{copied}");
// The parent gained a spanning entry for the copy, keeping the original.
let index = read(&dir, "index.md");
assert!(index.contains("- a.md"), "original kept: {index}");
assert!(index.contains("a-copy.md"), "copy attached: {index}");
// The whole workspace validates — both directions are sound.
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn duplicate_bumps_the_suffix_and_drops_cloned_children() {
// A container with a child, duplicated: the copy is childless (no double
// parent), and a second duplicate takes the next free `-copy-N` name.
let dir = tempdir("duplicate-suffix");
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- mid.md\n---\n",
);
write(
&dir,
"mid.md",
"---\ntitle: Mid\npart_of: index.md\ncontents:\n- leaf.md\n---\n",
);
write(&dir, "leaf.md", "---\ntitle: Leaf\npart_of: mid.md\n---\n");
let first = block_on(ws(&dir).duplicate(Path::new("mid.md"))).unwrap();
assert_eq!(first, PathBuf::from("mid-copy.md"));
// The copy did not clone the child — no contested containment for leaf.md.
let copy = read(&dir, "mid-copy.md");
assert!(
!copy.contains("leaf.md"),
"children must not be cloned: {copy}"
);
assert!(copy.contains("part_of: index.md"), "{copy}");
// A second duplicate of the same source bumps past the taken name.
let second = block_on(ws(&dir).duplicate(Path::new("mid.md"))).unwrap();
assert_eq!(second, PathBuf::from("mid-copy-2.md"));
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn duplicate_does_not_clone_a_registered_id() {
// Frontmatter-stamped identity must not travel to the copy: an ID names
// exactly one document. The copy is a distinct path with (here, lazy) no
// id of its own until something links it.
let dir = tempdir("duplicate-id");
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- a.md\n---\n",
);
write(
&dir,
"a.md",
"---\ntitle: A\npart_of: index.md\nid: aaaaaaa\n---\nbody\n",
);
let copy = block_on(id_ws(&dir).duplicate(Path::new("a.md"))).unwrap();
let copied = read(&dir, ©.to_string_lossy());
assert!(
!copied.contains("aaaaaaa"),
"the source's id must not be cloned: {copied}"
);
assert!(
!copied.contains("\nid:"),
"the copy carries no stamped id: {copied}"
);
}
#[test]
fn duplicate_a_separated_node_copies_its_body_and_repoints_content() {
// A separated node (whole-file metadata + a `content` body pointer): the
// copy is its own pair, pointing at its own body, not the source's.
let dir = tempdir("duplicate-separate");
write(&dir, "index.yaml", "title: Root\ncontents:\n- notes.yaml\n");
write(
&dir,
"notes.yaml",
"title: Notes\npart_of: index.yaml\ncontent: notes.md\n",
);
write(&dir, "notes.md", "Prose body, duplicated.\n");
let copy = block_on(ws(&dir).duplicate(Path::new("notes.yaml"))).unwrap();
assert_eq!(copy, PathBuf::from("notes-copy.yaml"));
// The copy node points at its own body; the body file is a real copy.
let node = read(&dir, "notes-copy.yaml");
assert!(
node.contains("content: notes-copy.md"),
"repointed content: {node}"
);
assert_eq!(read(&dir, "notes-copy.md"), "Prose body, duplicated.\n");
// Source body untouched, and the workspace validates.
assert_eq!(read(&dir, "notes.md"), "Prose body, duplicated.\n");
assert_eq!(block_on(ws(&dir).check("index.yaml")).unwrap(), vec![]);
}
}