Skip to main content

majordomus_cli/metadata/
frontmatter.rs

1//! Front matter: the metadata block at the top of a document of the layer.
2//!
3//! A YAML block between a first line `---` and the next line `---`, read exactly as the
4//! shell tool's `mj_record_front` reads it — the two implementations have to agree about
5//! every file in the repository, so this one recognises no more than that one does. There
6//! is no `+++`, no JSON block, and no leading blank line before the opening fence: a
7//! document that opens with a blank line has no front matter, it has a body that starts
8//! with a rule.
9//!
10//! Splitting and parsing are two steps on purpose. [`split`] is total over any text and
11//! tells a document with no front matter from one whose front matter is malformed;
12//! [`parse`] then reads the block, and a failure there is a diagnostic about a file rather
13//! than an error that stops the process. A block larger than [`MAX_FRONT_MATTER_BYTES`] is
14//! refused rather than parsed, because at that size it is not metadata.
15//!
16//! ```
17//! use majordomus_cli::metadata::frontmatter;
18//!
19//! let document = "---\nid: project.example\nkind: rule\n---\n\n# The body\n";
20//! let split = frontmatter::split(document).unwrap();
21//! assert_eq!(split.body.trim_start(), "# The body\n");
22//!
23//! let front = frontmatter::parse(split.front.expect("this document has front matter")).unwrap();
24//! assert_eq!(front["kind"], "rule");
25//!
26//! // a document with no fence is all body, and that is not an error
27//! let plain = frontmatter::split("# Just a document\n").unwrap();
28//! assert!(plain.front.is_none());
29//!
30//! // one that opens a block and never closes it is
31//! assert!(frontmatter::split("---\nid: x\n").is_err());
32//! ```
33
34use serde_json::{Map, Value};
35
36use super::yaml;
37
38/// Front matter larger than this is refused rather than parsed.
39pub const MAX_FRONT_MATTER_BYTES: usize = 64 * 1024;
40
41/// A document split into its parts.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Split<'a> {
44    /// The raw YAML between the fences, without them.
45    pub front: Option<&'a str>,
46    /// Everything after the closing fence, or the whole text when there is none.
47    pub body: &'a str,
48}
49
50/// Why a document could not be split.
51#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
52pub enum SplitError {
53    #[error("front matter opened on line 1 and never closed")]
54    /// The document opens with `---` and no closing fence follows.
55    Unclosed,
56    #[error("front matter is {0} bytes, over the {MAX_FRONT_MATTER_BYTES} byte limit")]
57    /// The front matter exceeds [`MAX_FRONT_MATTER_BYTES`]; the size is carried.
58    Oversized(usize),
59}
60
61/// Split a document. Text that does not open with `---` has no front matter and is all body.
62///
63/// ```
64/// use majordomus_cli::metadata::frontmatter::{split, SplitError};
65/// let s = split("---\nname: x\n---\n# Body\n").unwrap();
66/// assert_eq!(s.front, Some("name: x\n"));
67/// assert_eq!(s.body, "# Body\n");
68/// assert_eq!(split("# No front matter\n").unwrap().front, None);
69/// assert_eq!(split("---\nname: x\n"), Err(SplitError::Unclosed));
70/// ```
71pub fn split(text: &str) -> Result<Split<'_>, SplitError> {
72    let Some(rest) = text
73        .strip_prefix("---\n")
74        .or_else(|| text.strip_prefix("---\r\n"))
75    else {
76        if text == "---" {
77            return Err(SplitError::Unclosed);
78        }
79        return Ok(Split {
80            front: None,
81            body: text,
82        });
83    };
84    let mut offset = 0;
85    for line in rest.split_inclusive('\n') {
86        let trimmed = line.trim_end_matches(['\n', '\r']);
87        if trimmed == "---" {
88            let front = &rest[..offset];
89            if front.len() > MAX_FRONT_MATTER_BYTES {
90                return Err(SplitError::Oversized(front.len()));
91            }
92            return Ok(Split {
93                front: Some(front),
94                body: &rest[offset + line.len()..],
95            });
96        }
97        offset += line.len();
98    }
99    Err(SplitError::Unclosed)
100}
101
102/// Parse the front matter of a split document into a mapping.
103pub fn parse(front: &str) -> Result<Map<String, Value>, String> {
104    yaml::parse_mapping(front)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn splits_a_rule_file() {
113        let s = split("---\nid: x\nversion: 1\n---\n\n# Body\n").unwrap();
114        assert_eq!(s.front, Some("id: x\nversion: 1\n"));
115        assert_eq!(s.body, "\n# Body\n");
116        assert_eq!(parse(s.front.unwrap()).unwrap()["version"], 1);
117    }
118
119    #[test]
120    fn no_fence_means_no_front_matter() {
121        let s = split("# Title\n---\nnot front matter\n").unwrap();
122        assert_eq!(s.front, None);
123    }
124
125    #[test]
126    fn unclosed_is_an_error_not_a_body() {
127        assert_eq!(split("---\nid: x\n"), Err(SplitError::Unclosed));
128        assert_eq!(split("---"), Err(SplitError::Unclosed));
129    }
130
131    #[test]
132    fn oversized_is_refused() {
133        let big = format!("---\nk: {}\n---\n", "a".repeat(MAX_FRONT_MATTER_BYTES));
134        assert!(matches!(split(&big), Err(SplitError::Oversized(_))));
135    }
136
137    #[test]
138    fn empty_front_matter_is_an_empty_mapping() {
139        let s = split("---\n---\nbody").unwrap();
140        assert_eq!(s.front, Some(""));
141        assert!(parse("").unwrap().is_empty());
142    }
143}