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.
177pub fn infer_scalar(s: &str) -> fig::Value {
178    match s {
179        "true" => fig::Value::Bool(true),
180        "false" => fig::Value::Bool(false),
181        "null" | "~" => fig::Value::Null,
182        _ => {
183            if let Ok(i) = s.parse::<i64>() {
184                fig::Value::Int(i)
185            } else if let Ok(f) = s.parse::<f64>() {
186                fig::Value::Float(f)
187            } else {
188                fig::Value::Str(s.to_string())
189            }
190        }
191    }
192}
193
194/// Upsert `dotted` to `value` in `text`'s metadata (carrier-aware), creating
195/// a YAML frontmatter block when the document has none. Returns the full
196/// re-rendered document text.
197pub fn set_in_text(
198    text: &str,
199    carrier: Option<MetaCarrier>,
200    dotted: &str,
201    value: fig::Value,
202) -> Result<String> {
203    let mut editor = MetaEditor::open_or_init(text, carrier)?;
204    let path = key_path(dotted);
205    match path.last() {
206        // fig's `set` upserts a trailing *key*; an index-terminated path is a
207        // pure replacement (there is no "insert at absent index" to upsert).
208        Some(Segment::Index(_)) => editor.replace_value(&path, value)?,
209        _ => editor.set_value(&path, value)?,
210    }
211    editor.render()
212}
213
214/// Upsert `dotted` to a full [`Value`](prov_graph::meta::Value) — the mapping-valued
215/// counterpart to [`set_in_text`], which takes only a `fig::Value` scalar. Lets a
216/// caller set a whole nested block (e.g. the root's `prov:` policy block) without
217/// naming `fig`, converting through the crate's `Value → fig::Value` bridge.
218///
219/// # How a mapping is written
220///
221/// One splice, whatever the shape. fig renders a mapping as block YAML and
222/// creates a path's missing ancestors as block containers, so a nested set lands
223/// readable — the layout, comments and key order around it preserved — even when
224/// nothing along the path existed before.
225///
226/// It was not always one splice. Through fig 2.5.2 a block value spliced into
227/// auto-created ancestors came out corrupt *and reported success*: `a: {b: - x}`,
228/// which re-reads as a string rather than a list. So this function used to write
229/// the value, re-parse the document, compare the value's **kind** at the path,
230/// and on a mismatch fall back to writing one scalar leaf at a time, then prune
231/// whatever keys the old subtree had and the new one did not.
232///
233/// fig 2.5.3 removed the need for all of it: ancestors are seeded as block, and
234/// a splice that cannot be satisfied — into an existing *flow* container — is an
235/// `Err` instead of a quiet rewrite. There is no shape the leaf-by-leaf path can
236/// write that the direct one cannot, so the fallback had nothing left to catch,
237/// and the pruning went with it because a direct set replaces the node whole.
238///
239/// The write is an **upsert of the whole subtree**: keys the document carries
240/// under `dotted` that `value` does not are removed, so replacing a mapping
241/// replaces it rather than merging into what was there before.
242pub fn set_meta_in_text(
243    text: &str,
244    carrier: Option<MetaCarrier>,
245    dotted: &str,
246    value: &prov_graph::meta::Value,
247) -> Result<String> {
248    set_in_text(text, carrier, dotted, fig::Value::from(value))
249}
250
251/// The value at a dotted path in `meta`, or `None`. Mapping keys only — a
252/// sequence index along the way reads as absent.
253#[cfg(test)]
254fn value_at<'a>(
255    meta: &'a prov_graph::meta::Value,
256    dotted: &str,
257) -> Option<&'a prov_graph::meta::Value> {
258    let mut current = meta;
259    for part in dotted.split('.') {
260        current = current.as_mapping()?.get(part)?;
261    }
262    Some(current)
263}
264
265/// Delete the entry at `dotted` from `text`'s metadata (carrier-aware).
266/// Returns the full re-rendered document text. Errors when the document has
267/// no metadata or the path does not exist.
268pub fn unset_in_text(text: &str, carrier: Option<MetaCarrier>, dotted: &str) -> Result<String> {
269    let carrier = carrier
270        .ok_or_else(|| Error::Structure("document has no embedded metadata block".into()))?;
271    let mut editor = MetaEditor::open(text, carrier)?;
272    editor.delete(&key_path(dotted))?;
273    editor.render()
274}
275
276/// Re-emit `mapping` as a fresh metadata block of archetype `target`, placed in
277/// `target`'s canonical position around the plain `body` (before it for
278/// frontmatter, after it for endmatter) — the reconstruction a *format
279/// conversion* performs. Unlike the comment-preserving edits above, this
280/// deliberately rebuilds the block: a conversion crosses formats (a YAML comment
281/// has no JSON home), so only the values survive.
282///
283/// The content is rendered by prov's canonical [`serialize_mapping`] — the
284/// same serializer behind `prov meta --format`, so a converted block's
285/// sequence and scalar layout matches the rest of the codebase (fig's per-key
286/// [`Embed`] splice path renders some formats, notably fig sequences,
287/// differently). The block's fences and placement come from fig, by synthesizing
288/// an empty `target` block around `body` and splicing the serialized content into
289/// its content slot.
290///
291/// The content is spliced verbatim — the same bytes prov's reader
292/// ([`Document::parse`](prov_graph::Document::parse)) hands back to the format
293/// parser, which does not HTML-decode a `<pre><code>` island. Writing what that
294/// reader expects keeps a converted value round-tripping through
295/// `prov get`/`check` rather than acquiring stray `&lt;` entities.
296///
297/// [`serialize_mapping`]: prov_graph::meta::serialize_mapping
298pub fn reformat_block(body: &str, mapping: &Mapping, target: EmbedType) -> Result<String> {
299    // Synthesize an empty `target` block in its canonical place around `body`,
300    // then fill it: fig owns the fences and the placement, we own what goes
301    // between them. Filling is a same-archetype `retype`, which fig documents as
302    // a byte-identical rebuild carrying new content — so the fence bytes come
303    // back exactly as `render` laid them down.
304    let rendered = Embed::open_or_init(body.as_bytes(), target)?
305        .render()?
306        .to_string();
307    retype_block(&rendered, target, mapping, target)
308}
309
310/// Re-house the existing `from` block in `text` as a `target` one carrying
311/// `mapping` — the conversion [`reformat_block`] performs for a document that
312/// has no block yet, for one that does.
313///
314/// Every host byte outside the block survives, **on both sides**, in file order.
315/// That is the difference from rebuilding around a body slice: a document whose
316/// block sits mid-file — an HTML `<script>` island below a `<head>` — has host
317/// text above and below it, and reconstructing from one side silently discarded
318/// the other. fig 3.3's `retype` owns the whole operation, so the placement
319/// rules (a block moves only when the target archetype belongs at the other end
320/// of the file; mid-document to edge is refused outright) are fig's to state
321/// rather than something prov re-derives by arithmetic on spans.
322pub fn retype_block(
323    text: &str,
324    from: EmbedType,
325    mapping: &Mapping,
326    target: EmbedType,
327) -> Result<String> {
328    let mut inner = prov_graph::meta::serialize_mapping(mapping, target.inner_format())?;
329    // The content slot sits between the opening fence's trailing newline and the
330    // closing fence, so the content must end in exactly one newline for the close
331    // fence to land on its own line.
332    if !inner.ends_with('\n') {
333        inner.push('\n');
334    }
335    Ok(Embed::retype(text, from, target, &inner)?)
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn carrier_of(path: &str, text: &str) -> Option<MetaCarrier> {
343        prov_graph::document::Document::parse(path, text)
344            .unwrap()
345            .carrier
346    }
347
348    #[cfg(feature = "yaml")]
349    #[test]
350    fn set_preserves_comments_and_format() {
351        let text = "---\n# keep me\ntitle: Old\n---\nbody\n";
352        let out =
353            set_in_text(text, carrier_of("x.md", text), "title", infer_scalar("New")).unwrap();
354        assert_eq!(out, "---\n# keep me\ntitle: New\n---\nbody\n");
355    }
356
357    #[cfg(feature = "yaml")]
358    #[test]
359    fn an_all_digit_id_survives_the_round_trip_as_a_string() {
360        // The NOID alphabet has digits, so a minted id can look like a number.
361        // Stamping one must not turn it into an integer (which would drop the
362        // leading zero and make `Value::as_str` return `None` on read-back).
363        let text = "---\ntitle: T\n---\nbody\n";
364        let out = set_in_text(
365            text,
366            carrier_of("x.md", text),
367            "id",
368            fig::Value::Str("0123456".into()),
369        )
370        .unwrap();
371        let back = prov_graph::document::Document::parse("x.md", &out).unwrap();
372        assert_eq!(
373            back.meta.get("id").and_then(prov_graph::Value::as_str),
374            Some("0123456"),
375            "{out}"
376        );
377    }
378
379    #[cfg(feature = "fig-lang")]
380    #[test]
381    fn set_in_a_fig_block_stays_fig() {
382        let text = "```fig\ntitle = prov\n```\nbody\n";
383        let out = set_in_text(
384            text,
385            carrier_of("x.md", text),
386            "title",
387            infer_scalar("renamed"),
388        )
389        .unwrap();
390        assert!(out.starts_with("```fig\n"), "fence preserved: {out}");
391        assert!(
392            out.contains("title = renamed"),
393            "fig dialect preserved: {out}"
394        );
395        assert!(out.ends_with("```\nbody\n"));
396    }
397
398    #[cfg(feature = "yaml")]
399    #[test]
400    fn set_edits_a_bare_config_document() {
401        let text = "# workspace registry\ntitle: ID registry\nregistry:\n  abc: a.md\n";
402        let out = set_in_text(
403            text,
404            carrier_of("registry.yaml", text),
405            "registry.abc",
406            infer_scalar("moved/a.md"),
407        )
408        .unwrap();
409        assert!(out.contains("# workspace registry"), "comment kept: {out}");
410        assert!(out.contains("abc: moved/a.md"), "{out}");
411        assert!(!out.contains("---"), "no fences grown: {out}");
412    }
413
414    #[cfg(feature = "yaml")]
415    #[test]
416    fn set_creates_a_block_when_none_exists() {
417        let out = set_in_text("just a body\n", None, "title", infer_scalar("T")).unwrap();
418        assert!(out.starts_with("---\ntitle: T\n---\n"), "{out}");
419        assert!(out.ends_with("just a body\n"));
420    }
421
422    #[cfg(feature = "yaml")]
423    #[test]
424    fn unset_removes_only_the_named_key() {
425        let text = "---\ntitle: T\ndraft: true\n---\nbody\n";
426        let out = unset_in_text(text, carrier_of("x.md", text), "draft").unwrap();
427        assert_eq!(out, "---\ntitle: T\n---\nbody\n");
428        assert!(unset_in_text("no meta\n", None, "x").is_err());
429    }
430
431    #[test]
432    fn scalars_are_inferred() {
433        assert_eq!(infer_scalar("true"), fig::Value::Bool(true));
434        assert_eq!(infer_scalar("42"), fig::Value::Int(42));
435        assert_eq!(infer_scalar("4.5"), fig::Value::Float(4.5));
436        assert_eq!(infer_scalar("null"), fig::Value::Null);
437        assert_eq!(infer_scalar("hello"), fig::Value::Str("hello".into()));
438    }
439
440    #[cfg(feature = "yaml")]
441    #[test]
442    fn dotted_paths_mix_keys_and_indices() {
443        let text = "---\ncontents:\n- a.md\n- b.md\n---\n";
444        let out = set_in_text(
445            text,
446            carrier_of("x.md", text),
447            "contents.1",
448            infer_scalar("c.md"),
449        )
450        .unwrap();
451        assert!(out.contains("- a.md\n- c.md"), "{out}");
452    }
453
454    // ---- MetaEditor parity with fig::Embed: reorder_items/replace_key/reorder_keys ----
455
456    #[cfg(feature = "yaml")]
457    #[test]
458    fn replace_key_renames_the_key_and_preserves_comments_elsewhere() {
459        let text = "---\n# keep me\ntitle: Old\nauthor: me\n---\nbody\n";
460        let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
461        editor.replace_key(&key_path("title"), "name").unwrap();
462        let out = editor.render().unwrap();
463        assert!(out.contains("name: Old"), "{out}");
464        assert!(!out.contains("title:"), "{out}");
465        assert!(out.contains("# keep me"), "comment lost: {out}");
466        assert!(out.contains("author: me"), "{out}");
467    }
468
469    #[cfg(feature = "yaml")]
470    #[test]
471    fn reorder_keys_moves_listed_keys_first_and_preserves_comments() {
472        let text = "---\n# c1\ntitle: T\n# c2\nauthor: me\ndraft: true\n---\nbody\n";
473        let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
474        editor
475            .reorder_keys(&[] as &[Segment], &["draft", "title"])
476            .unwrap();
477        let out = editor.render().unwrap();
478        let draft_pos = out.find("draft:").unwrap();
479        let title_pos = out.find("title:").unwrap();
480        let author_pos = out.find("author:").unwrap();
481        assert!(draft_pos < title_pos && title_pos < author_pos, "{out}");
482        assert!(out.contains("# c1"), "comment lost: {out}");
483        assert!(out.contains("# c2"), "comment lost: {out}");
484    }
485
486    #[cfg(feature = "yaml")]
487    #[test]
488    fn reorder_items_moves_listed_items_first_and_preserves_comments() {
489        let text = "---\ncontents:\n- a # keep a\n- b # keep b\n- c # keep c\n---\nbody\n";
490        let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
491        editor
492            .reorder_items(&key_path("contents"), &[2, 0])
493            .unwrap();
494        let out = editor.render().unwrap();
495        assert!(out.contains("# keep a"), "comment lost: {out}");
496        assert!(out.contains("# keep b"), "comment lost: {out}");
497        assert!(out.contains("# keep c"), "comment lost: {out}");
498        let a_pos = out.find("- a").unwrap();
499        let b_pos = out.find("- b").unwrap();
500        let c_pos = out.find("- c").unwrap();
501        // indices [2, 0] -> c, a first (in that order), then the unlisted b follows.
502        assert!(c_pos < a_pos && a_pos < b_pos, "{out}");
503    }
504
505    #[cfg(feature = "yaml")]
506    #[test]
507    fn reorder_keys_works_on_a_whole_file_config_document() {
508        // Exercises the `MetaEditor::Whole` arm (a config document, not a
509        // fenced block) — the same op, the other carrier.
510        let text =
511            "# workspace registry\ntitle: ID registry\npart_of: index.md\nregistry:\n  abc: a.md\n";
512        let mut editor =
513            MetaEditor::open(text, carrier_of("registry.yaml", text).unwrap()).unwrap();
514        editor
515            .reorder_keys(&[] as &[Segment], &["part_of"])
516            .unwrap();
517        let out = editor.render().unwrap();
518        let part_of_pos = out.find("part_of:").unwrap();
519        let title_pos = out.find("title:").unwrap();
520        assert!(part_of_pos < title_pos, "{out}");
521        assert!(out.contains("# workspace registry"), "comment lost: {out}");
522    }
523
524    /// **The regression this function exists for.** Setting a mapping at a path
525    /// whose ancestors are absent used to fail outright: fig creates missing
526    /// ancestors as inline flow, and the block-rendered YAML mapping spliced
527    /// into that flow did not re-parse.
528    #[cfg(feature = "yaml")]
529    #[test]
530    fn a_mapping_lands_at_a_path_whose_ancestors_do_not_exist_yet() {
531        let text = "title: prov config\nspec: 1\n";
532        let carrier = carrier_of("config.yaml", text).unwrap();
533        let mut view = prov_graph::meta::Mapping::new();
534        view.insert(
535            "group".into(),
536            prov_graph::meta::Value::String("date".into()),
537        );
538        view.insert("by".into(), prov_graph::meta::Value::String("year".into()));
539
540        let out = set_meta_in_text(
541            text,
542            Some(carrier),
543            "diaryx.views.daily",
544            &prov_graph::meta::Value::Mapping(view),
545        )
546        .expect("a three-deep mapping into a document with no `diaryx` key");
547
548        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
549        let daily = value_at(&doc.meta, "diaryx.views.daily").expect("the block reads back");
550        let map = daily.as_mapping().expect("a mapping");
551        assert_eq!(
552            map.get("group").and_then(prov_graph::meta::Value::as_str),
553            Some("date")
554        );
555        assert_eq!(
556            map.get("by").and_then(prov_graph::meta::Value::as_str),
557            Some("year")
558        );
559        assert!(
560            out.contains("title: prov config"),
561            "the rest survived: {out}"
562        );
563    }
564
565    /// Replacing a mapping is a replace, not a merge: a key the old block had
566    /// and the new one does not is removed. Without this, clearing a view's
567    /// `under:` would leave the lens scoped to an anchor nobody declared.
568    #[cfg(feature = "yaml")]
569    #[test]
570    fn replacing_a_mapping_drops_the_keys_it_no_longer_declares() {
571        let text = "title: t\ndiaryx:\n  views:\n    daily:\n      group: date\n      under: '[Daily](id:abc)'\n";
572        let carrier = carrier_of("config.yaml", text).unwrap();
573        let mut view = prov_graph::meta::Mapping::new();
574        view.insert(
575            "group".into(),
576            prov_graph::meta::Value::String("date".into()),
577        );
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("replace");
586
587        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
588        let map = value_at(&doc.meta, "diaryx.views.daily")
589            .and_then(prov_graph::meta::Value::as_mapping)
590            .expect("still a mapping");
591        assert_eq!(
592            map.get("group").and_then(prov_graph::meta::Value::as_str),
593            Some("date")
594        );
595        assert!(
596            map.get("under").is_none(),
597            "a dropped key must not linger: {out}"
598        );
599    }
600
601    /// A sibling under the same parent is untouched — the write is scoped to
602    /// the path it was given, so declaring a second view keeps the first.
603    #[cfg(feature = "yaml")]
604    #[test]
605    fn a_sibling_mapping_survives_the_write() {
606        let text = "title: t\ndiaryx:\n  views:\n    daily:\n      group: date\n";
607        let carrier = carrier_of("config.yaml", text).unwrap();
608        let mut view = prov_graph::meta::Mapping::new();
609        view.insert(
610            "group".into(),
611            prov_graph::meta::Value::String("people".into()),
612        );
613
614        let out = set_meta_in_text(
615            text,
616            Some(carrier),
617            "diaryx.views.folks",
618            &prov_graph::meta::Value::Mapping(view),
619        )
620        .expect("a second view");
621
622        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
623        assert_eq!(
624            value_at(&doc.meta, "diaryx.views.daily.group")
625                .and_then(prov_graph::meta::Value::as_str),
626            Some("date"),
627            "the first view survived: {out}"
628        );
629        assert_eq!(
630            value_at(&doc.meta, "diaryx.views.folks.group")
631                .and_then(prov_graph::meta::Value::as_str),
632            Some("people")
633        );
634    }
635
636    /// Nested mappings flatten all the way down, however deep.
637    #[cfg(feature = "yaml")]
638    #[test]
639    fn nested_mappings_flatten_all_the_way_down() {
640        let text = "title: t\n";
641        let carrier = carrier_of("config.yaml", text).unwrap();
642        let mut inner = prov_graph::meta::Mapping::new();
643        inner.insert("closed".into(), prov_graph::meta::Value::Bool(true));
644        let mut outer = prov_graph::meta::Mapping::new();
645        outer.insert("audience".into(), prov_graph::meta::Value::Mapping(inner));
646
647        let out = set_meta_in_text(
648            text,
649            Some(carrier),
650            "a.b",
651            &prov_graph::meta::Value::Mapping(outer),
652        )
653        .expect("nested write");
654        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
655        assert_eq!(
656            value_at(&doc.meta, "a.b.audience.closed"),
657            Some(&prov_graph::meta::Value::Bool(true)),
658            "{out}"
659        );
660    }
661
662    /// A sequence lands fine when its parent block already exists — the common
663    /// case, and the one that must keep its block layout.
664    #[cfg(feature = "yaml")]
665    #[test]
666    fn a_sequence_lands_under_a_parent_that_already_exists() {
667        let text = "title: t\nvocab:\n  audience:\n    closed: true\n";
668        let carrier = carrier_of("config.yaml", text).unwrap();
669        let mut inner = prov_graph::meta::Mapping::new();
670        inner.insert(
671            "terms".into(),
672            prov_graph::meta::Value::Sequence(vec![
673                prov_graph::meta::Value::String("public".into()),
674                prov_graph::meta::Value::String("private".into()),
675            ]),
676        );
677        let mut outer = prov_graph::meta::Mapping::new();
678        outer.insert("audience".into(), prov_graph::meta::Value::Mapping(inner));
679
680        let out = set_meta_in_text(
681            text,
682            Some(carrier),
683            "vocab",
684            &prov_graph::meta::Value::Mapping(outer),
685        )
686        .expect("a sequence under an existing block");
687        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
688        let terms = value_at(&doc.meta, "vocab.audience.terms")
689            .and_then(prov_graph::meta::Value::as_sequence)
690            .expect("the sequence reads back as a sequence");
691        assert_eq!(terms.len(), 2, "{out}");
692    }
693
694    /// A list at a depth that does not exist yet. This was the shape that broke
695    /// worst before fig 2.5.3 — the ancestors were created as flow, the sequence
696    /// was spliced in as `nested: {terms: - public}`, and it re-read as the
697    /// *string* `"- public"`. prov detected that by re-parsing and refused the
698    /// write. Now the ancestors are block and the list is a list, so the assert
699    /// is on the value rather than on an error message.
700    #[cfg(feature = "yaml")]
701    #[test]
702    fn a_sequence_lands_as_a_sequence_at_a_path_with_no_parent_block() {
703        let text = "title: t\n";
704        let carrier = carrier_of("config.yaml", text).unwrap();
705        let mut outer = prov_graph::meta::Mapping::new();
706        outer.insert(
707            "terms".into(),
708            prov_graph::meta::Value::Sequence(vec![prov_graph::meta::Value::String(
709                "public".into(),
710            )]),
711        );
712
713        let out = set_meta_in_text(
714            text,
715            Some(carrier),
716            "deep.nested",
717            &prov_graph::meta::Value::Mapping(outer),
718        )
719        .expect("a list with no parent block now lands");
720        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
721        let terms = value_at(&doc.meta, "deep.nested.terms")
722            .and_then(prov_graph::meta::Value::as_sequence)
723            .unwrap_or_else(|| panic!("the list must read back as a list: {out}"));
724        assert_eq!(terms.len(), 1, "{out}");
725        assert_eq!(terms[0].as_str(), Some("public"), "{out}");
726    }
727
728    /// The other half of the same promise: a mapping written over an existing
729    /// one **replaces** it. prov used to guarantee this by diffing the old
730    /// subtree against the new leaves and unsetting the difference; now it rests
731    /// on fig's splice replacing the node whole, which is worth pinning.
732    #[cfg(feature = "yaml")]
733    #[test]
734    fn writing_a_mapping_replaces_the_subtree_rather_than_merging_into_it() {
735        let text = "title: t\na:\n  keep: 1\n  stale: 9\n  gone:\n    deeper: 2\n";
736        let carrier = carrier_of("config.yaml", text).unwrap();
737        let mut fresh = prov_graph::meta::Mapping::new();
738        fresh.insert("keep".into(), prov_graph::meta::Value::String("new".into()));
739
740        let out = set_meta_in_text(
741            text,
742            Some(carrier),
743            "a",
744            &prov_graph::meta::Value::Mapping(fresh),
745        )
746        .expect("replace an existing block");
747        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
748        assert_eq!(
749            value_at(&doc.meta, "a.keep").and_then(prov_graph::meta::Value::as_str),
750            Some("new"),
751            "{out}"
752        );
753        assert!(value_at(&doc.meta, "a.stale").is_none(), "{out}");
754        assert!(value_at(&doc.meta, "a.gone").is_none(), "{out}");
755        // and the document around it is untouched
756        assert_eq!(
757            value_at(&doc.meta, "title").and_then(prov_graph::meta::Value::as_str),
758            Some("t"),
759            "{out}"
760        );
761    }
762
763    /// The conversion `prov convert <file> metadata.embed …` performs, on a
764    /// document whose island sits below a `<head>`. Rebuilding around the body
765    /// slice put the head *below* the island it used to sit above; `retype`
766    /// re-houses the block in place and every host byte keeps its side.
767    #[cfg(feature = "yaml")]
768    #[test]
769    fn retyping_a_mid_document_island_keeps_the_head_above_it() {
770        let text = concat!(
771            "<!doctype html>\n",
772            "<html><head><title>KEEP ME</title></head>\n",
773            "<body>\n",
774            "<script type=\"application/yaml\">\n",
775            "title: Mid\n",
776            "</script>\n",
777            "<p>tail</p>\n",
778            "</html>\n",
779        );
780        let doc = prov_graph::document::Document::parse("page.html", text).unwrap();
781        let mapping = doc.meta.as_mapping().unwrap();
782        let out = retype_block(
783            text,
784            EmbedType::HtmlScriptYaml,
785            mapping,
786            EmbedType::HtmlCodeYaml,
787        )
788        .unwrap();
789
790        let head = out.find("KEEP ME").expect("the head survived");
791        let island = out
792            .find("<pre")
793            .expect("the block was re-housed as html_code");
794        assert!(head < island, "the head must stay above the block:\n{out}");
795        assert!(out.contains("<p>tail</p>"), "the tail survived:\n{out}");
796        assert!(
797            !out.contains("<script"),
798            "the old archetype is gone:\n{out}"
799        );
800    }
801}