#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrontMatter {
pub kind: Option<String>,
pub id: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub body: String,
}
#[derive(Debug, thiserror::Error)]
pub enum FrontMatterError {
#[error("invalid yaml: {0}")]
InvalidYaml(String),
#[error("unterminated front matter")]
Unterminated,
}
pub fn split(text: &str) -> Result<Option<(&str, &str)>, FrontMatterError> {
let first_end = text.find('\n').unwrap_or(text.len());
if text[..first_end].trim_end_matches('\r') != "---" {
return Ok(None);
}
if first_end == text.len() {
return Err(FrontMatterError::Unterminated);
}
let yaml_start = first_end + 1;
let mut pos = yaml_start;
loop {
let end = text[pos..]
.find('\n')
.map(|i| pos + i)
.unwrap_or(text.len());
if text[pos..end].trim_end_matches('\r') == "---" {
let yaml = if pos == yaml_start {
""
} else {
&text[yaml_start..pos - 1]
};
let body = if end < text.len() {
&text[end + 1..]
} else {
""
};
return Ok(Some((yaml, body)));
}
if end == text.len() {
return Err(FrontMatterError::Unterminated);
}
pos = end + 1;
}
}
pub fn parse(text: &str) -> Result<Option<FrontMatter>, FrontMatterError> {
let Some((yaml_text, body)) = split(text)? else {
return Ok(None);
};
let body = body.to_string();
let map: std::collections::BTreeMap<String, serde_yaml_ng::Value> =
if yaml_text.trim().is_empty() {
std::collections::BTreeMap::new()
} else {
serde_yaml_ng::from_str(yaml_text)
.map_err(|e| FrontMatterError::InvalidYaml(e.to_string()))?
};
let kind = map.get("type").map(value_to_string);
let id = map.get("id").map(value_to_string);
let title = map.get("title").map(value_to_string);
let description = map.get("description").map(value_to_string);
Ok(Some(FrontMatter {
kind,
id,
title,
description,
body,
}))
}
fn value_to_string(value: &serde_yaml_ng::Value) -> String {
match value {
serde_yaml_ng::Value::String(s) => s.clone(),
serde_yaml_ng::Value::Null => String::new(),
other => {
serde_yaml_ng::to_string(other)
.unwrap_or_default()
.trim()
.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_だけを変えても本文bodyは変わらない() {
let before = "---\ntype: Requirement\nid: FR-001\n---\n本文です。\n";
let after = "---\ntype: Requirement\nid: FR-002\n---\n本文です。\n";
let fm_before = parse(before).unwrap().unwrap();
let fm_after = parse(after).unwrap().unwrap();
assert_eq!(fm_before.body, fm_after.body);
assert_ne!(fm_before.id, fm_after.id);
}
#[test]
fn bodyにはフロントマターのブロックが含まれない() {
let text = "---\ntype: Requirement\nid: FR-001\n---\n本文です。\n";
let fm = parse(text).unwrap().unwrap();
assert!(!fm.body.contains("id: FR-001"));
assert!(!fm.body.contains("type: Requirement"));
assert!(fm.body.contains("本文です。"));
}
#[test]
fn ハイフン3つで始まらないテキストはnoneになる() {
let text = "# ただのMarkdown\n本文です。\n";
assert_eq!(parse(text).unwrap(), None);
}
#[test]
fn typeフィールドがない場合はkindがnoneになる() {
let text = "---\nid: FR-001\n---\n本文です。\n";
let fm = parse(text).unwrap().unwrap();
assert_eq!(fm.kind, None);
}
#[test]
fn splitは閉じ無しや空など境界の入力を正しく分ける() {
fn sp(t: &str) -> Result<Option<(&str, &str)>, ()> {
split(t).map_err(|_| ())
}
assert_eq!(sp("---"), Err(()));
assert_eq!(sp("---\n"), Err(()));
assert_eq!(sp("---\na: 1\n"), Err(()));
assert_eq!(sp("---\n---\n"), Ok(Some(("", ""))));
assert_eq!(sp("---\na: 1\n---"), Ok(Some(("a: 1", ""))));
assert_eq!(
sp("---\r\na: 1\r\n---\r\nbody\r\n"),
Ok(Some(("a: 1\r", "body\r\n")))
);
assert_eq!(
sp("---\na: 1\n---\nx\n---\ny\n"),
Ok(Some(("a: 1", "x\n---\ny\n")))
);
assert_eq!(sp("本文\n---\n"), Ok(None));
}
#[test]
fn parseのbodyはsplitの本文と一致する() {
for text in [
"---\n---\n",
"---\na: 1\n---",
"---\r\na: 1\r\n---\r\nbody\r\n",
"---\na: 1\n---\nx\n---\ny\n",
] {
let (_, body) = split(text).unwrap().unwrap();
assert_eq!(parse(text).unwrap().unwrap().body, body, "{text:?}");
}
}
}