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 rendered = Embed::open_or_init(body.as_bytes(), target)?
305 .render()?
306 .to_string();
307 retype_block(&rendered, target, mapping, target)
308}
309
310pub 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 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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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}