#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Frontmatter {
entries: Vec<(String, String)>,
}
impl Frontmatter {
pub fn new() -> Frontmatter {
Frontmatter::default()
}
pub fn get(&self, key: &str) -> Option<&str> {
self.entries
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
pub fn get_list(&self, key: &str) -> Option<Vec<String>> {
let raw = self.get(key)?.trim();
let inner = raw.strip_prefix('[')?.strip_suffix(']')?;
if inner.trim().is_empty() {
return Some(Vec::new());
}
Some(
inner
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
)
}
pub fn set(&mut self, key: &str, value: impl Into<String>) {
let value = value.into();
if let Some(slot) = self.entries.iter_mut().find(|(k, _)| k == key) {
slot.1 = value;
} else {
self.entries.push((key.to_string(), value));
}
}
pub fn set_list(&mut self, key: &str, values: &[String]) {
self.set(key, format!("[{}]", values.join(", ")));
}
pub fn remove(&mut self, key: &str) {
self.entries.retain(|(k, _)| k != key);
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.entries.iter().map(|(k, _)| k.as_str())
}
pub fn render(&self) -> String {
let mut out = String::from("---\n");
for (k, v) in &self.entries {
out.push_str(k);
out.push_str(": ");
out.push_str(v);
out.push('\n');
}
out.push_str("---\n");
out
}
}
pub fn parse(doc: &str) -> Option<(Frontmatter, &str)> {
let rest = doc.strip_prefix("---")?;
let rest = rest.strip_prefix('\r').unwrap_or(rest);
let rest = rest.strip_prefix('\n')?;
let mut fm = Frontmatter::new();
let mut offset = 0usize;
for line in rest.split_inclusive('\n') {
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed == "---" {
let body = &rest[offset + line.len()..];
return Some((fm, body));
}
offset += line.len();
if trimmed.trim().is_empty() || trimmed.trim_start().starts_with('#') {
continue;
}
if trimmed.starts_with([' ', '\t']) {
return None;
}
let (key, value) = trimmed.split_once(':')?;
let key = key.trim();
let value = value.trim();
if key.is_empty() || key.contains(char::is_whitespace) || value.is_empty() {
return None; }
fm.set(key, value);
}
None }
pub fn body_bytes(doc: &str) -> usize {
parse(doc).map_or(doc.len(), |(_, body)| body.len())
}
#[cfg(test)]
mod tests {
use super::*;
const DOC: &str = "---\nmap: 1\nscope: src/auth\nchildren: [jwt/MAP.md, oauth/MAP.md]\nx-custom: kept\n---\n# body\n";
#[test]
fn parses_and_round_trips() {
let (fm, body) = parse(DOC).expect("parse");
assert_eq!(fm.get("map"), Some("1"));
assert_eq!(fm.get("scope"), Some("src/auth"));
assert_eq!(
fm.get_list("children"),
Some(vec!["jwt/MAP.md".to_string(), "oauth/MAP.md".to_string()])
);
assert_eq!(fm.get("x-custom"), Some("kept"), "unknown keys preserved");
assert_eq!(body, "# body\n");
assert_eq!(fm.render() + body, DOC, "byte-exact round trip");
}
#[test]
fn rejects_non_subset_yaml() {
assert!(
parse("---\nnested:\n a: 1\n---\n").is_none(),
"nested key rejected"
);
assert!(parse("---\nno fence").is_none(), "unterminated");
assert!(parse("no frontmatter").is_none());
}
#[test]
fn empty_list_and_missing_keys() {
let (fm, _) = parse("---\nchildren: []\n---\n").expect("parse");
assert_eq!(fm.get_list("children"), Some(vec![]));
assert_eq!(fm.get("absent"), None);
assert_eq!(fm.get_list("absent"), None);
}
#[test]
fn set_and_render_canonical() {
let mut fm = Frontmatter::new();
fm.set("map", "1");
fm.set_list("children", &["a/MAP.md".into()]);
fm.set("map", "1"); assert_eq!(fm.render(), "---\nmap: 1\nchildren: [a/MAP.md]\n---\n");
}
#[test]
fn body_bytes_excludes_frontmatter() {
assert_eq!(body_bytes(DOC), "# body\n".len());
assert_eq!(body_bytes("just body"), "just body".len());
}
#[test]
fn keys_iterate_in_insertion_order() {
let (fm, _) = parse(DOC).expect("parse");
let keys: Vec<&str> = fm.keys().collect();
assert_eq!(keys, vec!["map", "scope", "children", "x-custom"]);
}
}