use super::{Parsed, parse, render, split};
use anyhow::{Result, bail};
use serde_json::{Map, Value};
pub fn rewrite(text: &str, change: impl FnOnce(&mut Map<String, Value>)) -> Result<String> {
let s = split(text);
let mut map = match parse(s.raw) {
Parsed::Map(m) => m,
Parsed::None => Map::new(),
Parsed::Invalid(e) => bail!("frontmatter does not parse: {e}"),
};
change(&mut map);
Ok(format!("{}{}", render(&map), s.body))
}
pub fn rewrite_keeping(
text: &str,
renames: &[(&str, &str)],
change: impl FnOnce(&mut Map<String, Value>),
) -> Result<String> {
let s = split(text);
let map = match parse(s.raw) {
Parsed::Map(m) => m,
Parsed::None => Map::new(),
Parsed::Invalid(e) => bail!("frontmatter does not parse: {e}"),
};
let mut new = map.clone();
change(&mut new);
if new == map {
return Ok(text.to_string());
}
if let Some(raw) = s.raw {
let patched = patch_raw(raw, &map, &new, renames);
if matches!(parse(Some(&patched)), Parsed::Map(m) if m == new) {
let head_len = text.len() - s.body.len();
let fence_end = &text[..head_len];
let start = fence_end.find(raw).unwrap_or(0);
return Ok(format!("{}{}{}{}", &fence_end[..start], patched, &fence_end[start + raw.len()..], s.body));
}
}
Ok(format!("{}{}", render(&new), s.body))
}
fn patch_raw(raw: &str, old: &Map<String, Value>, new: &Map<String, Value>, renames: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(raw.len());
for line in raw.split_inclusive('\n') {
let key = line.split_once(':').map(|(k, _)| k).filter(|k| !k.starts_with([' ', '\t', '-', '#']));
let changed = key.and_then(|k| {
new.get(k).filter(|v| old.get(k) != Some(*v) && !v.is_array() && !v.is_object()).map(|v| (k, v))
});
match changed {
Some((k, v)) => {
out.push_str(&one_key(k, v));
}
None => out.push_str(&rename_tokens(line, renames)),
}
}
for (k, v) in new.iter().filter(|(k, _)| !old.contains_key(*k)) {
out.push_str(&one_key(k, v));
}
out
}
fn one_key(k: &str, v: &Value) -> String {
let mut one = Map::new();
one.insert(k.to_string(), v.clone());
let r = render(&one);
r.strip_prefix("---\n").and_then(|r| r.strip_suffix("---\n")).unwrap_or(&r).to_string()
}
fn rename_tokens(line: &str, renames: &[(&str, &str)]) -> String {
let edge = |c: Option<char>| c.is_none_or(|c| matches!(c, ' ' | '[' | ']' | ',' | '\'' | '"' | '#' | '\n' | '\r'));
let mut s = line.to_string();
for (from, to) in renames {
let mut at = 0;
while let Some(i) = s[at..].find(from).map(|i| i + at) {
let end = i + from.len();
if edge(s[..i].chars().next_back()) && edge(s[end..].chars().next()) {
s.replace_range(i..end, to);
at = i + to.len();
} else {
at = end;
}
}
}
s
}
pub fn ordered(map: Map<String, Value>) -> Map<String, Value> {
const ORDER: &[&str] = &["id", "title", "status", "created", "updated", "owners", "tags", "aliases"];
let mut out = Map::new();
let mut rest = map;
for k in ORDER {
if let Some(v) = rest.remove(*k) {
out.insert((*k).to_string(), v);
}
}
let tail: Vec<(String, Value)> = rest.into_iter().collect();
let (late, early): (Vec<_>, Vec<_>) = tail.into_iter().partition(|(k, _)| k == "rel" || k == "files");
out.extend(early);
out.extend(late);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn e3_keeping_leaves_quotes_and_untouched_files() {
let text = "---\nid: sot:a/b\ntitle: 'Blog rules'\nrel:\n depends_on: [sot:x/y, 'sot:x/yz']\n---\nbody\n";
let same = rewrite_keeping(text, &[("sot:q/q", "sot:r/r")], |_| {}).unwrap();
assert_eq!(same, text);
let out = rewrite_keeping(text, &[("sot:x/y", "sot:z/y")], |m| {
crate::authoring::refs::rewrite_rel(m, "sot:x/y", "sot:z/y");
})
.unwrap();
assert_eq!(out, text.replace("[sot:x/y,", "[sot:z/y,"));
let out = rewrite_keeping(text, &[], |m| {
m.insert("updated".into(), Value::String("2026".into()));
m.insert("id".into(), Value::String("sot:c/d".into()));
})
.unwrap();
assert!(out.contains("title: 'Blog rules'\n") && out.contains("id: sot:c/d\n") && out.contains("updated:"));
}
#[test]
fn keeps_body() {
let text = "---\nid: a\nstatus: draft\n---\n# Title\n\nbody `with` --- dashes\n";
let out = rewrite(text, |m| {
m.insert("status".into(), Value::String("active".into()));
})
.unwrap();
assert!(out.ends_with("# Title\n\nbody `with` --- dashes\n"));
assert!(out.contains("status: active\n"));
}
}