Skip to main content

agent_first_data/document/format/
frontmatter.rs

1//! Frontmatter mode: address the leading TOML (`+++`) or YAML (`---`) metadata
2//! block of a Markdown-ish file as a config document, leaving the body bytes
3//! untouched.
4//!
5//! This backend does not parse Markdown. It splits the source into three
6//! byte-exact spans — the opening delimiter line, the frontmatter block, and
7//! the closing delimiter line plus everything after it (the body) — and hands
8//! only the middle span to the inner TOML/YAML backend. Every edit re-splices
9//! `pre + edited_frontmatter + post`, so the body survives verbatim; the frozen
10//! body is exactly why frontmatter mode is source-preserving-only (there is no
11//! whole-document re-render — the body is not part of the parsed value).
12//!
13//! Detection is never automatic: the caller selects the delimiter explicitly
14//! (`--input-format toml-frontmatter|yaml-frontmatter`). A file that does not
15//! open with the requested delimiter, or whose block is never closed, is a hard
16//! error — sniffing a fence is exactly the shape-guessing AFDATA avoids.
17
18use crate::document::{DocumentError, DocumentResult};
19
20/// The fence delimiter that brackets a frontmatter block.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Delimiter {
23    /// `+++` — Zola's TOML frontmatter.
24    Plus,
25    /// `---` — Jekyll/Hugo/Obsidian YAML frontmatter.
26    Dash,
27}
28
29impl Delimiter {
30    /// The literal fence line for this delimiter.
31    fn marker(self) -> &'static str {
32        match self {
33            Delimiter::Plus => "+++",
34            Delimiter::Dash => "---",
35        }
36    }
37
38    /// Human-readable format label used in error envelopes.
39    fn label(self) -> &'static str {
40        match self {
41            Delimiter::Plus => "TOML frontmatter",
42            Delimiter::Dash => "YAML frontmatter",
43        }
44    }
45}
46
47/// Three byte-exact spans of a frontmatter document: the opening delimiter line
48/// (`pre`), the frontmatter block handed to the inner backend (`frontmatter`),
49/// and the closing delimiter line plus the body (`post`). Concatenating `pre`,
50/// `frontmatter`, and `post` reproduces the original source byte for byte.
51#[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
58/// Split `source` into its frontmatter spans for the given delimiter.
59///
60/// The opening delimiter must be the file's very first line (no leading blank
61/// lines, no BOM). The closing delimiter is the next line equal to the marker
62/// at column zero. A missing opening or closing fence is a [`DocumentError`],
63/// never a silent "there is no frontmatter, treat the whole file as body".
64pub 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        // A bare `+++`/`---` with no trailing newline: opened, never closed.
80        return Err(unterminated(delim));
81    };
82    let block_start = nl + 1;
83
84    // The closing delimiter is the next line whose trimmed content is the
85    // marker. Leading whitespace disqualifies it (an indented `+++` is content,
86    // not a fence); trailing whitespace/CR is tolerated.
87    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        // A `+++` file addressed as YAML frontmatter must not silently succeed.
188        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        // A `+++` that is not at column zero is body content, so the block is
201        // unterminated rather than closed there.
202        let err = split("+++\ntitle = \"x\"\n  +++\n", Delimiter::Plus).unwrap_err();
203        assert!(matches!(err, DocumentError::ParseError { .. }));
204    }
205}