agent_first_data/document/format/
frontmatter.rs1use crate::document::{DocumentError, DocumentResult};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Delimiter {
23 Plus,
25 Dash,
27}
28
29impl Delimiter {
30 fn marker(self) -> &'static str {
32 match self {
33 Delimiter::Plus => "+++",
34 Delimiter::Dash => "---",
35 }
36 }
37
38 fn label(self) -> &'static str {
40 match self {
41 Delimiter::Plus => "TOML frontmatter",
42 Delimiter::Dash => "YAML frontmatter",
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct Parts<'a> {
53 pub pre: &'a str,
54 pub frontmatter: &'a str,
55 pub post: &'a str,
56}
57
58pub fn split(source: &str, delim: Delimiter) -> DocumentResult<Parts<'_>> {
65 let marker = delim.marker();
66
67 let first_line_end = source.find('\n');
68 let first_line = match first_line_end {
69 Some(nl) => &source[..nl],
70 None => source,
71 };
72 if first_line.trim_end() != marker {
73 return Err(DocumentError::ParseError {
74 format: delim.label().to_string(),
75 detail: format!("file does not begin with a `{marker}` frontmatter delimiter line"),
76 });
77 }
78 let Some(nl) = first_line_end else {
79 return Err(unterminated(delim));
81 };
82 let block_start = nl + 1;
83
84 let mut idx = block_start;
88 loop {
89 let line_end = source[idx..]
90 .find('\n')
91 .map(|offset| idx + offset)
92 .unwrap_or(source.len());
93 let line = &source[idx..line_end];
94 if line.trim_end() == marker {
95 return Ok(Parts {
96 pre: &source[..block_start],
97 frontmatter: &source[block_start..idx],
98 post: &source[idx..],
99 });
100 }
101 if line_end == source.len() {
102 return Err(unterminated(delim));
103 }
104 idx = line_end + 1;
105 }
106}
107
108fn unterminated(delim: Delimiter) -> DocumentError {
109 DocumentError::ParseError {
110 format: delim.label().to_string(),
111 detail: format!(
112 "unterminated frontmatter: no closing `{}` delimiter",
113 delim.marker()
114 ),
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
121 use super::*;
122
123 fn assert_roundtrip(source: &str, parts: &Parts<'_>) {
124 assert_eq!(
125 format!("{}{}{}", parts.pre, parts.frontmatter, parts.post),
126 source,
127 "spans must concatenate back to the source byte for byte"
128 );
129 }
130
131 #[test]
132 fn splits_toml_frontmatter_and_freezes_body() {
133 let source = "+++\ntitle = \"x\"\n+++\n# Body\n\nprose\n";
134 let parts = split(source, Delimiter::Plus).unwrap();
135 assert_eq!(parts.pre, "+++\n");
136 assert_eq!(parts.frontmatter, "title = \"x\"\n");
137 assert_eq!(parts.post, "+++\n# Body\n\nprose\n");
138 assert_roundtrip(source, &parts);
139 }
140
141 #[test]
142 fn splits_yaml_frontmatter() {
143 let source = "---\ntitle: x\n---\nbody\n";
144 let parts = split(source, Delimiter::Dash).unwrap();
145 assert_eq!(parts.pre, "---\n");
146 assert_eq!(parts.frontmatter, "title: x\n");
147 assert_eq!(parts.post, "---\nbody\n");
148 assert_roundtrip(source, &parts);
149 }
150
151 #[test]
152 fn empty_block_is_valid() {
153 let source = "+++\n+++\nbody\n";
154 let parts = split(source, Delimiter::Plus).unwrap();
155 assert_eq!(parts.frontmatter, "");
156 assert_eq!(parts.post, "+++\nbody\n");
157 assert_roundtrip(source, &parts);
158 }
159
160 #[test]
161 fn frontmatter_only_no_body() {
162 let source = "+++\ntitle = \"x\"\n+++\n";
163 let parts = split(source, Delimiter::Plus).unwrap();
164 assert_eq!(parts.frontmatter, "title = \"x\"\n");
165 assert_eq!(parts.post, "+++\n");
166 assert_roundtrip(source, &parts);
167 }
168
169 #[test]
170 fn preserves_crlf_line_endings() {
171 let source = "+++\r\ntitle = \"x\"\r\n+++\r\nbody\r\n";
172 let parts = split(source, Delimiter::Plus).unwrap();
173 assert_eq!(parts.pre, "+++\r\n");
174 assert_eq!(parts.frontmatter, "title = \"x\"\r\n");
175 assert_eq!(parts.post, "+++\r\nbody\r\n");
176 assert_roundtrip(source, &parts);
177 }
178
179 #[test]
180 fn missing_opening_delimiter_errors() {
181 let err = split("# Just a markdown heading\n", Delimiter::Plus).unwrap_err();
182 assert!(matches!(err, DocumentError::ParseError { .. }));
183 }
184
185 #[test]
186 fn wrong_delimiter_errors() {
187 let err = split("+++\ntitle = \"x\"\n+++\n", Delimiter::Dash).unwrap_err();
189 assert!(matches!(err, DocumentError::ParseError { .. }));
190 }
191
192 #[test]
193 fn unterminated_block_errors() {
194 let err = split("+++\ntitle = \"x\"\nno closing fence\n", Delimiter::Plus).unwrap_err();
195 assert!(matches!(err, DocumentError::ParseError { .. }));
196 }
197
198 #[test]
199 fn indented_fence_is_not_a_close() {
200 let err = split("+++\ntitle = \"x\"\n +++\n", Delimiter::Plus).unwrap_err();
203 assert!(matches!(err, DocumentError::ParseError { .. }));
204 }
205}