prov 0.3.2

A self-describing plaintext workspace: structure lives in documents' own embedded metadata.
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
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
//! Attachments — giving an *arbitrary* file its own workspace-linked metadata.
//!
//! A prov document carries its structure in embedded frontmatter, but a
//! binary — an image, a PDF, a font — cannot. The fix reuses the **separated**
//! document shape (`EmbedStyle::Separate`): a whole-file metadata *sidecar*
//! joined to a body file by a `content` attribute. An attachment is that same
//! pattern with the body relaxed from prose to bytes — the sidecar
//! `photo.jpg.yaml` holds `title`/`id`/relations and points `content` at
//! `photo.jpg`, which prov links, moves, and validates but never *reads*.
//!
//! This is the sidecar prov's philosophy welcomes, not the one it rejects:
//! a co-located, visible, self-describing document any tool can open — the exact
//! opposite of an app-private `.obsidian/`-style folder (`lib.rs`).
//!
//! Three operations:
//! - [`attach`](Workspace::attach) — mint a sidecar for a loose file and link it
//!   under a parent (the attachment analogue of [`create`](Workspace::create)).
//! - [`attachment_for`](Workspace::attachment_for) — the reverse lookup: given a
//!   payload, find its sidecar by the `<file>.<ext>` convention, confirmed by the
//!   authoritative `content` pointer.
//! - [`loose_attachments`](Workspace::loose_attachments) — every opaque file with
//!   no sidecar yet, the work-list an importer walks.
//!
//! Move and delete need no new code: a sidecar is a separated node, so
//! [`rename`](Workspace::rename) already relocates the payload beside it (keeping
//! `content` correct) and [`delete`](Workspace::delete) removes the pair.

use std::collections::BTreeSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;

use fig::Segment;

use crate::document::{is_opaque_payload, whole_file_extension};
use crate::edit::MetaEditor;
use crate::error::{Error, Result};
use crate::fs::Storage;
use crate::identity::{IdentityPolicy, Trigger};
use crate::index::IndexStore;
use crate::link;
use crate::meta::{Mapping, Value};
use crate::workspace::Workspace;

/// The whole-file metadata extensions a sidecar can use, in reverse-lookup
/// preference order. The `<payload>.<ext>` naming convention (`photo.jpg` →
/// `photo.jpg.yaml`) keeps the full payload name, so `a.png` and `a.txt` get
/// distinct sidecars instead of colliding on `a.yaml`. An extension whose format
/// feature is not compiled simply never matches (its sidecar fails to parse as a
/// whole-file document), so the list is safe to keep static.
const SIDECAR_EXTENSIONS: &[&str] = &["yaml", "yml", "json", "toml", "fig", "figl"];

/// The sidecar path for `payload` in metadata `format`: the payload's full name
/// plus the format's whole-file extension, as a sibling (`sub/a.pdf` →
/// `sub/a.pdf.yaml`), so the sidecar's `content` pointer is just the basename.
fn sidecar_path(payload: &Path, format: fig::Format) -> PathBuf {
    let ext = whole_file_extension(format);
    let name = payload
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or_default();
    payload.with_file_name(format!("{name}.{ext}"))
}

impl<FS: Storage, Id, Ix: IndexStore> Workspace<FS, Id, Ix> {
    /// The metadata sidecar for the attachment `payload`, or `None` when it has
    /// none. Probes the `<payload>.<ext>` convention for each whole-file metadata
    /// extension and confirms the candidate's `content` actually resolves back to
    /// `payload` — the convention is the fast path, the `content` pointer is
    /// authoritative, so a sidecar under a non-conventional name is still found by
    /// [`loose_attachments`] treating the payload as unattached only when no
    /// pointer claims it. (Here we accept the convention's hits; a bespoke layout
    /// is the caller's to track.)
    pub async fn attachment_for(&self, payload: &Path) -> Result<Option<PathBuf>> {
        let payload = link::normalize(payload);
        let Some(name) = payload.file_name().and_then(|n| n.to_str()) else {
            return Ok(None);
        };
        for ext in SIDECAR_EXTENSIONS {
            let candidate = payload.with_file_name(format!("{name}.{ext}"));
            if !self.fs().try_exists(&self.root().join(&candidate)).await? {
                continue;
            }
            let (_, doc) = self.load(&candidate).await?;
            if let Some(content) = doc.content_attr() {
                let dir = candidate.parent().unwrap_or(Path::new(""));
                if link::normalize(dir.join(content)) == payload {
                    return Ok(Some(candidate));
                }
            }
        }
        Ok(None)
    }

