1#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct FrontMatter {
5 pub kind: Option<String>,
6 pub id: Option<String>,
7 pub title: Option<String>,
8 pub description: Option<String>,
9 pub body: String,
10}
11
12#[derive(Debug, thiserror::Error)]
13pub enum FrontMatterError {
14 #[error("invalid yaml: {0}")]
15 InvalidYaml(String),
16 #[error("unterminated front matter")]
17 Unterminated,
18}
19
20pub fn split(text: &str) -> Result<Option<(&str, &str)>, FrontMatterError> {
26 let first_end = text.find('\n').unwrap_or(text.len());
27 if text[..first_end].trim_end_matches('\r') != "---" {
28 return Ok(None);
29 }
30 if first_end == text.len() {
31 return Err(FrontMatterError::Unterminated);
32 }
33 let yaml_start = first_end + 1;
34 let mut pos = yaml_start;
35 loop {
36 let end = text[pos..]
37 .find('\n')
38 .map(|i| pos + i)
39 .unwrap_or(text.len());
40 if text[pos..end].trim_end_matches('\r') == "---" {
41 let yaml = if pos == yaml_start {
42 ""
43 } else {
44 &text[yaml_start..pos - 1]
45 };
46 let body = if end < text.len() {
47 &text[end + 1..]
48 } else {
49 ""
50 };
51 return Ok(Some((yaml, body)));
52 }
53 if end == text.len() {
54 return Err(FrontMatterError::Unterminated);
55 }
56 pos = end + 1;
57 }
58}
59
60pub fn parse(text: &str) -> Result<Option<FrontMatter>, FrontMatterError> {
63 let Some((yaml_text, body)) = split(text)? else {
64 return Ok(None);
65 };
66 let body = body.to_string();
67
68 let map: std::collections::BTreeMap<String, serde_yaml_ng::Value> =
69 if yaml_text.trim().is_empty() {
70 std::collections::BTreeMap::new()
71 } else {
72 serde_yaml_ng::from_str(yaml_text)
73 .map_err(|e| FrontMatterError::InvalidYaml(e.to_string()))?
74 };
75
76 let kind = map.get("type").map(value_to_string);
77 let id = map.get("id").map(value_to_string);
78
79 let title = map.get("title").map(value_to_string);
80 let description = map.get("description").map(value_to_string);
81
82 Ok(Some(FrontMatter {
83 kind,
84 id,
85 title,
86 description,
87 body,
88 }))
89}
90
91fn value_to_string(value: &serde_yaml_ng::Value) -> String {
92 match value {
93 serde_yaml_ng::Value::String(s) => s.clone(),
94 serde_yaml_ng::Value::Null => String::new(),
95 other => {
96 serde_yaml_ng::to_string(other)
98 .unwrap_or_default()
99 .trim()
100 .to_string()
101 }
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
111 fn id_だけを変えても本文bodyは変わらない() {
112 let before = "---\ntype: Requirement\nid: FR-001\n---\n本文です。\n";
113 let after = "---\ntype: Requirement\nid: FR-002\n---\n本文です。\n";
114 let fm_before = parse(before).unwrap().unwrap();
115 let fm_after = parse(after).unwrap().unwrap();
116 assert_eq!(fm_before.body, fm_after.body);
117 assert_ne!(fm_before.id, fm_after.id);
118 }
119
120 #[test]
122 fn bodyにはフロントマターのブロックが含まれない() {
123 let text = "---\ntype: Requirement\nid: FR-001\n---\n本文です。\n";
124 let fm = parse(text).unwrap().unwrap();
125 assert!(!fm.body.contains("id: FR-001"));
126 assert!(!fm.body.contains("type: Requirement"));
127 assert!(fm.body.contains("本文です。"));
128 }
129
130 #[test]
132 fn ハイフン3つで始まらないテキストはnoneになる() {
133 let text = "# ただのMarkdown\n本文です。\n";
134 assert_eq!(parse(text).unwrap(), None);
135 }
136
137 #[test]
139 fn typeフィールドがない場合はkindがnoneになる() {
140 let text = "---\nid: FR-001\n---\n本文です。\n";
141 let fm = parse(text).unwrap().unwrap();
142 assert_eq!(fm.kind, None);
143 }
144
145 #[test]
147 fn splitは閉じ無しや空など境界の入力を正しく分ける() {
148 fn sp(t: &str) -> Result<Option<(&str, &str)>, ()> {
149 split(t).map_err(|_| ())
150 }
151 assert_eq!(sp("---"), Err(()));
152 assert_eq!(sp("---\n"), Err(()));
153 assert_eq!(sp("---\na: 1\n"), Err(()));
154 assert_eq!(sp("---\n---\n"), Ok(Some(("", ""))));
155 assert_eq!(sp("---\na: 1\n---"), Ok(Some(("a: 1", ""))));
156 assert_eq!(
157 sp("---\r\na: 1\r\n---\r\nbody\r\n"),
158 Ok(Some(("a: 1\r", "body\r\n")))
159 );
160 assert_eq!(
161 sp("---\na: 1\n---\nx\n---\ny\n"),
162 Ok(Some(("a: 1", "x\n---\ny\n")))
163 );
164 assert_eq!(sp("本文\n---\n"), Ok(None));
165 }
166
167 #[test]
169 fn parseのbodyはsplitの本文と一致する() {
170 for text in [
171 "---\n---\n",
172 "---\na: 1\n---",
173 "---\r\na: 1\r\n---\r\nbody\r\n",
174 "---\na: 1\n---\nx\n---\ny\n",
175 ] {
176 let (_, body) = split(text).unwrap().unwrap();
177 assert_eq!(parse(text).unwrap().unwrap().body, body, "{text:?}");
178 }
179 }
180}