use fig::{Embed, EmbedType, Segment};
use crate::document::MetaCarrier;
use crate::error::{Error, Result};
use crate::meta::Mapping;
fn default_embed_type() -> EmbedType {
#[cfg(feature = "yaml")]
return EmbedType::FrontmatterYaml;
#[cfg(all(not(feature = "yaml"), feature = "json"))]
return EmbedType::FrontmatterJson;
#[cfg(all(not(feature = "yaml"), not(feature = "json"), feature = "fig-lang"))]
return EmbedType::FrontmatterFig;
}
pub enum MetaEditor {
Fenced(Embed),
Whole(fig::Editor),
}
impl MetaEditor {
pub fn open(text: &str, carrier: MetaCarrier) -> Result<Self> {
Ok(match carrier {
MetaCarrier::Fenced(kind) => MetaEditor::Fenced(Embed::open(text.as_bytes(), kind)?),
MetaCarrier::WholeFile(format) => {
MetaEditor::Whole(fig::Editor::open(text.as_bytes(), format)?)
}
})
}
pub fn open_or_init(text: &str, carrier: Option<MetaCarrier>) -> Result<Self> {
Ok(match carrier {
Some(MetaCarrier::WholeFile(format)) => {
MetaEditor::Whole(fig::Editor::open(text.as_bytes(), format)?)
}
Some(MetaCarrier::Fenced(kind)) => {
MetaEditor::Fenced(Embed::open_or_init(text.as_bytes(), kind)?)
}
None => MetaEditor::Fenced(Embed::open_or_init(text.as_bytes(), default_embed_type())?),
})
}
pub fn set_value(&mut self, path: &[Segment], value: impl Into<fig::Value>) -> Result<()> {
match self {
MetaEditor::Fenced(e) => e.set_value(path, value)?,
MetaEditor::Whole(e) => e.set_value(path, value)?,
}
Ok(())
}
pub fn replace_value(&mut self, path: &[Segment], value: impl Into<fig::Value>) -> Result<()> {
match self {
MetaEditor::Fenced(e) => e.replace_value(path, value)?,
MetaEditor::Whole(e) => e.replace_value(path, value)?,
}
Ok(())
}
pub fn replace_key(&mut self, path: &[Segment], key: &str) -> Result<()> {
match self {
MetaEditor::Fenced(e) => e.replace_key(path, key)?,
MetaEditor::Whole(e) => e.replace_key(path, key)?,
}
Ok(())
}
pub fn append_value(&mut self, path: &[Segment], value: impl Into<fig::Value>) -> Result<()> {
match self {
MetaEditor::Fenced(e) => e.append_value(path, value)?,
MetaEditor::Whole(e) => e.append_value(path, value)?,
}
Ok(())
}
pub fn delete(&mut self, path: &[Segment]) -> Result<()> {
match self {
MetaEditor::Fenced(e) => e.delete(path)?,
MetaEditor::Whole(e) => e.delete(path)?,
}
Ok(())
}
pub fn remove_item(&mut self, path: &[Segment], index: usize) -> Result<()> {
match self {
MetaEditor::Fenced(e) => e.remove_item(path, index)?,
MetaEditor::Whole(e) => e.remove_item(path, index)?,
}
Ok(())
}
pub fn reorder_keys<S: AsRef<str>>(&mut self, path: &[Segment], keys: &[S]) -> Result<()> {
match self {
MetaEditor::Fenced(e) => e.reorder_keys(path, keys)?,
MetaEditor::Whole(e) => e.reorder_keys(path, keys)?,
}
Ok(())
}
pub fn reorder_items(&mut self, path: &[Segment], indices: &[usize]) -> Result<()> {
match self {
MetaEditor::Fenced(e) => e.reorder_items(path, indices)?,
MetaEditor::Whole(e) => e.reorder_items(path, indices)?,
}
Ok(())
}
pub fn render(&mut self) -> Result<String> {
Ok(match self {
MetaEditor::Fenced(e) => e.render()?.to_string(),
MetaEditor::Whole(e) => e.source()?.to_string(),
})
}
}
pub fn key_path(dotted: &str) -> Vec<Segment<'_>> {
dotted
.split('.')
.map(|part| match part.parse::<usize>() {
Ok(index) => Segment::Index(index),
Err(_) => Segment::Key(part),
})
.collect()
}
pub fn infer_scalar(s: &str) -> fig::Value {
match s {
"true" => fig::Value::Bool(true),
"false" => fig::Value::Bool(false),
"null" | "~" => fig::Value::Null,
_ => {
if let Ok(i) = s.parse::<i64>() {
fig::Value::Int(i)
} else if let Ok(f) = s.parse::<f64>() {
fig::Value::Float(f)
} else {
fig::Value::Str(s.to_string())
}
}
}
}
pub fn set_in_text(
text: &str,
carrier: Option<MetaCarrier>,
dotted: &str,
value: fig::Value,
) -> Result<String> {
let mut editor = MetaEditor::open_or_init(text, carrier)?;
let path = key_path(dotted);
match path.last() {
Some(Segment::Index(_)) => editor.replace_value(&path, value)?,
_ => editor.set_value(&path, value)?,
}
editor.render()
}
pub fn set_meta_in_text(
text: &str,
carrier: Option<MetaCarrier>,
dotted: &str,
value: &crate::meta::Value,
) -> Result<String> {
set_in_text(text, carrier, dotted, fig::Value::from(value))
}
pub fn unset_in_text(text: &str, carrier: Option<MetaCarrier>, dotted: &str) -> Result<String> {
let carrier = carrier
.ok_or_else(|| Error::Structure("document has no embedded metadata block".into()))?;
let mut editor = MetaEditor::open(text, carrier)?;
editor.delete(&key_path(dotted))?;
editor.render()
}
pub fn reformat_block(body: &str, mapping: &Mapping, target: EmbedType) -> Result<String> {
let mut inner = crate::meta::serialize_mapping(mapping, target.inner_format())?;
if !inner.ends_with('\n') {
inner.push('\n');
}
let rendered = Embed::open_or_init(body.as_bytes(), target)?
.render()?
.to_string();
let content = Embed::extract(&rendered, target)?.region().content;
let mut out = String::with_capacity(rendered.len() + inner.len());
out.push_str(&rendered[..content.start]);
out.push_str(&inner);
out.push_str(&rendered[content.end..]);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn carrier_of(path: &str, text: &str) -> Option<MetaCarrier> {
crate::document::Document::parse(path, text)
.unwrap()
.carrier
}
#[cfg(feature = "yaml")]
#[test]
fn set_preserves_comments_and_format() {
let text = "---\n# keep me\ntitle: Old\n---\nbody\n";
let out =
set_in_text(text, carrier_of("x.md", text), "title", infer_scalar("New")).unwrap();
assert_eq!(out, "---\n# keep me\ntitle: New\n---\nbody\n");
}
#[cfg(feature = "fig-lang")]
#[test]
fn set_in_a_fig_block_stays_fig() {
let text = "```fig\ntitle = prov\n```\nbody\n";
let out = set_in_text(
text,
carrier_of("x.md", text),
"title",
infer_scalar("renamed"),
)
.unwrap();
assert!(out.starts_with("```fig\n"), "fence preserved: {out}");
assert!(
out.contains("title = renamed"),
"fig dialect preserved: {out}"
);
assert!(out.ends_with("```\nbody\n"));
}
#[cfg(feature = "yaml")]
#[test]
fn set_edits_a_bare_config_document() {
let text = "# workspace registry\ntitle: ID registry\nregistry:\n abc: a.md\n";
let out = set_in_text(
text,
carrier_of("registry.yaml", text),
"registry.abc",
infer_scalar("moved/a.md"),
)
.unwrap();
assert!(out.contains("# workspace registry"), "comment kept: {out}");
assert!(out.contains("abc: moved/a.md"), "{out}");
assert!(!out.contains("---"), "no fences grown: {out}");
}
#[cfg(feature = "yaml")]
#[test]
fn set_creates_a_block_when_none_exists() {
let out = set_in_text("just a body\n", None, "title", infer_scalar("T")).unwrap();
assert!(out.starts_with("---\ntitle: T\n---\n"), "{out}");
assert!(out.ends_with("just a body\n"));
}
#[cfg(feature = "yaml")]
#[test]
fn unset_removes_only_the_named_key() {
let text = "---\ntitle: T\ndraft: true\n---\nbody\n";
let out = unset_in_text(text, carrier_of("x.md", text), "draft").unwrap();
assert_eq!(out, "---\ntitle: T\n---\nbody\n");
assert!(unset_in_text("no meta\n", None, "x").is_err());
}
#[test]
fn scalars_are_inferred() {
assert_eq!(infer_scalar("true"), fig::Value::Bool(true));
assert_eq!(infer_scalar("42"), fig::Value::Int(42));
assert_eq!(infer_scalar("4.5"), fig::Value::Float(4.5));
assert_eq!(infer_scalar("null"), fig::Value::Null);
assert_eq!(infer_scalar("hello"), fig::Value::Str("hello".into()));
}
#[cfg(feature = "yaml")]
#[test]
fn dotted_paths_mix_keys_and_indices() {
let text = "---\ncontents:\n- a.md\n- b.md\n---\n";
let out = set_in_text(
text,
carrier_of("x.md", text),
"contents.1",
infer_scalar("c.md"),
)
.unwrap();
assert!(out.contains("- a.md\n- c.md"), "{out}");
}
#[cfg(feature = "yaml")]
#[test]
fn replace_key_renames_the_key_and_preserves_comments_elsewhere() {
let text = "---\n# keep me\ntitle: Old\nauthor: me\n---\nbody\n";
let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
editor.replace_key(&key_path("title"), "name").unwrap();
let out = editor.render().unwrap();
assert!(out.contains("name: Old"), "{out}");
assert!(!out.contains("title:"), "{out}");
assert!(out.contains("# keep me"), "comment lost: {out}");
assert!(out.contains("author: me"), "{out}");
}
#[cfg(feature = "yaml")]
#[test]
fn reorder_keys_moves_listed_keys_first_and_preserves_comments() {
let text = "---\n# c1\ntitle: T\n# c2\nauthor: me\ndraft: true\n---\nbody\n";
let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
editor
.reorder_keys(&[] as &[Segment], &["draft", "title"])
.unwrap();
let out = editor.render().unwrap();
let draft_pos = out.find("draft:").unwrap();
let title_pos = out.find("title:").unwrap();
let author_pos = out.find("author:").unwrap();
assert!(draft_pos < title_pos && title_pos < author_pos, "{out}");
assert!(out.contains("# c1"), "comment lost: {out}");
assert!(out.contains("# c2"), "comment lost: {out}");
}
#[cfg(feature = "yaml")]
#[test]
fn reorder_items_moves_listed_items_first_and_preserves_comments() {
let text = "---\ncontents:\n- a # keep a\n- b # keep b\n- c # keep c\n---\nbody\n";
let mut editor = MetaEditor::open(text, carrier_of("x.md", text).unwrap()).unwrap();
editor
.reorder_items(&key_path("contents"), &[2, 0])
.unwrap();
let out = editor.render().unwrap();
assert!(out.contains("# keep a"), "comment lost: {out}");
assert!(out.contains("# keep b"), "comment lost: {out}");
assert!(out.contains("# keep c"), "comment lost: {out}");
let a_pos = out.find("- a").unwrap();
let b_pos = out.find("- b").unwrap();
let c_pos = out.find("- c").unwrap();
assert!(c_pos < a_pos && a_pos < b_pos, "{out}");
}
#[cfg(feature = "yaml")]
#[test]
fn reorder_keys_works_on_a_whole_file_config_document() {
let text =
"# workspace registry\ntitle: ID registry\npart_of: index.md\nregistry:\n abc: a.md\n";
let mut editor =
MetaEditor::open(text, carrier_of("registry.yaml", text).unwrap()).unwrap();
editor
.reorder_keys(&[] as &[Segment], &["part_of"])
.unwrap();
let out = editor.render().unwrap();
let part_of_pos = out.find("part_of:").unwrap();
let title_pos = out.find("title:").unwrap();
assert!(part_of_pos < title_pos, "{out}");
assert!(out.contains("# workspace registry"), "comment lost: {out}");
}
}