    /// Every opaque file under the root that has no sidecar yet — the *recursive*
    /// population, the whole tree. A flat filesystem scan (hidden entries
    /// skipped), independent of link resolution, like the title/id/content scans
    /// beside it. Sidecars and prose documents are text prov reads, so they
    /// are not payloads and never appear here.
    ///
    /// This is the `--recursive` escape hatch for `attach --all`; the bounded
    /// [`loose_attachments_in`](Self::loose_attachments_in) is the safer default.
    pub async fn loose_attachments(&self) -> Result<Vec<PathBuf>> {
        let mut found = Vec::new();
        self.scan_loose(PathBuf::new(), &mut found).await?;
        found.sort();
        Ok(found)
    }

    /// Loose opaque files (no sidecar yet) in the directories the workspace
    /// already reaches from `start` — **reachability-bounded** discovery, the
    /// default for `attach --all`. Unreached directories are never scanned, so
    /// `attach --all` in a project root sweeps only the folders the workspace
    /// occupies, not a vendored subtree or a nested prov workspace. The
    /// counterpart to the bounded orphan check (DESIGN §8).
    pub async fn loose_attachments_in(&self, start: &Path) -> Result<Vec<PathBuf>> {
        // The reachable set: `start` plus every path a census link resolves to.
        let mut reachable: BTreeSet<PathBuf> = BTreeSet::new();
        reachable.insert(link::normalize(start));
        for entry in self.census(start).await? {
            if let Some(p) = entry.resolution.resolved_path() {
                reachable.insert(p.clone());
            }
        }
        let reached_dirs = Self::reached_dirs(&reachable);
        let mut found = Vec::new();
        for file in self.direct_child_files(&reached_dirs).await? {
            if is_opaque_payload(&file) && self.attachment_for(&file).await?.is_none() {
                found.push(file);
            }
        }
        found.sort();
        Ok(found)
    }

    /// Recursively collect opaque files lacking a sidecar under `rel_dir`. Same
    /// walk shape as the content/id scans; unreadable and hidden entries skipped.
    fn scan_loose<'a>(
        &'a self,
        rel_dir: PathBuf,
        out: &'a mut Vec<PathBuf>,
    ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
        Box::pin(async move {
            let Ok(entries) = self.fs().read_dir(&self.root().join(&rel_dir)).await else {
                return Ok(());
            };
            for entry in entries {
                let Some(name) = entry
                    .file_name()
                    .and_then(|n| n.to_str())
                    .map(str::to_owned)
                else {
                    continue;
                };
                if name.starts_with('.') {
                    continue;
                }
                let rel = if rel_dir.as_os_str().is_empty() {
                    PathBuf::from(&name)
                } else {
                    rel_dir.join(&name)
                };
                if entry.file_type().is_dir() {
                    self.scan_loose(rel, out).await?;
                } else if entry.file_type().is_file()
                    && is_opaque_payload(&rel)
                    && self.attachment_for(&rel).await?.is_none()
                {
                    out.push(rel);
                }
            }
            Ok(())
        })
    }
}

impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
    /// Attach the opaque file `payload` as a spanning child of `parent`: mint a
    /// whole-file metadata **sidecar** beside it (`photo.jpg` → `photo.jpg.yaml`)
    /// carrying `title`, the inverse link back to `parent`, a `content` pointer at
    /// the payload, and an `attachment: true` marker; and add the sidecar (never
    /// the payload) to `parent`'s spanning field. If the identity policy registers
    /// on create, the sidecar is assigned a stable ID. Returns the sidecar's path.
    ///
    /// The payload is the structural analogue of a separated document's prose
    /// body, so it is *not* rewritten, read, or required to be text — only its
    /// existence is checked. Refuses a payload prov can read as a document
    /// (that is [`adopt`](Workspace::adopt)'s job: it can hold its own
    /// frontmatter) and refuses when a sidecar already exists (query
    /// [`attachment_for`](Workspace::attachment_for) first for idempotency).
    pub async fn attach(&mut self, payload: &Path, parent: &Path) -> Result<PathBuf> {
        self.attach_titled(payload, parent, None).await
    }

    /// [`attach`](Self::attach) with an explicit sidecar title (else the payload's
    /// titleized stem). Authoring the title here keeps the parent's spanning-entry
    /// *label* in step with it, exactly as [`create_titled`](Self::create_titled).
    pub(crate) async fn attach_titled(
        &mut self,
        payload: &Path,
        parent: &Path,
        title_override: Option<&str>,
    ) -> Result<PathBuf> {
        let payload = link::normalize(payload);
        let parent = link::normalize(parent);

        if !self.fs().try_exists(&self.root().join(&payload)).await? {
            return Err(Error::NotFound(payload.to_path_buf()));
        }
        // An attachment shadows *external* content. A file prov can read is a
        // document that should carry its own metadata — adopt it, don't sidecar it.
        if !is_opaque_payload(&payload) {
            return Err(Error::Structure(format!(
                "{} is a prov document, not an opaque attachment — use `adopt`",
                payload.display()
            )));
        }

        let (spanning, inverse) = self.spanning_pair()?;
        let format = self.default_embed_format();
        let node = sidecar_path(&payload, format);
        if self.fs().try_exists(&self.root().join(&node)).await? {
            return Err(Error::AlreadyExists(node.to_path_buf()));
        }

        let (parent_text, parent_doc) = self.load(&parent).await?;
        let title = title_override
            .map(str::to_owned)
            .unwrap_or_else(|| link::path_to_title(&payload));
        let parent_title = parent_doc
            .meta
            .get("title")
            .and_then(Value::as_str)
            .map(str::to_owned)
            .unwrap_or_else(|| link::path_to_title(&parent));

        // Opens before the first id-authoring call below, so the index
        // checkpoint covers the registrations those make (see `mutate::create`).
        let mut cs = self.change();

        // The sidecar's inverse link up (the parent exists → an id link registers
        // it by path) and the parent's spanning entry down (the sidecar is not on
        // disk yet → mint its id directly rather than register-by-path).
        let up = self
            .authored_target(&inverse, &node, &parent, &parent_title, true)
            .await?;
        let down = self
            .authored_target(&spanning, &parent, &node, &title, false)
            .await?;

        // The sidecar: a whole-file mapping pointing `content` at the payload
        // (a sibling, so just its name) and flagged as an attachment.
        let payload_ref = payload
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or_default()
            .to_string();
        let mut map = Mapping::new();
        map.insert("title".into(), Value::String(title));
        map.insert(inverse.clone(), Value::String(up));
        map.insert("content".into(), Value::String(payload_ref));
        map.insert("attachment".into(), Value::Bool(true));
        // Fixity: record a checksum of the payload's bytes, so `check` can later
        // detect bit-rot. Unambiguous for an attachment — its bytes are never
        // edited — so it is recorded whenever the workspace covers payloads, with
        // no per-file opt-in. The payload is read once here, at attach time.
        if self.fixity().covers_payloads() {
            let bytes = self.fs().read(&self.root().join(&payload)).await?;
            map.insert(
                "content_hash".into(),
                Value::String(crate::fixity::digest(&bytes)),
            );
        }
        let node_text = crate::meta::serialize_mapping(&map, format)?;

        // The parent: append the sidecar to its spanning field (creating it if
        // absent — `append` needs an existing sequence).
        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)]))?;
        }
        let parent_out = parent_editor.render()?;

        cs.write(&node, node_text);
        cs.write(&parent, parent_out);

        // Identity hook — eager policies assign an ID from birth (idempotent: an
        // id-linked sidecar was already registered above).
        if self.identity().registration().fires_on(Trigger::Create)
            && self.index().id_for_path(&node).is_none()
        {
            let id = self.mint_unique(&node);
            self.index_mut().register(&id, &node);
        }
        self.commit(cs).await?;
        Ok(node)
    }
}

