use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frontmatter {
pub fields: BTreeMap<String, String>,
pub body: String,
}
impl Frontmatter {
pub fn get(&self, key: &str) -> Option<&str> {
self.fields.get(key).map(String::as_str)
}
}
pub fn quote(value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
pub fn parse(raw: &str) -> Option<Frontmatter> {
let mut lines = raw.split_inclusive('\n');
let first = lines.next()?;
if !is_fence(first) {
return None;
}
let mut fields = BTreeMap::new();
let mut consumed = first.len();
let mut closed = false;
for line in lines {
consumed += line.len();
if is_fence(line) {
closed = true;
break;
}
if let Some((key, value)) = line.trim_end_matches(['\r', '\n']).split_once(':') {
fields.insert(key.trim().to_string(), unquote(value.trim()));
}
}
if !closed {
return None;
}
let body = raw[consumed..].trim_start_matches(['\r', '\n']).to_string();
Some(Frontmatter { fields, body })
}
fn is_fence(line: &str) -> bool {
line.trim_end_matches(['\r', '\n']).trim() == "---"
}
fn unquote(value: &str) -> String {
let v = value.trim();
if v.len() >= 2 && v.starts_with('"') && v.ends_with('"') {
v[1..v.len() - 1]
.replace("\\\"", "\"")
.replace("\\\\", "\\")
} else if v.len() >= 2 && v.starts_with('\'') && v.ends_with('\'') {
v[1..v.len() - 1].replace("''", "'")
} else {
v.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_escapes_quotes_and_backslashes() {
assert_eq!(quote("plain"), "\"plain\"");
assert_eq!(quote("a\"b"), "\"a\\\"b\"");
assert_eq!(quote("a\\b"), "\"a\\\\b\"");
}
#[test]
fn quote_then_parse_roundtrips_colon_names() {
let name = "asobi:decision:no-pwa";
let raw = format!("---\ntitle: {}\n---\n\nbody\n", quote(name));
let fm = parse(&raw).expect("frontmatter parses");
assert_eq!(fm.get("title"), Some(name));
assert_eq!(fm.body, "body\n");
}
#[test]
fn parse_unquotes_double_and_single() {
let raw = "---\nname: \"my-skill\"\ndescription: 'it''s fine'\n---\nbody";
let fm = parse(raw).unwrap();
assert_eq!(fm.get("name"), Some("my-skill"));
assert_eq!(fm.get("description"), Some("it's fine"));
}
#[test]
fn parse_returns_none_without_leading_fence() {
assert_eq!(parse("no frontmatter here"), None);
}
#[test]
fn parse_returns_none_when_fence_unclosed() {
assert_eq!(parse("---\ntitle: x\nno closing fence\n"), None);
}
#[test]
fn parse_preserves_body_thematic_break() {
let raw = "---\ntitle: Foo\n---\n\nIntro\n\n---\n\nMore body\n";
let fm = parse(raw).unwrap();
assert_eq!(fm.get("title"), Some("Foo"));
assert!(fm.body.starts_with("Intro"));
assert!(
fm.body.contains("---"),
"thematic break dropped: {}",
fm.body
);
assert!(fm.body.contains("More body"));
}
#[test]
fn parse_tolerates_crlf() {
let raw = "---\r\ntitle: \"a:b\"\r\n---\r\nbody line\r\n";
let fm = parse(raw).unwrap();
assert_eq!(fm.get("title"), Some("a:b"));
assert_eq!(fm.body, "body line\r\n");
}
#[test]
fn parse_missing_key_is_none_not_empty() {
let raw = "---\nname: partial\n---\nbody";
let fm = parse(raw).unwrap();
assert_eq!(fm.get("name"), Some("partial"));
assert_eq!(fm.get("description"), None);
}
}