Skip to main content

supersigil_parser/
frontmatter.rs

1//! Stage 1b–1c: Front matter extraction and deserialization.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use supersigil_core::{Frontmatter, ParseError};
7
8/// Result of deserializing YAML front matter.
9#[derive(Debug, Clone, PartialEq)]
10pub enum FrontMatterResult {
11    /// The YAML contained a `supersigil:` key with a valid `Frontmatter`.
12    Supersigil {
13        /// The parsed supersigil front matter.
14        frontmatter: Frontmatter,
15        /// Additional YAML keys outside the `supersigil:` block.
16        extra: HashMap<String, yaml_serde::Value>,
17    },
18    /// The YAML did not contain a `supersigil:` key.
19    NotSupersigil,
20}
21
22/// Check whether a line is a `---` delimiter (optionally followed by trailing
23/// whitespace).
24fn is_delimiter(line: &str) -> bool {
25    let trimmed = line.trim_end();
26    trimmed == "---"
27}
28
29/// Detect and extract YAML front matter between `---` delimiters.
30///
31/// Returns `Ok(Some((yaml_str, body_str)))` when front matter is found,
32/// `Ok(None)` when the file does not start with `---`.
33///
34/// # Errors
35///
36/// Returns `ParseError::UnclosedFrontMatter` when the opening `---` has no
37/// matching closing delimiter.
38pub fn extract_front_matter<'a>(
39    content: &'a str,
40    path: &Path,
41) -> Result<Option<(&'a str, &'a str)>, ParseError> {
42    // Check if the first line is a `---` delimiter.
43    let first_newline = content.find('\n');
44    let first_line = match first_newline {
45        Some(pos) => &content[..pos],
46        None => content,
47    };
48
49    if !is_delimiter(first_line) {
50        return Ok(None);
51    }
52
53    // Start of YAML content is right after the first line + newline.
54    let yaml_start = match first_newline {
55        Some(pos) => pos + 1,
56        // Content is just "---" with no newline — unclosed.
57        None => {
58            return Err(ParseError::UnclosedFrontMatter {
59                path: path.to_path_buf(),
60            });
61        }
62    };
63
64    // Scan remaining lines for the closing `---` delimiter.
65    let rest = &content[yaml_start..];
66    let mut offset = 0;
67    for line in rest.split('\n') {
68        if is_delimiter(line) {
69            let yaml = &content[yaml_start..yaml_start + offset];
70            let body_start = yaml_start + offset + line.len();
71            // Skip the newline after the closing delimiter if present.
72            let body_start = if content.as_bytes().get(body_start) == Some(&b'\n') {
73                body_start + 1
74            } else {
75                body_start
76            };
77            let body = &content[body_start..];
78            return Ok(Some((yaml, body)));
79        }
80        // +1 for the \n that split consumed
81        offset += line.len() + 1;
82    }
83
84    Err(ParseError::UnclosedFrontMatter {
85        path: path.to_path_buf(),
86    })
87}
88
89/// Deserialize YAML front matter, extracting the `supersigil:` namespace and
90/// preserving extra metadata keys.
91///
92/// Returns `FrontMatterResult::Supersigil` when a `supersigil:` key is found,
93/// `FrontMatterResult::NotSupersigil` when it is absent.
94///
95/// # Errors
96///
97/// Returns `ParseError::InvalidYaml` for malformed YAML, or
98/// `ParseError::MissingId` when the `supersigil:` key is present but `id` is
99/// missing.
100pub fn deserialize_front_matter(yaml: &str, path: &Path) -> Result<FrontMatterResult, ParseError> {
101    // Empty YAML → no supersigil key.
102    if yaml.trim().is_empty() {
103        return Ok(FrontMatterResult::NotSupersigil);
104    }
105
106    // Parse into a generic YAML mapping first.
107    let mut mapping: HashMap<String, yaml_serde::Value> =
108        yaml_serde::from_str(yaml).map_err(|e| ParseError::InvalidYaml {
109            path: path.to_path_buf(),
110            message: e.to_string(),
111        })?;
112
113    // Remove the `supersigil:` key, moving the value out without cloning.
114    let Some(supersigil_value) = mapping.remove("supersigil") else {
115        return Ok(FrontMatterResult::NotSupersigil);
116    };
117
118    // Extract fields from the supersigil mapping structurally to detect
119    // missing `id` without relying on brittle error-message string matching.
120    let yaml_serde::Value::Mapping(supersigil_map) = supersigil_value else {
121        return Err(ParseError::InvalidYaml {
122            path: path.to_path_buf(),
123            message: "supersigil value must be a mapping".to_owned(),
124        });
125    };
126
127    let get_optional_string = |map: &yaml_serde::Mapping, key: &str| -> Option<String> {
128        map.get(key)
129            .and_then(yaml_serde::Value::as_str)
130            .map(str::to_owned)
131    };
132
133    let id = get_optional_string(&supersigil_map, "id").ok_or_else(|| ParseError::MissingId {
134        path: path.to_path_buf(),
135    })?;
136
137    let frontmatter = Frontmatter {
138        id,
139        doc_type: get_optional_string(&supersigil_map, "type"),
140        status: get_optional_string(&supersigil_map, "status"),
141    };
142
143    // The remaining keys are all non-supersigil extra metadata.
144    Ok(FrontMatterResult::Supersigil {
145        frontmatter,
146        extra: mapping,
147    })
148}