1use fig::{Embed, EmbedType, Segment};
14
15use prov_graph::document::MetaCarrier;
16use prov_graph::meta::Mapping;
17use prov_graph::{Error, Result};
18
19fn 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
39pub enum MetaEditor {
42 Fenced(Embed),
44 Whole(fig::Editor),
46}
47
48impl MetaEditor {
49 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 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 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 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 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 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 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 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 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 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 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
163pub 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
175pub 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
194pub 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 Some(Segment::Index(_)) => editor.replace_value(&path, value)?,
209 _ => editor.set_value(&path, value)?,
210 }
211 editor.render()
212}
213
214pub 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#[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
265pub 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
276pub 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 if !inner.ends_with('\n') {
304 inner.push('\n');
305 }
306 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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(
739 value_at(&doc.meta, "title").and_then(prov_graph::meta::Value::as_str),
740 Some("t"),
741 "{out}"
742 );
743 }
744}