Skip to main content

braze_sync/fs/
frontmatter.rs

1//! YAML frontmatter helper.
2//!
3//! Strict about the fences: the file must start with `---\n` (or
4//! `---\r\n`) and the YAML must end with a line that is exactly `---`.
5//! Anything else returns a typed error so the validate command can
6//! report it cleanly. The body after the closing fence is returned
7//! verbatim, trailing newline included.
8
9use crate::error::{Error, Result};
10use serde::{de::DeserializeOwned, Serialize};
11use std::path::Path;
12
13/// Parse a frontmatter document from `text`. Returns the deserialized
14/// frontmatter and the remaining body as a slice into the input.
15///
16/// Errors are reported as [`Error::InvalidFormat`] / [`Error::YamlParse`]
17/// with `path` attached so callers can surface filename context.
18pub fn parse<'a, T>(path: &Path, text: &'a str) -> Result<(T, &'a str)>
19where
20    T: DeserializeOwned,
21{
22    // Strip an optional UTF-8 BOM and leading whitespace-free start.
23    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
24
25    let after_open = strip_fence_line(text).ok_or_else(|| Error::InvalidFormat {
26        path: path.to_path_buf(),
27        message: "missing opening `---` frontmatter fence".into(),
28    })?;
29
30    // Find the closing fence: a line that is exactly `---` (CRLF tolerated).
31    let (yaml, body) = split_at_closing_fence(after_open).ok_or_else(|| Error::InvalidFormat {
32        path: path.to_path_buf(),
33        message: "missing closing `---` frontmatter fence".into(),
34    })?;
35
36    let parsed: T = serde_norway::from_str(yaml).map_err(|source| Error::YamlParse {
37        path: path.to_path_buf(),
38        source,
39    })?;
40    Ok((parsed, body))
41}
42
43/// Render `frontmatter` and `body` back to a single text document. The
44/// inverse of [`parse`]. Always emits LF line endings and ensures the
45/// frontmatter section ends with a newline before the closing fence.
46pub fn render<T: Serialize>(path: &Path, frontmatter: &T, body: &str) -> Result<String> {
47    let yaml = serde_norway::to_string(frontmatter).map_err(|e| Error::InvalidFormat {
48        path: path.to_path_buf(),
49        message: format!("frontmatter serialization failed: {e}"),
50    })?;
51
52    let mut out = String::with_capacity(yaml.len() + body.len() + 16);
53    out.push_str("---\n");
54    out.push_str(&yaml);
55    if !yaml.ends_with('\n') {
56        out.push('\n');
57    }
58    out.push_str("---\n");
59    out.push_str(body);
60    Ok(out)
61}
62
63/// If `text` starts with a `---` fence line, return the rest. Tolerates
64/// both `\n` and `\r\n` line endings.
65fn strip_fence_line(text: &str) -> Option<&str> {
66    if let Some(rest) = text.strip_prefix("---\n") {
67        return Some(rest);
68    }
69    if let Some(rest) = text.strip_prefix("---\r\n") {
70        return Some(rest);
71    }
72    None
73}
74
75/// Walk `text` line by line until we hit a line that is exactly `---`.
76/// Returns `(yaml_section, body)` where `body` starts immediately after
77/// the line terminator that follows the closing fence.
78fn split_at_closing_fence(text: &str) -> Option<(&str, &str)> {
79    let mut cursor = 0usize;
80    let bytes = text.as_bytes();
81    while cursor < bytes.len() {
82        let line_start = cursor;
83        let line_end = match bytes[cursor..].iter().position(|&b| b == b'\n') {
84            Some(off) => cursor + off,
85            None => bytes.len(),
86        };
87        // The fence line itself: trim a trailing \r so CRLF is tolerated.
88        let line_bytes_end = if line_end > line_start && bytes[line_end - 1] == b'\r' {
89            line_end - 1
90        } else {
91            line_end
92        };
93        if &bytes[line_start..line_bytes_end] == b"---" {
94            let yaml = &text[..line_start];
95            // body starts after the \n that ends the fence line, or end-of-input
96            let body_start = if line_end < bytes.len() {
97                line_end + 1
98            } else {
99                bytes.len()
100            };
101            return Some((yaml, &text[body_start..]));
102        }
103        cursor = if line_end < bytes.len() {
104            line_end + 1
105        } else {
106            bytes.len()
107        };
108    }
109    None
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use serde::Deserialize;
116    use std::path::PathBuf;
117
118    #[derive(Debug, Deserialize, Serialize, PartialEq)]
119    struct Meta {
120        name: String,
121        #[serde(default)]
122        tags: Vec<String>,
123    }
124
125    fn p() -> PathBuf {
126        PathBuf::from("test.liquid")
127    }
128
129    #[test]
130    fn parse_minimal() {
131        let text = "---\nname: hi\n---\nbody line\n";
132        let (meta, body): (Meta, &str) = parse(&p(), text).unwrap();
133        assert_eq!(
134            meta,
135            Meta {
136                name: "hi".into(),
137                tags: vec![]
138            }
139        );
140        assert_eq!(body, "body line\n");
141    }
142
143    #[test]
144    fn parse_empty_body_after_fence() {
145        let text = "---\nname: empty\n---\n";
146        let (meta, body): (Meta, &str) = parse(&p(), text).unwrap();
147        assert_eq!(meta.name, "empty");
148        assert_eq!(body, "");
149    }
150
151    #[test]
152    fn parse_no_trailing_newline_on_body() {
153        let text = "---\nname: x\n---\nfinal";
154        let (_meta, body): (Meta, &str) = parse(&p(), text).unwrap();
155        assert_eq!(body, "final");
156    }
157
158    #[test]
159    fn parse_crlf_line_endings_tolerated() {
160        let text = "---\r\nname: crlf\r\n---\r\nbody\r\n";
161        let (meta, body): (Meta, &str) = parse(&p(), text).unwrap();
162        assert_eq!(meta.name, "crlf");
163        assert_eq!(body, "body\r\n");
164    }
165
166    #[test]
167    fn parse_bom_stripped() {
168        let text = "\u{feff}---\nname: bom\n---\nbody\n";
169        let (meta, _): (Meta, &str) = parse(&p(), text).unwrap();
170        assert_eq!(meta.name, "bom");
171    }
172
173    #[test]
174    fn parse_body_containing_triple_dash_is_preserved() {
175        // The body legitimately contains a `---` separator. Only the
176        // first closing fence terminates the frontmatter.
177        let text = "---\nname: x\n---\nintro\n---\nmore body\n";
178        let (_, body): (Meta, &str) = parse(&p(), text).unwrap();
179        assert_eq!(body, "intro\n---\nmore body\n");
180    }
181
182    #[test]
183    fn parse_missing_opening_fence_errors() {
184        let text = "name: x\n---\nbody\n";
185        let err = parse::<Meta>(&p(), text).unwrap_err();
186        match err {
187            Error::InvalidFormat { message, .. } => assert!(message.contains("opening")),
188            other => panic!("expected InvalidFormat, got {other:?}"),
189        }
190    }
191
192    #[test]
193    fn parse_missing_closing_fence_errors() {
194        let text = "---\nname: x\nbody never closes\n";
195        let err = parse::<Meta>(&p(), text).unwrap_err();
196        match err {
197            Error::InvalidFormat { message, .. } => assert!(message.contains("closing")),
198            other => panic!("expected InvalidFormat, got {other:?}"),
199        }
200    }
201
202    #[test]
203    fn parse_invalid_yaml_in_frontmatter_errors() {
204        let text = "---\nname: [unterminated\n---\nbody\n";
205        let err = parse::<Meta>(&p(), text).unwrap_err();
206        assert!(matches!(err, Error::YamlParse { .. }), "got {err:?}");
207    }
208
209    #[test]
210    fn render_round_trip() {
211        let meta = Meta {
212            name: "round".into(),
213            tags: vec!["a".into(), "b".into()],
214        };
215        let body = "hello\nworld\n";
216        let text = render(&p(), &meta, body).unwrap();
217        assert!(text.starts_with("---\n"));
218        assert!(text.contains("name: round"));
219        assert!(text.ends_with("hello\nworld\n"));
220
221        let (parsed, parsed_body): (Meta, &str) = parse(&p(), &text).unwrap();
222        assert_eq!(parsed, meta);
223        assert_eq!(parsed_body, body);
224    }
225
226    #[test]
227    fn render_empty_body_round_trips() {
228        let meta = Meta {
229            name: "empty".into(),
230            tags: vec![],
231        };
232        let text = render(&p(), &meta, "").unwrap();
233        let (parsed, body): (Meta, &str) = parse(&p(), &text).unwrap();
234        assert_eq!(parsed, meta);
235        assert_eq!(body, "");
236    }
237}