use serde_json::{Map, Value};
use crate::error::DocError;
const DELIMITER: &str = "---";
pub(crate) fn split_frontmatter(input: &str) -> Result<(Value, &str), DocError> {
let Some(after_open) = opening_delimiter_body(input) else {
return Ok((Value::Object(Map::new()), input));
};
let closing = find_closing_delimiter(after_open)
.ok_or_else(|| DocError::Frontmatter("missing closing --- delimiter".to_string()))?;
let block = &after_open[..closing.block_end];
let body = &after_open[closing.body_start..];
let mut map = Map::new();
for line in block.lines() {
if line.trim().is_empty() {
continue;
}
let (key, value) = parse_line(line)?;
if map.insert(key.clone(), value).is_some() {
return Err(DocError::Frontmatter(format!("duplicate key: {key}")));
}
}
Ok((Value::Object(map), body))
}
fn opening_delimiter_body(input: &str) -> Option<&str> {
let after_marker = input.strip_prefix(DELIMITER)?;
let newline_pos = after_marker.find('\n')?;
let rest_of_line = after_marker[..newline_pos].trim_end_matches('\r');
if !rest_of_line.is_empty() {
return None;
}
Some(&after_marker[newline_pos + 1..])
}
struct ClosingDelimiter {
block_end: usize,
body_start: usize,
}
fn find_closing_delimiter(text: &str) -> Option<ClosingDelimiter> {
let mut offset = 0;
for line in text.split_inclusive('\n') {
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed == DELIMITER {
return Some(ClosingDelimiter {
block_end: offset,
body_start: offset + line.len(),
});
}
offset += line.len();
}
None
}
fn parse_line(line: &str) -> Result<(String, Value), DocError> {
let (key, raw_value) = line
.split_once(':')
.ok_or_else(|| DocError::Frontmatter(format!("expected 'key: value', got: {line}")))?;
let key = key.trim();
if key.is_empty() {
return Err(DocError::Frontmatter(format!("empty key in line: {line}")));
}
let value = parse_value(raw_value.trim())?;
Ok((key.to_string(), value))
}
fn parse_value(raw: &str) -> Result<Value, DocError> {
if let Some(inner) = raw.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
let items = inner
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(parse_scalar)
.collect::<Result<Vec<_>, _>>()?;
return Ok(Value::Array(items));
}
parse_scalar(raw)
}
fn parse_scalar(raw: &str) -> Result<Value, DocError> {
if raw.is_empty() {
return Err(DocError::Frontmatter("empty value".to_string()));
}
if let Some(inner) = raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
if inner.contains('"') {
return Err(DocError::Frontmatter(format!(
"unescaped quote in string: {raw}"
)));
}
return Ok(Value::String(inner.to_string()));
}
match raw {
"true" => Ok(Value::Bool(true)),
"false" => Ok(Value::Bool(false)),
_ => Ok(Value::String(raw.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_title_string_and_list_fields() {
let input = "---\ntitle: Getting Started\nauthor: \"Jane Doe\"\ntags: [rust, docs, cli]\n---\n# Body\n";
let (frontmatter, body) = split_frontmatter(input).expect("valid frontmatter");
assert_eq!(
frontmatter,
json!({
"title": "Getting Started",
"author": "Jane Doe",
"tags": ["rust", "docs", "cli"],
})
);
assert_eq!(body, "# Body\n");
}
#[test]
fn no_leading_delimiter_returns_empty_frontmatter_and_full_body() {
let input = "# Just Markdown\nNo frontmatter here.\n";
let (frontmatter, body) = split_frontmatter(input).expect("no frontmatter is not an error");
assert_eq!(frontmatter, json!({}));
assert_eq!(body, input);
}
#[test]
fn unterminated_block_is_a_frontmatter_error() {
let input = "---\ntitle: Unterminated\n# Body without closing delimiter\n";
let err = split_frontmatter(input).expect_err("missing closing delimiter must error");
assert!(matches!(err, DocError::Frontmatter(_)));
}
#[test]
fn empty_frontmatter_block_yields_empty_object() {
let input = "---\n---\nbody text\n";
let (frontmatter, body) = split_frontmatter(input).expect("empty block is valid");
assert_eq!(frontmatter, json!({}));
assert_eq!(body, "body text\n");
}
#[test]
fn duplicate_key_is_a_frontmatter_error() {
let input = "---\ntitle: One\ntitle: Two\n---\nbody\n";
let err = split_frontmatter(input).expect_err("duplicate key must error");
assert!(matches!(err, DocError::Frontmatter(_)));
}
#[test]
fn bare_true_and_false_parse_as_booleans() {
let input = "---\npinned: true\ndraft: false\n---\nbody\n";
let (frontmatter, _) = split_frontmatter(input).expect("valid frontmatter");
assert_eq!(frontmatter, json!({"pinned": true, "draft": false}));
}
#[test]
fn quoted_true_stays_a_string() {
let input = "---\nnote: \"true\"\n---\nbody\n";
let (frontmatter, _) = split_frontmatter(input).expect("valid frontmatter");
assert_eq!(frontmatter, json!({"note": "true"}));
}
#[test]
fn version_number_stays_a_string_by_design() {
let input = "---\nversion: 2\n---\nbody\n";
let (frontmatter, _) = split_frontmatter(input).expect("valid frontmatter");
assert_eq!(frontmatter, json!({"version": "2"}));
}
}