use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct Frontmatter {
pub name: String,
pub description: String,
}
#[derive(Debug, Deserialize)]
struct RawFrontmatter {
name: Option<String>,
description: Option<String>,
}
#[derive(Debug, Clone)]
pub struct FrontmatterError {
pub message: String,
}
impl FrontmatterError {
pub(crate) fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
pub fn parse_frontmatter(contents: &str) -> Result<Frontmatter, FrontmatterError> {
let bounds = frontmatter_bounds(contents)
.ok_or_else(|| FrontmatterError::new("missing YAML frontmatter"))?;
let frontmatter = &contents[bounds.start..bounds.end];
let raw: RawFrontmatter = serde_yaml::from_str(frontmatter)
.map_err(|error| FrontmatterError::new(error.to_string()))?;
let name = raw.name.unwrap_or_default().trim().to_string();
if name.is_empty() {
return Err(FrontmatterError::new("missing required field 'name'"));
}
let description = raw.description.unwrap_or_default().trim().to_string();
if description.is_empty() {
return Err(FrontmatterError::new(
"missing required field 'description'",
));
}
Ok(Frontmatter { name, description })
}
#[derive(Debug, Clone, Copy)]
struct FrontmatterBounds {
start: usize,
end: usize,
}
fn frontmatter_bounds(contents: &str) -> Option<FrontmatterBounds> {
let mut offset = 0;
let mut lines = contents.split_inclusive('\n');
let first = lines.next()?;
if trim_line_endings(first) != "---" {
return None;
}
offset += first.len();
let start = offset;
for line in lines {
if trim_line_endings(line) == "---" {
return Some(FrontmatterBounds { start, end: offset });
}
offset += line.len();
}
None
}
fn trim_line_endings(line: &str) -> &str {
line.trim_end_matches(['\r', '\n'])
}
#[cfg(test)]
mod tests {
use super::{FrontmatterError, parse_frontmatter};
fn parse_error(contents: &str) -> FrontmatterError {
parse_frontmatter(contents).expect_err("frontmatter should fail")
}
#[test]
fn rejects_missing_frontmatter() {
let error = parse_error("# Title\n");
assert_eq!(error.message, "missing YAML frontmatter");
}
#[test]
fn rejects_missing_fields() {
let contents = "---\nname: example\n---\n";
let error = parse_error(contents);
assert_eq!(error.message, "missing required field 'description'");
}
#[test]
fn parses_required_fields() {
let contents = "---\nname: example\ndescription: test\n---\nBody";
let parsed = parse_frontmatter(contents).expect("frontmatter should parse");
assert_eq!(parsed.name, "example");
}
}