#[cfg(all(test, feature = "yaml"))]
mod tests {
    use super::*;
    use crate::exec::block_on;
    use crate::fs::StdFs;
    use crate::validate::Finding;

    fn write(dir: &Path, rel: &str, bytes: &[u8]) {
        let p = dir.join(rel);
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
        std::fs::write(p, bytes).unwrap();
    }

    fn read(dir: &Path, rel: &str) -> String {
        std::fs::read_to_string(dir.join(rel)).unwrap()
    }

    fn tempdir(tag: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("prov-attach-{tag}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    fn ws(dir: &Path) -> Workspace<StdFs> {
        Workspace::builder(StdFs).root(dir).build()
    }

    #[test]
    fn attach_gives_a_binary_a_linked_metadata_sidecar() {
        let dir = tempdir("basic");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        // A binary payload: bytes prov must never try to read as text.
        write(&dir, "photo.jpg", &[0xff, 0xd8, 0xff, 0xe0, 0x00]);

        let node =
            block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();
        // The sidecar keeps the full payload name (no `a.jpg`/`a.png` collision).
        assert_eq!(node, PathBuf::from("photo.jpg.yaml"));

        let sidecar = read(&dir, "photo.jpg.yaml");
        assert!(sidecar.contains("title: Photo"), "{sidecar}");
        assert!(
            sidecar.contains("content: photo.jpg"),
            "points at the payload: {sidecar}"
        );
        assert!(
            sidecar.contains("attachment: true"),
            "flagged as an attachment: {sidecar}"
        );
        assert!(
            sidecar.contains("index.md"),
            "inverse link up to the parent: {sidecar}"
        );

        // The parent links the sidecar (the node), never the raw payload.
        let index = read(&dir, "index.md");
        assert!(index.contains("photo.jpg.yaml"), "{index}");
        assert!(
            !index.contains("[photo.jpg]") && !index.contains("(photo.jpg)"),
            "{index}"
        );

        // The payload is untouched, and the whole workspace validates — the
        // `content` pointer resolves, and the opaque payload is neither read nor
        // treated as an orphan.
        assert_eq!(
            std::fs::read(dir.join("photo.jpg")).unwrap(),
            [0xff, 0xd8, 0xff, 0xe0, 0x00]
        );
        assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
    }

    #[test]
    fn attach_records_a_payload_checksum_that_check_verifies() {
        // Fixity default (payloads): the sidecar carries a sha256 of the bytes,
        // and a clean workspace verifies without a finding.
        let dir = tempdir("fixity-record");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        let payload: &[u8] = &[0xff, 0xd8, 0xff, 0xe0, 0x01, 0x02, 0x03];
        write(&dir, "photo.jpg", payload);

        block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();

        let sidecar = read(&dir, "photo.jpg.yaml");
        let expected = crate::fixity::digest(payload);
        assert!(
            sidecar.contains(&format!("content_hash: {expected}")),
            "{sidecar}"
        );
        assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
    }

    #[test]
    fn check_catches_a_corrupted_payload() {
        // The archival payoff: bit-rot no link check would ever see.
        let dir = tempdir("fixity-rot");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "photo.jpg", &[0xff, 0xd8, 0xff, 0xe0, 0x01]);
        block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();

        // A bit rots — the payload's bytes change out from under its checksum.
        write(&dir, "photo.jpg", &[0xff, 0xd8, 0xff, 0xe0, 0x99]);

        let findings = block_on(ws(&dir).check("index.md")).unwrap();
        assert!(
            findings.iter().any(|f| matches!(
                f,
                Finding::FixityMismatch { doc, .. } if doc == Path::new("photo.jpg.yaml")
            )),
            "expected a fixity mismatch, got: {findings:?}"
        );
    }

    #[test]
    fn restamping_accepts_a_changed_payload_and_clears_the_finding() {
        // The pressure-release valve: an intended change is re-blessed by
        // re-stamping, and the workspace validates again.
        let dir = tempdir("fixity-restamp");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "photo.jpg", &[0x01, 0x02, 0x03]);
        block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();

        write(&dir, "photo.jpg", &[0x04, 0x05, 0x06]); // an intended re-export

