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), via [`fig::split`]) hands back to
293/// the format parser, which does not HTML-decode a `<pre><code>` island. Writing
294/// what that 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    let mut inner = prov_graph::meta::serialize_mapping(mapping, target.inner_format())?;
300    // The content slot sits between the opening fence's trailing newline and the
301    // closing fence, so the content must end in exactly one newline for the close
302    // fence to land on its own line.
303    if !inner.ends_with('\n') {
304        inner.push('\n');
305    }
306    // Synthesize an empty `target` block in its canonical place around `body`,
307    // then replace its (empty) content slot with the serialized content: fig owns
308    // the fences and placement, we own what goes between them.
309    let rendered = Embed::open_or_init(body.as_bytes(), target)?
310        .render()?
311        .to_string();
312    let content = Embed::extract(&rendered, target)?.region().content;
313    let mut out = String::with_capacity(rendered.len() + inner.len());
314    out.push_str(&rendered[..content.start]);
315    out.push_str(&inner);
316    out.push_str(&rendered[content.end..]);
317    Ok(out)
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn carrier_of(path: &str, text: &str) -> Option<MetaCarrier> {
325        prov_graph::document::Document::parse(path, text)
326            .unwrap()
327            .carrier
328    }
329
330    #[cfg(feature = "yaml")]
331    #[test]
332    fn set_preserves_comments_and_format() {
333        let text = "---\n# keep me\ntitle: Old\n---\nbody\n";
334        let out =
335            set_in_text(text, carrier_of("x.md", text), "title", infer_scalar("New")).unwrap();
336        assert_eq!(out, "---\n# keep me\ntitle: New\n---\nbody\n");
337    }
338
339    #[cfg(feature = "yaml")]
340    #[test]
341    fn an_all_digit_id_survives_the_round_trip_as_a_string() {
342        // The NOID alphabet has digits, so a minted id can look like a number.
343        // Stamping one must not turn it into an integer (which would drop the
344        // leading zero and make `Value::as_str` return `None` on read-back).
345        let text = "---\ntitle: T\n---\nbody\n";
346        let out = set_in_text(
347            text,
348            carrier_of("x.md", text),
349            "id",
350            fig::Value::Str("0123456".into()),
351        )
352        .unwrap();
353        let back = prov_graph::document::Document::parse("x.md", &out).unwrap();
354        assert_eq!(
355            back.meta.get("id").and_then(prov_graph::Value::as_str),
356            Some("0123456"),
357            "{out}"
358        );
359    }
360
361    #[cfg(feature = "fig-lang")]
362    #[test]
363    fn set_in_a_fig_block_stays_fig() {
364        let text = "```fig\ntitle = prov\n```\nbody\n";
365        let out = set_in_text(
366            text,
367            carrier_of("x.md", text),
368            "title",
369            infer_scalar("renamed"),
370        )
371        .unwrap();
372        assert!(out.starts_with("```fig\n"), "fence preserved: {out}");
373        assert!(
374            out.contains("title = renamed"),
375            "fig dialect preserved: {out}"
376        );
377        assert!(out.ends_with("```\nbody\n"));
378    }
379
380    #[cfg(feature = "yaml")]
381    #[test]
382    fn set_edits_a_bare_config_document() {
383        let text = "# workspace registry\ntitle: ID registry\nregistry:\n  abc: a.md\n";
384        let out = set_in_text(
385            text,
386            carrier_of("registry.yaml", text),
387            "registry.abc",
388            infer_scalar("moved/a.md"),
389        )
390        .unwrap();
391        assert!(out.contains("# workspace registry"), "comment kept: {out}");
392        assert!(out.contains("abc: moved/a.md"), "{out}");
393        assert!(!out.contains("---"), "no fences grown: {out}");
394    }
395
396    #[cfg(feature = "yaml")]
397    #[test]
398    fn set_creates_a_block_when_none_exists() {
399        let out = set_in_text("just a body\n", None, "title", infer_scalar("T")).unwrap();
400        assert!(out.starts_with("---\ntitle: T\n---\n"), "{out}");
401        assert!(out.ends_with("just a body\n"));
402    }
403
404    #[cfg(feature = "yaml")]
405    #[test]
406    fn unset_removes_only_the_named_key() {
407        let text = "---\ntitle: T\ndraft: true\n---\nbody\n";
408        let out = unset_in_text(text, carrier_of("x.md", text), "draft").unwrap();
409        assert_eq!(out, "---\ntitle: T\n---\nbody\n");
410        assert!(unset_in_text("no meta\n", None, "x").is_err());
411    }
412
413    #[test]
414    fn scalars_are_inferred() {
415        assert_eq!(infer_scalar("true"), fig::Value::Bool(true));
416        assert_eq!(infer_scalar("42"), fig::Value::Int(42));
417        assert_eq!(infer_scalar("4.5"), fig::Value::Float(4.5));
418        assert_eq!(infer_scalar("null"), fig::Value::Null);
419        assert_eq!(infer_scalar("hello"), fig::Value::Str("hello".into()));
420    }
421
422    #[cfg(feature = "yaml")]
423    #[test]
424    fn dotted_paths_mix_keys_and_indices() {
425        let text = "---\ncontents:\n- a.md\n- b.md\n---\n";
426        let out = set_in_text(
427            text,
428            carrier_of("x.md", text),
429            "contents.1",
430            infer_scalar("c.md"),
431        )
432        .unwrap();
433        assert!(out.contains("- a.md\n- c.md"), "{out}");
434    }
435
436    // ---- MetaEditor parity with fig::Embed: reorder_items/replace_key/reorder_keys ----
437
438    #[cfg(feature = "yaml")]
439    #[test]
440    fn replace_key_renames_the_key_and_preserves_comments_elsewhere() {
441        let text = "---\n# keep me\ntitle: Old\nauthor: me\n---\nbody\n";
442        let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
443        editor.replace_key(&key_path("title"), "name").unwrap();
444        let out = editor.render().unwrap();
445        assert!(out.contains("name: Old"), "{out}");
446        assert!(!out.contains("title:"), "{out}");
447        assert!(out.contains("# keep me"), "comment lost: {out}");
448        assert!(out.contains("author: me"), "{out}");
449    }
450
451    #[cfg(feature = "yaml")]
452    #[test]
453    fn reorder_keys_moves_listed_keys_first_and_preserves_comments() {
454        let text = "---\n# c1\ntitle: T\n# c2\nauthor: me\ndraft: true\n---\nbody\n";
455        let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
456        editor
457            .reorder_keys(&[] as &[Segment], &["draft", "title"])
458            .unwrap();
459        let out = editor.render().unwrap();
460        let draft_pos = out.find("draft:").unwrap();
461        let title_pos = out.find("title:").unwrap();
462        let author_pos = out.find("author:").unwrap();
463        assert!(draft_pos < title_pos && title_pos < author_pos, "{out}");
464        assert!(out.contains("# c1"), "comment lost: {out}");
465        assert!(out.contains("# c2"), "comment lost: {out}");
466    }
467
468    #[cfg(feature = "yaml")]
469    #[test]
470    fn reorder_items_moves_listed_items_first_and_preserves_comments() {
471        let text = "---\ncontents:\n- a # keep a\n- b # keep b\n- c # keep c\n---\nbody\n";
472        let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
473        editor
474            .reorder_items(&key_path("contents"), &[2, 0])
475            .unwrap();
476        let out = editor.render().unwrap();
477        assert!(out.contains("# keep a"), "comment lost: {out}");
478        assert!(out.contains("# keep b"), "comment lost: {out}");
479        assert!(out.contains("# keep c"), "comment lost: {out}");
480        let a_pos = out.find("- a").unwrap();
481        let b_pos = out.find("- b").unwrap();
482        let c_pos = out.find("- c").unwrap();
483        // indices [2, 0] -> c, a first (in that order), then the unlisted b follows.
484        assert!(c_pos < a_pos && a_pos < b_pos, "{out}");
485    }
486
487    #[cfg(feature = "yaml")]
488    #[test]
489    fn reorder_keys_works_on_a_whole_file_config_document() {
490        // Exercises the `MetaEditor::Whole` arm (a config document, not a
491        // fenced block) — the same op, the other carrier.
492        let text =
493            "# workspace registry\ntitle: ID registry\npart_of: index.md\nregistry:\n  abc: a.md\n";
494        let mut editor =
495            MetaEditor::open(text, carrier_of("registry.yaml", text).unwrap()).unwrap();
496        editor
497            .reorder_keys(&[] as &[Segment], &["part_of"])
498            .unwrap();
499        let out = editor.render().unwrap();
500        let part_of_pos = out.find("part_of:").unwrap();
501        let title_pos = out.find("title:").unwrap();
502        assert!(part_of_pos < title_pos, "{out}");
503        assert!(out.contains("# workspace registry"), "comment lost: {out}");
504    }
505
506    /// **The regression this function exists for.** Setting a mapping at a path
507    /// whose ancestors are absent used to fail outright: fig creates missing
508    /// ancestors as inline flow, and the block-rendered YAML mapping spliced
509    /// into that flow did not re-parse.
510    #[cfg(feature = "yaml")]
511    #[test]
512    fn a_mapping_lands_at_a_path_whose_ancestors_do_not_exist_yet() {
513        let text = "title: prov config\nspec: 1\n";
514        let carrier = carrier_of("config.yaml", text).unwrap();
515        let mut view = prov_graph::meta::Mapping::new();
516        view.insert(
517            "group".into(),
518            prov_graph::meta::Value::String("date".into()),
519        );
520        view.insert("by".into(), prov_graph::meta::Value::String("year".into()));
521
522        let out = set_meta_in_text(
523            text,
524            Some(carrier),
525            "diaryx.views.daily",
526            &prov_graph::meta::Value::Mapping(view),
527        )
528        .expect("a three-deep mapping into a document with no `diaryx` key");
529
530        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
531        let daily = value_at(&doc.meta, "diaryx.views.daily").expect("the block reads back");
532        let map = daily.as_mapping().expect("a mapping");
533        assert_eq!(
534            map.get("group").and_then(prov_graph::meta::Value::as_str),
535            Some("date")
536        );
537        assert_eq!(
538            map.get("by").and_then(prov_graph::meta::Value::as_str),
539            Some("year")
540        );
541        assert!(
542            out.contains("title: prov config"),
543            "the rest survived: {out}"
544        );
545    }
546
547    /// Replacing a mapping is a replace, not a merge: a key the old block had
548    /// and the new one does not is removed. Without this, clearing a view's
549    /// `under:` would leave the lens scoped to an anchor nobody declared.
550    #[cfg(feature = "yaml")]
551    #[test]
552    fn replacing_a_mapping_drops_the_keys_it_no_longer_declares() {
553        let text = "title: t\ndiaryx:\n  views:\n    daily:\n      group: date\n      under: '[Daily](id:abc)'\n";
554        let carrier = carrier_of("config.yaml", text).unwrap();
555        let mut view = prov_graph::meta::Mapping::new();
556        view.insert(
557            "group".into(),
558            prov_graph::meta::Value::String("date".into()),
559        );
560
561        let out = set_meta_in_text(
562            text,
563            Some(carrier),
564            "diaryx.views.daily",
565            &prov_graph::meta::Value::Mapping(view),
566        )
567        .expect("replace");
568
569        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
570        let map = value_at(&doc.meta, "diaryx.views.daily")
571            .and_then(prov_graph::meta::Value::as_mapping)
572            .expect("still a mapping");
573        assert_eq!(
574            map.get("group").and_then(prov_graph::meta::Value::as_str),
575            Some("date")
576        );
577        assert!(
578            map.get("under").is_none(),
579            "a dropped key must not linger: {out}"
580        );
581    }
582
583    /// A sibling under the same parent is untouched — the write is scoped to
584    /// the path it was given, so declaring a second view keeps the first.
585    #[cfg(feature = "yaml")]
586    #[test]
587    fn a_sibling_mapping_survives_the_write() {
588        let text = "title: t\ndiaryx:\n  views:\n    daily:\n      group: date\n";
589        let carrier = carrier_of("config.yaml", text).unwrap();
590        let mut view = prov_graph::meta::Mapping::new();
591        view.insert(
592            "group".into(),
593            prov_graph::meta::Value::String("people".into()),
594        );
595
596        let out = set_meta_in_text(
597            text,
598            Some(carrier),
599            "diaryx.views.folks",
600            &prov_graph::meta::Value::Mapping(view),
601        )
602        .expect("a second view");
603
604        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
605        assert_eq!(
606            value_at(&doc.meta, "diaryx.views.daily.group")
607                .and_then(prov_graph::meta::Value::as_str),
608            Some("date"),
609            "the first view survived: {out}"
610        );
611        assert_eq!(
612            value_at(&doc.meta, "diaryx.views.folks.group")
613                .and_then(prov_graph::meta::Value::as_str),
614            Some("people")
615        );
616    }
617
618    /// Nested mappings flatten all the way down, however deep.
619    #[cfg(feature = "yaml")]
620    #[test]
621    fn nested_mappings_flatten_all_the_way_down() {
622        let text = "title: t\n";
623        let carrier = carrier_of("config.yaml", text).unwrap();
624        let mut inner = prov_graph::meta::Mapping::new();
625        inner.insert("closed".into(), prov_graph::meta::Value::Bool(true));
626        let mut outer = prov_graph::meta::Mapping::new();
627        outer.insert("audience".into(), prov_graph::meta::Value::Mapping(inner));
628
629        let out = set_meta_in_text(
630            text,
631            Some(carrier),
632            "a.b",
633            &prov_graph::meta::Value::Mapping(outer),
634        )
635        .expect("nested write");
636        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
637        assert_eq!(
638            value_at(&doc.meta, "a.b.audience.closed"),
639            Some(&prov_graph::meta::Value::Bool(true)),
640            "{out}"
641        );
642    }
643
644    /// A sequence lands fine when its parent block already exists — the common
645    /// case, and the one that must keep its block layout.
646    #[cfg(feature = "yaml")]
647    #[test]
648    fn a_sequence_lands_under_a_parent_that_already_exists() {
649        let text = "title: t\nvocab:\n  audience:\n    closed: true\n";
650        let carrier = carrier_of("config.yaml", text).unwrap();
651        let mut inner = prov_graph::meta::Mapping::new();
652        inner.insert(
653            "terms".into(),
654            prov_graph::meta::Value::Sequence(vec![
655                prov_graph::meta::Value::String("public".into()),
656                prov_graph::meta::Value::String("private".into()),
657            ]),
658        );
659        let mut outer = prov_graph::meta::Mapping::new();
660        outer.insert("audience".into(), prov_graph::meta::Value::Mapping(inner));
661
662        let out = set_meta_in_text(
663            text,
664            Some(carrier),
665            "vocab",
666            &prov_graph::meta::Value::Mapping(outer),
667        )
668        .expect("a sequence under an existing block");
669        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
670        let terms = value_at(&doc.meta, "vocab.audience.terms")
671            .and_then(prov_graph::meta::Value::as_sequence)
672            .expect("the sequence reads back as a sequence");
673        assert_eq!(terms.len(), 2, "{out}");
674    }
675
676    /// A list at a depth that does not exist yet. This was the shape that broke
677    /// worst before fig 2.5.3 — the ancestors were created as flow, the sequence
678    /// was spliced in as `nested: {terms: - public}`, and it re-read as the
679    /// *string* `"- public"`. prov detected that by re-parsing and refused the
680    /// write. Now the ancestors are block and the list is a list, so the assert
681    /// is on the value rather than on an error message.
682    #[cfg(feature = "yaml")]
683    #[test]
684    fn a_sequence_lands_as_a_sequence_at_a_path_with_no_parent_block() {
685        let text = "title: t\n";
686        let carrier = carrier_of("config.yaml", text).unwrap();
687        let mut outer = prov_graph::meta::Mapping::new();
688        outer.insert(
689            "terms".into(),
690            prov_graph::meta::Value::Sequence(vec![prov_graph::meta::Value::String(
691                "public".into(),
692            )]),
693        );
694
695        let out = set_meta_in_text(
696            text,
697            Some(carrier),
698            "deep.nested",
699            &prov_graph::meta::Value::Mapping(outer),
700        )
701        .expect("a list with no parent block now lands");
702        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
703        let terms = value_at(&doc.meta, "deep.nested.terms")
704            .and_then(prov_graph::meta::Value::as_sequence)
705            .unwrap_or_else(|| panic!("the list must read back as a list: {out}"));
706        assert_eq!(terms.len(), 1, "{out}");
707        assert_eq!(terms[0].as_str(), Some("public"), "{out}");
708    }
709
710    /// The other half of the same promise: a mapping written over an existing
711    /// one **replaces** it. prov used to guarantee this by diffing the old
712    /// subtree against the new leaves and unsetting the difference; now it rests
713    /// on fig's splice replacing the node whole, which is worth pinning.
714    #[cfg(feature = "yaml")]
715    #[test]
716    fn writing_a_mapping_replaces_the_subtree_rather_than_merging_into_it() {
717        let text = "title: t\na:\n  keep: 1\n  stale: 9\n  gone:\n    deeper: 2\n";
718        let carrier = carrier_of("config.yaml", text).unwrap();
719        let mut fresh = prov_graph::meta::Mapping::new();
720        fresh.insert("keep".into(), prov_graph::meta::Value::String("new".into()));
721
722        let out = set_meta_in_text(
723            text,
724            Some(carrier),
725            "a",
726            &prov_graph::meta::Value::Mapping(fresh),
727        )
728        .expect("replace an existing block");
729        let doc = prov_graph::Document::parse(std::path::Path::new("config.yaml"), &out).unwrap();
730        assert_eq!(
731            value_at(&doc.meta, "a.keep").and_then(prov_graph::meta::Value::as_str),
732            Some("new"),
733            "{out}"
734        );
735        assert!(value_at(&doc.meta, "a.stale").is_none(), "{out}");
736        assert!(value_at(&doc.meta, "a.gone").is_none(), "{out}");
737        // and the document around it is untouched
738        assert_eq!(
739            value_at(&doc.meta, "title").and_then(prov_graph::meta::Value::as_str),
740            Some("t"),
741            "{out}"
742        );
743    }
744}