Skip to main content

prov_store/
edit.rs

1//! Format-preserving edits to a document's metadata, whatever carries it.
2//!
3//! [`MetaEditor`] dispatches on the document's [`MetaCarrier`]: a fenced block
4//! is edited with fig's [`fig::Embed`] (fences and body untouched), a config
5//! document with fig's [`fig::Editor`] (the whole file *is* the metadata).
6//! Either way the edit is comment-preserving and byte-minimal — only the
7//! changed node's bytes move — and the original carrier and format are never
8//! rewritten into another.
9//!
10//! The workspace mutation ops build on this; the free functions are the
11//! single-document surface (the CLI's `set`/`unset`).
12
13use fig::{Embed, EmbedType, Segment};
14
15use prov_graph::document::MetaCarrier;
16use prov_graph::meta::Mapping;
17use prov_graph::{Error, Result};
18
19/// The frontmatter archetype used to synthesize a metadata block for a document
20/// that has none. YAML (`---`) is the convention when compiled in; otherwise the
21/// first other format that is. Exactly one arm survives `cfg` stripping, and the
22/// `compile_error!` in `lib.rs` guarantees at least one does.
23fn default_embed_type() -> EmbedType {
24    #[cfg(feature = "yaml")]
25    return EmbedType::FrontmatterYaml;
26    #[cfg(all(not(feature = "yaml"), feature = "json"))]
27    return EmbedType::FrontmatterJson;
28    #[cfg(all(not(feature = "yaml"), not(feature = "json"), feature = "toml"))]
29    return EmbedType::PlusToml;
30    #[cfg(all(
31        not(feature = "yaml"),
32        not(feature = "json"),
33        not(feature = "toml"),
34        feature = "fig-lang"
35    ))]
36    return EmbedType::FrontmatterFig;
37}
38
39/// A comment-preserving editor over a document's metadata, generic over where
40/// the metadata lives.
41pub enum MetaEditor {
42    /// Editing a fenced block inside a host file.
43    Fenced(Embed),
44    /// Editing a config document (the whole file is the metadata).
45    Whole(fig::Editor),
46}
47
48impl MetaEditor {
49    /// Open an editor over `text` for an existing carrier.
50    pub fn open(text: &str, carrier: MetaCarrier) -> Result<Self> {
51        Ok(match carrier {
52            MetaCarrier::Fenced(kind) => MetaEditor::Fenced(Embed::open(text.as_bytes(), kind)?),
53            MetaCarrier::WholeFile(format) => {
54                MetaEditor::Whole(fig::Editor::open(text.as_bytes(), format)?)
55            }
56        })
57    }
58
59    /// Open an editor over `text`, creating the metadata block when the
60    /// document has none: an explicit carrier is honored (an absent fenced
61    /// block is synthesized in place), and `None` defaults to a fresh
62    /// frontmatter block in `default_embed_type`'s archetype (`---` YAML
63    /// when that feature is compiled in).
64    pub fn open_or_init(text: &str, carrier: Option<MetaCarrier>) -> Result<Self> {
65        Ok(match carrier {
66            Some(MetaCarrier::WholeFile(format)) => {
67                MetaEditor::Whole(fig::Editor::open(text.as_bytes(), format)?)
68            }
69            Some(MetaCarrier::Fenced(kind)) => {
70                MetaEditor::Fenced(Embed::open_or_init(text.as_bytes(), kind)?)
71            }
72            None => MetaEditor::Fenced(Embed::open_or_init(text.as_bytes(), default_embed_type())?),
73        })
74    }
75
76    /// Upsert `value` at `path` (the trailing segment must be a key).
77    pub fn set_value(&mut self, path: &[Segment], value: impl Into<fig::Value>) -> Result<()> {
78        match self {
79            MetaEditor::Fenced(e) => e.set_value(path, value)?,
80            MetaEditor::Whole(e) => e.set_value(path, value)?,
81        }
82        Ok(())
83    }
84
85    /// Replace the existing value at `path`.
86    pub fn replace_value(&mut self, path: &[Segment], value: impl Into<fig::Value>) -> Result<()> {
87        match self {
88            MetaEditor::Fenced(e) => e.replace_value(path, value)?,
89            MetaEditor::Whole(e) => e.replace_value(path, value)?,
90        }
91        Ok(())
92    }
93
94    /// Rename the key at `path`, keeping its value, position, and comments.
95    pub fn replace_key(&mut self, path: &[Segment], key: &str) -> Result<()> {
96        match self {
97            MetaEditor::Fenced(e) => e.replace_key(path, key)?,
98            MetaEditor::Whole(e) => e.replace_key(path, key)?,
99        }
100        Ok(())
101    }
102
103    /// Append `value` to the sequence at `path`.
104    pub fn append_value(&mut self, path: &[Segment], value: impl Into<fig::Value>) -> Result<()> {
105        match self {
106            MetaEditor::Fenced(e) => e.append_value(path, value)?,
107            MetaEditor::Whole(e) => e.append_value(path, value)?,
108        }
109        Ok(())
110    }
111
112    /// Delete the mapping entry at `path`.
113    pub fn delete(&mut self, path: &[Segment]) -> Result<()> {
114        match self {
115            MetaEditor::Fenced(e) => e.delete(path)?,
116            MetaEditor::Whole(e) => e.delete(path)?,
117        }
118        Ok(())
119    }
120
121    /// Remove the item at `index` from the sequence at `path`.
122    pub fn remove_item(&mut self, path: &[Segment], index: usize) -> Result<()> {
123        match self {
124            MetaEditor::Fenced(e) => e.remove_item(path, index)?,
125            MetaEditor::Whole(e) => e.remove_item(path, index)?,
126        }
127        Ok(())
128    }
129
130    /// Reorder the mapping entries at `path` (empty path = root) so `keys`
131    /// come first, in that order; entries not listed keep their original
132    /// relative order and follow. Unknown keys are ignored. Every entry keeps
133    /// its comments and interleaved trivia.
134    pub fn reorder_keys<S: AsRef<str>>(&mut self, path: &[Segment], keys: &[S]) -> Result<()> {
135        match self {
136            MetaEditor::Fenced(e) => e.reorder_keys(path, keys)?,
137            MetaEditor::Whole(e) => e.reorder_keys(path, keys)?,
138        }
139        Ok(())
140    }
141
142    /// Reorder the sequence at `path` so the items at `indices` (positions in
143    /// the current order) come first, in that order; items not listed keep
144    /// their original relative order and follow. Out-of-range indices are
145    /// ignored.
146    pub fn reorder_items(&mut self, path: &[Segment], indices: &[usize]) -> Result<()> {
147        match self {
148            MetaEditor::Fenced(e) => e.reorder_items(path, indices)?,
149            MetaEditor::Whole(e) => e.reorder_items(path, indices)?,
150        }
151        Ok(())
152    }
153
154    /// Render the full document text with the edits applied.
155    pub fn render(&mut self) -> Result<String> {
156        Ok(match self {
157            MetaEditor::Fenced(e) => e.render()?.to_string(),
158            MetaEditor::Whole(e) => e.source()?.to_string(),
159        })
160    }
161}
162
163/// Parse a dotted key path (`a.b.0.c`) into fig path segments. An all-digit
164/// segment indexes a sequence; anything else names a mapping key.
165pub fn key_path(dotted: &str) -> Vec<Segment<'_>> {
166    dotted
167        .split('.')
168        .map(|part| match part.parse::<usize>() {
169            Ok(index) => Segment::Index(index),
170            Err(_) => Segment::Key(part),
171        })
172        .collect()
173}
174
175/// Interpret a CLI-provided scalar: `true`/`false`, integers, floats, and
176/// `null` become their typed values; everything else stays a string.
177///
178/// A number carrying a **redundant leading zero** (`08`, `007`, `-04`) is not a
179/// number here, because typing it does not round-trip: `08` parses to `8` and
180/// the padding is gone for good, with no CLI spelling that gets it back. Padding
181/// is meaningful in precisely the data this tool holds — a month `08`, a day
182/// `04`, an all-digit id — so its presence is the evidence that the author meant
183/// text. A lone `0` and a fraction's `0.5` are canonical and still type.
184///
185/// This is the general form of a hazard callers have been dodging one at a time:
186/// [`identity`](prov_graph::identity) warns that an id may be all digits, and
187/// prov-cli stamps ids as explicit string scalars to route around this function.
188/// Those call sites are still right to be explicit — a value that must *never*
189/// be typed should not depend on inference — but the trap is no longer here.
190pub fn infer_scalar(s: &str) -> fig::Value {
191    match s {
192        "true" => fig::Value::Bool(true),
193        "false" => fig::Value::Bool(false),
194        "null" | "~" => fig::Value::Null,
195        _ if is_zero_padded(s) => fig::Value::Str(s.to_string()),
196        _ => {
197            if let Ok(i) = s.parse::<i64>() {
198                fig::Value::Int(i)
199            } else if let Ok(f) = s.parse::<f64>() {
200                fig::Value::Float(f)
201            } else {
202                fig::Value::Str(s.to_string())
203            }
204        }
205    }
206}
207
208/// Whether `s` opens with a zero a numeric parse would silently discard — `08`,
209/// `-007`, `00`. The test is "zero followed by another digit", so the `0` of `0`
210/// and of `0.5` (information, not padding) is not caught.
211fn is_zero_padded(s: &str) -> bool {
212    let mut digits = s.strip_prefix(['-', '+']).unwrap_or(s).chars();
213    digits.next() == Some('0') && digits.next().is_some_and(|c| c.is_ascii_digit())
214}
215
216/// Upsert `dotted` to `value` in `text`'s metadata (carrier-aware), creating
217/// a YAML frontmatter block when the document has none. Returns the full
218/// re-rendered document text.
219pub fn set_in_text(
220    text: &str,
221    carrier: Option<MetaCarrier>,
222    dotted: &str,
223    value: fig::Value,
224) -> Result<String> {
225    let mut editor = MetaEditor::open_or_init(text, carrier)?;
226    let path = key_path(dotted);
227    match path.last() {
228        // fig's `set` upserts a trailing *key*; an index-terminated path is a
229        // pure replacement (there is no "insert at absent index" to upsert).
230        Some(Segment::Index(_)) => editor.replace_value(&path, value)?,
231        _ => editor.set_value(&path, value)?,
232    }
233    editor.render()
234}
235
236/// Upsert `dotted` to a full [`Value`](prov_graph::meta::Value) — the mapping-valued
237/// counterpart to [`set_in_text`], which takes only a `fig::Value` scalar. Lets a
238/// caller set a whole nested block (e.g. the root's `prov:` policy block) without
239/// naming `fig`, converting through the crate's `Value → fig::Value` bridge.
240///
241/// # How a mapping is written
242///
243/// One splice, whatever the shape. fig renders a mapping as block YAML and
244/// creates a path's missing ancestors as block containers, so a nested set lands
245/// readable — the layout, comments and key order around it preserved — even when
246/// nothing along the path existed before.
247///
248/// It was not always one splice. Through fig 2.5.2 a block value spliced into
249/// auto-created ancestors came out corrupt *and reported success*: `a: {b: - x}`,
250/// which re-reads as a string rather than a list. So this function used to write
251/// the value, re-parse the document, compare the value's **kind** at the path,
252/// and on a mismatch fall back to writing one scalar leaf at a time, then prune
253/// whatever keys the old subtree had and the new one did not.
254///
255/// fig 2.5.3 removed the need for all of it: ancestors are seeded as block, and
256/// a splice that cannot be satisfied — into an existing *flow* container — is an
257/// `Err` instead of a quiet rewrite. There is no shape the leaf-by-leaf path can
258/// write that the direct one cannot, so the fallback had nothing left to catch,
259/// and the pruning went with it because a direct set replaces the node whole.
260///
261/// The write is an **upsert of the whole subtree**: keys the document carries
262/// under `dotted` that `value` does not are removed, so replacing a mapping
263/// replaces it rather than merging into what was there before.
264pub fn set_meta_in_text(
265    text: &str,
266    carrier: Option<MetaCarrier>,
267    dotted: &str,
268    value: &prov_graph::meta::Value,
269) -> Result<String> {
270    set_in_text(text, carrier, dotted, fig::Value::from(value))
271}
272
273/// The value at a dotted path in `meta`, or `None`. Mapping keys only — a
274/// sequence index along the way reads as absent.
275#[cfg(test)]
276fn value_at<'a>(
277    meta: &'a prov_graph::meta::Value,
278    dotted: &str,
279) -> Option<&'a prov_graph::meta::Value> {
280    let mut current = meta;
281    for part in dotted.split('.') {
282        current = current.as_mapping()?.get(part)?;
283    }
284    Some(current)
285}
286
287/// Delete the entry at `dotted` from `text`'s metadata (carrier-aware).
288/// Returns the full re-rendered document text. Errors when the document has
289/// no metadata or the path does not exist.
290pub fn unset_in_text(text: &str, carrier: Option<MetaCarrier>, dotted: &str) -> Result<String> {
291    let carrier = carrier
292        .ok_or_else(|| Error::Structure("document has no embedded metadata block".into()))?;
293    let mut editor = MetaEditor::open(text, carrier)?;
294    editor.delete(&key_path(dotted))?;
295    editor.render()
296}
297
298/// Re-emit `mapping` as a fresh metadata block of archetype `target`, placed in
299/// `target`'s canonical position around the plain `body` (before it for
300/// frontmatter, after it for endmatter) — the reconstruction a *format
301/// conversion* performs. Unlike the comment-preserving edits above, this
302/// deliberately rebuilds the block: a conversion crosses formats (a YAML comment
303/// has no JSON home), so only the values survive.
304///
305/// The content is rendered by prov's canonical [`serialize_mapping`] — the
306/// same serializer behind `prov meta --format`, so a converted block's
307/// sequence and scalar layout matches the rest of the codebase (fig's per-key
308/// [`Embed`] splice path renders some formats, notably fig sequences,
309/// differently). The block's fences and placement come from fig, by synthesizing
310/// an empty `target` block around `body` and splicing the serialized content into
311/// its content slot.
312///
313/// The content is spliced verbatim — the same bytes prov's reader
314/// ([`Document::parse`](prov_graph::Document::parse)) hands back to the format
315/// parser, which does not HTML-decode a `<pre><code>` island. Writing what that
316/// reader expects keeps a converted value round-tripping through
317/// `prov get`/`check` rather than acquiring stray `&lt;` entities.
318///
319/// [`serialize_mapping`]: prov_graph::meta::serialize_mapping
320pub fn reformat_block(body: &str, mapping: &Mapping, target: EmbedType) -> Result<String> {
321    // Synthesize an empty `target` block in its canonical place around `body`,
322    // then fill it: fig owns the fences and the placement, we own what goes
323    // between them. Filling is a same-archetype `retype`, which fig documents as
324    // a byte-identical rebuild carrying new content — so the fence bytes come
325    // back exactly as `render` laid them down.
326    let rendered = Embed::open_or_init(body.as_bytes(), target)?
327        .render()?
328        .to_string();
329    retype_block(&rendered, target, mapping, target)
330}
331
332/// Re-house the existing `from` block in `text` as a `target` one carrying
333/// `mapping` — the conversion [`reformat_block`] performs for a document that
334/// has no block yet, for one that does.
335///
336/// Every host byte outside the block survives, **on both sides**, in file order.
337/// That is the difference from rebuilding around a body slice: a document whose
338/// block sits mid-file — an HTML `<script>` island below a `<head>` — has host
339/// text above and below it, and reconstructing from one side silently discarded
340/// the other. fig 3.3's `retype` owns the whole operation, so the placement
341/// rules (a block moves only when the target archetype belongs at the other end
342/// of the file; mid-document to edge is refused outright) are fig's to state
343/// rather than something prov re-derives by arithmetic on spans.
344pub fn retype_block(
345    text: &str,
346    from: EmbedType,
347    mapping: &Mapping,
348    target: EmbedType,
349) -> Result<String> {
350    let mut inner = prov_graph::meta::serialize_mapping(mapping, target.inner_format())?;
351    // The content slot sits between the opening fence's trailing newline and the
352    // closing fence, so the content must end in exactly one newline for the close
353    // fence to land on its own line.
354    if !inner.ends_with('\n') {
355        inner.push('\n');
356    }
357    Ok(Embed::retype(text, from, target, &inner)?)
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn carrier_of(path: &str, text: &str) -> Option<MetaCarrier> {
365        prov_graph::document::Document::parse(path, text)
366            .unwrap()
367            .carrier
368    }
369
370    #[cfg(feature = "yaml")]
371    #[test]
372    fn set_preserves_comments_and_format() {
373        let text = "---\n# keep me\ntitle: Old\n---\nbody\n";
374        let out =
375            set_in_text(text, carrier_of("x.md", text), "title", infer_scalar("New")).unwrap();
376        assert_eq!(out, "---\n# keep me\ntitle: New\n---\nbody\n");
377    }
378
379    #[cfg(feature = "yaml")]
380    #[test]
381    fn an_all_digit_id_survives_the_round_trip_as_a_string() {
382        // The NOID alphabet has digits, so a minted id can look like a number.
383        // Stamping one must not turn it into an integer (which would drop the
384        // leading zero and make `Value::as_str` return `None` on read-back).
385        let text = "---\ntitle: T\n---\nbody\n";
386        let out = set_in_text(
387            text,
388            carrier_of("x.md", text),
389            "id",
390            fig::Value::Str("0123456".into()),
391        )
392        .unwrap();
393        let back = prov_graph::document::Document::parse("x.md", &out).unwrap();
394        assert_eq!(
395            back.meta.get("id").and_then(prov_graph::Value::as_str),
396            Some("0123456"),
397            "{out}"
398        );
399    }
400
401    #[cfg(feature = "fig-lang")]
402    #[test]
403    fn set_in_a_fig_block_stays_fig() {
404        let text = "```fig\ntitle = prov\n```\nbody\n";
405        let out = set_in_text(
406            text,
407            carrier_of("x.md", text),
408            "title",
409            infer_scalar("renamed"),
410        )
411        .unwrap();
412        assert!(out.starts_with("```fig\n"), "fence preserved: {out}");
413        assert!(
414            out.contains("title = renamed"),
415            "fig dialect preserved: {out}"
416        );
417        assert!(out.ends_with("```\nbody\n"));
418    }
419
420    #[cfg(feature = "yaml")]
421    #[test]
422    fn set_edits_a_bare_config_document() {
423        let text = "# workspace registry\ntitle: ID registry\nregistry:\n  abc: a.md\n";
424        let out = set_in_text(
425            text,
426            carrier_of("registry.yaml", text),
427            "registry.abc",
428            infer_scalar("moved/a.md"),
429        )
430        .unwrap();
431        assert!(out.contains("# workspace registry"), "comment kept: {out}");
432        assert!(out.contains("abc: moved/a.md"), "{out}");
433        assert!(!out.contains("---"), "no fences grown: {out}");
434    }
435
436    #[cfg(feature = "yaml")]
437    #[test]
438    fn set_creates_a_block_when_none_exists() {
439        let out = set_in_text("just a body\n", None, "title", infer_scalar("T")).unwrap();
440        assert!(out.starts_with("---\ntitle: T\n---\n"), "{out}");
441        assert!(out.ends_with("just a body\n"));
442    }
443
444    #[cfg(feature = "yaml")]
445    #[test]
446    fn unset_removes_only_the_named_key() {
447        let text = "---\ntitle: T\ndraft: true\n---\nbody\n";
448        let out = unset_in_text(text, carrier_of("x.md", text), "draft").unwrap();
449        assert_eq!(out, "---\ntitle: T\n---\nbody\n");
450        assert!(unset_in_text("no meta\n", None, "x").is_err());
451    }
452
453    #[test]
454    fn scalars_are_inferred() {
455        assert_eq!(infer_scalar("true"), fig::Value::Bool(true));
456        assert_eq!(infer_scalar("42"), fig::Value::Int(42));
457        assert_eq!(infer_scalar("4.5"), fig::Value::Float(4.5));
458        assert_eq!(infer_scalar("null"), fig::Value::Null);
459        assert_eq!(infer_scalar("hello"), fig::Value::Str("hello".into()));
460    }
461
462    #[test]
463    fn zero_padding_survives_inference() {
464        // `prov set <doc> title 08` on a month index used to write `title: 8`,
465        // silently breaking the route that addressed it as "08" — and there was
466        // no way to spell the string back. Padding means the author meant text.
467        assert_eq!(infer_scalar("08"), fig::Value::Str("08".into()));
468        assert_eq!(infer_scalar("007"), fig::Value::Str("007".into()));
469        assert_eq!(infer_scalar("-04"), fig::Value::Str("-04".into()));
470        assert_eq!(infer_scalar("00"), fig::Value::Str("00".into()));
471
472        // A leading zero that carries information is still a number: a lone `0`,
473        // and the one every fraction below 1 opens with.
474        assert_eq!(infer_scalar("0"), fig::Value::Int(0));
475        assert_eq!(infer_scalar("0.5"), fig::Value::Float(0.5));
476        assert_eq!(infer_scalar("-0.25"), fig::Value::Float(-0.25));
477    }
478
479    #[cfg(feature = "yaml")]
480    #[test]
481    fn dotted_paths_mix_keys_and_indices() {
482        let text = "---\ncontents:\n- a.md\n- b.md\n---\n";
483        let out = set_in_text(
484            text,
485            carrier_of("x.md", text),
486            "contents.1",
487            infer_scalar("c.md"),
488        )
489        .unwrap();
490        assert!(out.contains("- a.md\n- c.md"), "{out}");
491    }
492
493    // ---- MetaEditor parity with fig::Embed: reorder_items/replace_key/reorder_keys ----
494
495    #[cfg(feature = "yaml")]
496    #[test]
497    fn replace_key_renames_the_key_and_preserves_comments_elsewhere() {
498        let text = "---\n# keep me\ntitle: Old\nauthor: me\n---\nbody\n";
499        let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
500        editor.replace_key(&key_path("title"), "name").unwrap();
501        let out = editor.render().unwrap();
502        assert!(out.contains("name: Old"), "{out}");
503        assert!(!out.contains("title:"), "{out}");
504        assert!(out.contains("# keep me"), "comment lost: {out}");
505        assert!(out.contains("author: me"), "{out}");
506    }
507
508    #[cfg(feature = "yaml")]
509    #[test]
510    fn reorder_keys_moves_listed_keys_first_and_preserves_comments() {
511        let text = "---\n# c1\ntitle: T\n# c2\nauthor: me\ndraft: true\n---\nbody\n";
512        let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
513        editor
514            .reorder_keys(&[] as &[Segment], &["draft", "title"])
515            .unwrap();
516        let out = editor.render().unwrap();
517        let draft_pos = out.find("draft:").unwrap();
518        let title_pos = out.find("title:").unwrap();
519        let author_pos = out.find("author:").unwrap();
520        assert!(draft_pos < title_pos && title_pos < author_pos, "{out}");
521        assert!(out.contains("# c1"), "comment lost: {out}");
522        assert!(out.contains("# c2"), "comment lost: {out}");
523    }
524
525    #[cfg(feature = "yaml")]
526    #[test]
527    fn reorder_items_moves_listed_items_first_and_preserves_comments() {
528        let text = "---\ncontents:\n- a # keep a\n- b # keep b\n- c # keep c\n---\nbody\n";
529        let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
530        editor
531            .reorder_items(&key_path("contents"), &[2, 0])
532            .unwrap();
533        let out = editor.render().unwrap();
534        assert!(out.contains("# keep a"), "comment lost: {out}");
535        assert!(out.contains("# keep b"), "comment lost: {out}");
536        assert!(out.contains("# keep c"), "comment lost: {out}");
537        let a_pos = out.find("- a").unwrap();
538        let b_pos = out.find("- b").unwrap();
539        let c_pos = out.find("- c").unwrap();
540        // indices [2, 0] -> c, a first (in that order), then the unlisted b follows.
541        assert!(c_pos < a_pos && a_pos < b_pos, "{out}");
542    }
543
544    #[cfg(feature = "yaml")]
545    #[test]
546    fn reorder_keys_works_on_a_whole_file_config_document() {
547        // Exercises the `MetaEditor::Whole` arm (a config document, not a
548        // fenced block) — the same op, the other carrier.
549        let text =
550            "# workspace registry\ntitle: ID registry\npart_of: index.md\nregistry:\n  abc: a.md\n";
551        let mut editor =
552            MetaEditor::open(text, carrier_of("registry.yaml", text).unwrap()).unwrap();
553        editor
554            .reorder_keys(&[] as &[Segment], &["part_of"])
555            .unwrap();
556        let out = editor.render().unwrap();
557        let part_of_pos = out.find("part_of:").unwrap();
558        let title_pos = out.find("title:").unwrap();
559        assert!(part_of_pos < title_pos, "{out}");
560        assert!(out.contains("# workspace registry"), "comment lost: {out}");
561    }
562
563    /// **The regression this function exists for.** Setting a mapping at a path
564    /// whose ancestors are absent used to fail outright: fig creates missing
565    /// ancestors as inline flow, and the block-rendered YAML mapping spliced
566    /// into that flow did not re-parse.
567    #[cfg(feature = "yaml")]
568    #[test]
569    fn a_mapping_lands_at_a_path_whose_ancestors_do_not_exist_yet() {
570        let text = "title: prov config\nspec: 1\n";
571        let carrier = carrier_of("config.yaml", text).unwrap();
572        let mut view = prov_graph::meta::Mapping::new();
573        view.insert(
574            "group".into(),
575            prov_graph::meta::Value::String("date".into()),
576        );
577        view.insert("by".into(), prov_graph::meta::Value::String("year".into()));
578
579        let out = set_meta_in_text(
580            text,
581            Some(carrier),
582            "diaryx.views.daily",
583            &prov_graph::meta::Value::Mapping(view),
584        )
585        .expect("a three-deep mapping into a document with no `diaryx` key");
586
587        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
588        let daily = value_at(&doc.meta, "diaryx.views.daily").expect("the block reads back");
589        let map = daily.as_mapping().expect("a mapping");
590        assert_eq!(
591            map.get("group").and_then(prov_graph::meta::Value::as_str),
592            Some("date")
593        );
594        assert_eq!(
595            map.get("by").and_then(prov_graph::meta::Value::as_str),
596            Some("year")
597        );
598        assert!(
599            out.contains("title: prov config"),
600            "the rest survived: {out}"
601        );
602    }
603
604    /// Replacing a mapping is a replace, not a merge: a key the old block had
605    /// and the new one does not is removed. Without this, clearing a view's
606    /// `under:` would leave the lens scoped to an anchor nobody declared.
607    #[cfg(feature = "yaml")]
608    #[test]
609    fn replacing_a_mapping_drops_the_keys_it_no_longer_declares() {
610        let text = "title: t\ndiaryx:\n  views:\n    daily:\n      group: date\n      under: '[Daily](id:abc)'\n";
611        let carrier = carrier_of("config.yaml", text).unwrap();
612        let mut view = prov_graph::meta::Mapping::new();
613        view.insert(
614            "group".into(),
615            prov_graph::meta::Value::String("date".into()),
616        );
617
618        let out = set_meta_in_text(
619            text,
620            Some(carrier),
621            "diaryx.views.daily",
622            &prov_graph::meta::Value::Mapping(view),
623        )
624        .expect("replace");
625
626        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
627        let map = value_at(&doc.meta, "diaryx.views.daily")
628            .and_then(prov_graph::meta::Value::as_mapping)
629            .expect("still a mapping");
630        assert_eq!(
631            map.get("group").and_then(prov_graph::meta::Value::as_str),
632            Some("date")
633        );
634        assert!(
635            map.get("under").is_none(),
636            "a dropped key must not linger: {out}"
637        );
638    }
639
640    /// A sibling under the same parent is untouched — the write is scoped to
641    /// the path it was given, so declaring a second view keeps the first.
642    #[cfg(feature = "yaml")]
643    #[test]
644    fn a_sibling_mapping_survives_the_write() {
645        let text = "title: t\ndiaryx:\n  views:\n    daily:\n      group: date\n";
646        let carrier = carrier_of("config.yaml", text).unwrap();
647        let mut view = prov_graph::meta::Mapping::new();
648        view.insert(
649            "group".into(),
650            prov_graph::meta::Value::String("people".into()),
651        );
652
653        let out = set_meta_in_text(
654            text,
655            Some(carrier),
656            "diaryx.views.folks",
657            &prov_graph::meta::Value::Mapping(view),
658        )
659        .expect("a second view");
660
661        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
662        assert_eq!(
663            value_at(&doc.meta, "diaryx.views.daily.group")
664                .and_then(prov_graph::meta::Value::as_str),
665            Some("date"),
666            "the first view survived: {out}"
667        );
668        assert_eq!(
669            value_at(&doc.meta, "diaryx.views.folks.group")
670                .and_then(prov_graph::meta::Value::as_str),
671            Some("people")
672        );
673    }
674
675    /// Nested mappings flatten all the way down, however deep.
676    #[cfg(feature = "yaml")]
677    #[test]
678    fn nested_mappings_flatten_all_the_way_down() {
679        let text = "title: t\n";
680        let carrier = carrier_of("config.yaml", text).unwrap();
681        let mut inner = prov_graph::meta::Mapping::new();
682        inner.insert("closed".into(), prov_graph::meta::Value::Bool(true));
683        let mut outer = prov_graph::meta::Mapping::new();
684        outer.insert("audience".into(), prov_graph::meta::Value::Mapping(inner));
685
686        let out = set_meta_in_text(
687            text,
688            Some(carrier),
689            "a.b",
690            &prov_graph::meta::Value::Mapping(outer),
691        )
692        .expect("nested write");
693        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
694        assert_eq!(
695            value_at(&doc.meta, "a.b.audience.closed"),
696            Some(&prov_graph::meta::Value::Bool(true)),
697            "{out}"
698        );
699    }
700
701    /// A sequence lands fine when its parent block already exists — the common
702    /// case, and the one that must keep its block layout.
703    #[cfg(feature = "yaml")]
704    #[test]
705    fn a_sequence_lands_under_a_parent_that_already_exists() {
706        let text = "title: t\nvocab:\n  audience:\n    closed: true\n";
707        let carrier = carrier_of("config.yaml", text).unwrap();
708        let mut inner = prov_graph::meta::Mapping::new();
709        inner.insert(
710            "terms".into(),
711            prov_graph::meta::Value::Sequence(vec![
712                prov_graph::meta::Value::String("public".into()),
713                prov_graph::meta::Value::String("private".into()),
714            ]),
715        );
716        let mut outer = prov_graph::meta::Mapping::new();
717        outer.insert("audience".into(), prov_graph::meta::Value::Mapping(inner));
718
719        let out = set_meta_in_text(
720            text,
721            Some(carrier),
722            "vocab",
723            &prov_graph::meta::Value::Mapping(outer),
724        )
725        .expect("a sequence under an existing block");
726        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
727        let terms = value_at(&doc.meta, "vocab.audience.terms")
728            .and_then(prov_graph::meta::Value::as_sequence)
729            .expect("the sequence reads back as a sequence");
730        assert_eq!(terms.len(), 2, "{out}");
731    }
732
733    /// A list at a depth that does not exist yet. This was the shape that broke
734    /// worst before fig 2.5.3 — the ancestors were created as flow, the sequence
735    /// was spliced in as `nested: {terms: - public}`, and it re-read as the
736    /// *string* `"- public"`. prov detected that by re-parsing and refused the
737    /// write. Now the ancestors are block and the list is a list, so the assert
738    /// is on the value rather than on an error message.
739    #[cfg(feature = "yaml")]
740    #[test]
741    fn a_sequence_lands_as_a_sequence_at_a_path_with_no_parent_block() {
742        let text = "title: t\n";
743        let carrier = carrier_of("config.yaml", text).unwrap();
744        let mut outer = prov_graph::meta::Mapping::new();
745        outer.insert(
746            "terms".into(),
747            prov_graph::meta::Value::Sequence(vec![prov_graph::meta::Value::String(
748                "public".into(),
749            )]),
750        );
751
752        let out = set_meta_in_text(
753            text,
754            Some(carrier),
755            "deep.nested",
756            &prov_graph::meta::Value::Mapping(outer),
757        )
758        .expect("a list with no parent block now lands");
759        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
760        let terms = value_at(&doc.meta, "deep.nested.terms")
761            .and_then(prov_graph::meta::Value::as_sequence)
762            .unwrap_or_else(|| panic!("the list must read back as a list: {out}"));
763        assert_eq!(terms.len(), 1, "{out}");
764        assert_eq!(terms[0].as_str(), Some("public"), "{out}");
765    }
766
767    /// The other half of the same promise: a mapping written over an existing
768    /// one **replaces** it. prov used to guarantee this by diffing the old
769    /// subtree against the new leaves and unsetting the difference; now it rests
770    /// on fig's splice replacing the node whole, which is worth pinning.
771    #[cfg(feature = "yaml")]
772    #[test]
773    fn writing_a_mapping_replaces_the_subtree_rather_than_merging_into_it() {
774        let text = "title: t\na:\n  keep: 1\n  stale: 9\n  gone:\n    deeper: 2\n";
775        let carrier = carrier_of("config.yaml", text).unwrap();
776        let mut fresh = prov_graph::meta::Mapping::new();
777        fresh.insert("keep".into(), prov_graph::meta::Value::String("new".into()));
778
779        let out = set_meta_in_text(
780            text,
781            Some(carrier),
782            "a",
783            &prov_graph::meta::Value::Mapping(fresh),
784        )
785        .expect("replace an existing block");
786        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
787        assert_eq!(
788            value_at(&doc.meta, "a.keep").and_then(prov_graph::meta::Value::as_str),
789            Some("new"),
790            "{out}"
791        );
792        assert!(value_at(&doc.meta, "a.stale").is_none(), "{out}");
793        assert!(value_at(&doc.meta, "a.gone").is_none(), "{out}");
794        // and the document around it is untouched
795        assert_eq!(
796            value_at(&doc.meta, "title").and_then(prov_graph::meta::Value::as_str),
797            Some("t"),
798            "{out}"
799        );
800    }
801
802    /// The conversion `prov convert <file> metadata.embed …` performs, on a
803    /// document whose island sits below a `<head>`. Rebuilding around the body
804    /// slice put the head *below* the island it used to sit above; `retype`
805    /// re-houses the block in place and every host byte keeps its side.
806    #[cfg(feature = "yaml")]
807    #[test]
808    fn retyping_a_mid_document_island_keeps_the_head_above_it() {
809        let text = concat!(
810            "<!doctype html>\n",
811            "<html><head><title>KEEP ME</title></head>\n",
812            "<body>\n",
813            "<script type=\"application/yaml\">\n",
814            "title: Mid\n",
815            "</script>\n",
816            "<p>tail</p>\n",
817            "</html>\n",
818        );
819        let doc = prov_graph::document::Document::parse("page.html", text).unwrap();
820        let mapping = doc.meta.as_mapping().unwrap();
821        let out = retype_block(
822            text,
823            EmbedType::HtmlScriptYaml,
824            mapping,
825            EmbedType::HtmlCodeYaml,
826        )
827        .unwrap();
828
829        let head = out.find("KEEP ME").expect("the head survived");
830        let island = out
831            .find("<pre")
832            .expect("the block was re-housed as html_code");
833        assert!(head < island, "the head must stay above the block:\n{out}");
834        assert!(out.contains("<p>tail</p>"), "the tail survived:\n{out}");
835        assert!(
836            !out.contains("<script"),
837            "the old archetype is gone:\n{out}"
838        );
839    }
840}