        let mut w = ws(&dir);
        let finding = block_on(w.check("index.md"))
            .unwrap()
            .into_iter()
            .find(|f| matches!(f, Finding::FixityMismatch { .. }))
            .expect("a mismatch to re-stamp");
        let fix = block_on(w.suggest_fix(&finding))
            .unwrap()
            .expect("a re-stamp fix");
        block_on(w.apply_fix(&fix)).unwrap();

        // Re-blessed: the recorded hash now matches the new bytes.
        assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
        assert!(read(&dir, "photo.jpg.yaml").contains(&crate::fixity::digest(&[0x04, 0x05, 0x06])));
    }

    #[test]
    fn fixity_off_records_no_checksum() {
        let dir = tempdir("fixity-off");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "photo.jpg", &[0x01, 0x02, 0x03]);

        let w = || {
            Workspace::builder(StdFs)
                .root(&dir)
                .fixity(crate::config::Fixity::Off)
                .build()
        };
        block_on(w().attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();

        assert!(
            !read(&dir, "photo.jpg.yaml").contains("content_hash"),
            "off records nothing"
        );
        assert_eq!(block_on(w().check("index.md")).unwrap(), vec![]);
    }

    #[test]
    fn attachment_for_finds_the_sidecar_and_refuses_a_document_payload() {
        let dir = tempdir("lookup");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "assets/logo.png", &[0x89, 0x50, 0x4e, 0x47]);

        assert!(
            block_on(ws(&dir).attachment_for(Path::new("assets/logo.png")))
                .unwrap()
                .is_none()
        );
        block_on(ws(&dir).attach(Path::new("assets/logo.png"), Path::new("index.md"))).unwrap();
        assert_eq!(
            block_on(ws(&dir).attachment_for(Path::new("assets/logo.png"))).unwrap(),
            Some(PathBuf::from("assets/logo.png.yaml"))
        );

        // A readable document is not an attachment — adopt it instead.
        write(&dir, "note.md", b"---\ntitle: Note\n---\nbody\n");
        let err =
            block_on(ws(&dir).attach(Path::new("note.md"), Path::new("index.md"))).unwrap_err();
        assert!(
            err.to_string().contains("not an opaque attachment"),
            "{err}"
        );
    }

    #[test]
    fn loose_attachments_lists_only_unsidecarred_binaries() {
        let dir = tempdir("loose");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "a.pdf", b"%PDF-1.7\n");
        write(&dir, "sub/b.png", &[0x89, 0x50]);
        // A prose document is not a payload; it should never appear.
        write(&dir, "sub/note.md", b"---\ntitle: Note\n---\n");

        let mut loose = block_on(ws(&dir).loose_attachments()).unwrap();
        loose.sort();
        assert_eq!(
            loose,
            vec![PathBuf::from("a.pdf"), PathBuf::from("sub/b.png")]
        );

        // Attaching one drops it from the loose set (its sidecar now claims it).
        block_on(ws(&dir).attach(Path::new("a.pdf"), Path::new("index.md"))).unwrap();
        assert_eq!(
            block_on(ws(&dir).loose_attachments()).unwrap(),
            vec![PathBuf::from("sub/b.png")]
        );
    }

    #[test]
    fn renaming_a_sidecar_moves_its_payload_and_keeps_content_correct() {
        // A sidecar is a separated node, so the existing move machinery relocates
        // the payload beside it and repoints `content` — no attachment-specific code.
        let dir = tempdir("rename");
        write(&dir, "index.md", b"---\ntitle: Home\n---\n");
        write(&dir, "photo.jpg", &[0xff, 0xd8]);
        block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();

        block_on(ws(&dir).rename(
            Path::new("photo.jpg.yaml"),
            Path::new("media/hero.jpg.yaml"),
        ))
        .unwrap();

        assert!(
            dir.join("media/hero.jpg").exists(),
            "payload moved beside the sidecar"
        );
        assert!(!dir.join("photo.jpg").exists(), "old payload gone");
        assert!(
            read(&dir, "media/hero.jpg.yaml").contains("content: hero.jpg"),
            "content repointed"
        );
        assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
    }
}