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}