Skip to main content

bear_cli/
frontmatter.rs

1use std::collections::BTreeMap;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct FrontMatter {
5    fields: Vec<(String, String)>,
6}
7
8impl FrontMatter {
9    pub fn new(fields: Vec<(String, String)>) -> Self {
10        Self { fields }
11    }
12
13    pub fn fields(&self) -> &[(String, String)] {
14        &self.fields
15    }
16
17    pub fn get(&self, key: &str) -> Option<&str> {
18        self.fields
19            .iter()
20            .find(|(field_key, _)| field_key == key)
21            .map(|(_, value)| value.as_str())
22    }
23
24    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
25        let key = key.into();
26        let value = value.into();
27
28        if let Some((_, current_value)) = self
29            .fields
30            .iter_mut()
31            .find(|(field_key, _)| field_key == &key)
32        {
33            *current_value = value;
34        } else {
35            self.fields.push((key, value));
36        }
37    }
38
39    pub fn remove(&mut self, key: &str) -> Option<String> {
40        let index = self
41            .fields
42            .iter()
43            .position(|(field_key, _)| field_key == key)?;
44        Some(self.fields.remove(index).1)
45    }
46
47    pub fn merge_missing_from(&mut self, other: &FrontMatter) {
48        for (key, value) in &other.fields {
49            if self.get(key).is_none() {
50                self.fields.push((key.clone(), value.clone()));
51            }
52        }
53    }
54
55    pub fn to_note_text(&self, body: &str) -> String {
56        if self.fields.is_empty() {
57            return body.to_string();
58        }
59
60        let mut output = self.to_string();
61        if !body.starts_with('\n') && !body.is_empty() {
62            output.push('\n');
63        }
64        output.push_str(body);
65        output
66    }
67
68    pub fn to_map(&self) -> BTreeMap<String, FrontMatterValue> {
69        self.fields
70            .iter()
71            .map(|(key, raw)| (key.clone(), parse_value(raw)))
72            .collect()
73    }
74}
75
76impl std::fmt::Display for FrontMatter {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        if self.fields.is_empty() {
79            return Ok(());
80        }
81
82        writeln!(f, "---")?;
83        for (key, value) in &self.fields {
84            writeln!(f, "{key}: {}", quote_if_needed(value))?;
85        }
86        write!(f, "---")
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum FrontMatterValue {
92    String(String),
93    Bool(bool),
94    Integer(i64),
95    Array(Vec<String>),
96}
97
98pub fn parse_front_matter(text: &str) -> (Option<FrontMatter>, String) {
99    let mut lines = text.lines();
100    let Some(first_line) = lines.next() else {
101        return (None, String::new());
102    };
103
104    if first_line.trim() != "---" {
105        return (None, text.to_string());
106    }
107
108    let mut fields = Vec::new();
109    let mut body_start = None;
110    let all_lines = text.lines().collect::<Vec<_>>();
111
112    for (index, line) in all_lines.iter().enumerate().skip(1) {
113        let trimmed = line.trim();
114        if trimmed == "---" {
115            body_start = Some(index + 1);
116            break;
117        }
118
119        if trimmed.is_empty() || trimmed.starts_with('#') {
120            continue;
121        }
122
123        let Some((key, value)) = line.split_once(':') else {
124            continue;
125        };
126        let key = key.trim();
127        if key.is_empty() {
128            continue;
129        }
130
131        fields.push((key.to_string(), unquote(value.trim())));
132    }
133
134    let Some(body_start) = body_start else {
135        return (None, text.to_string());
136    };
137
138    let body = all_lines[body_start..].join("\n");
139    (Some(FrontMatter::new(fields)), body)
140}
141
142fn unquote(value: &str) -> String {
143    if value.len() >= 2 {
144        let bytes = value.as_bytes();
145        if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
146            || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
147        {
148            return value[1..value.len() - 1].to_string();
149        }
150    }
151
152    value.to_string()
153}
154
155fn quote_if_needed(value: &str) -> String {
156    let looks_like_scalar = matches!(
157        parse_value(value),
158        FrontMatterValue::Bool(_) | FrontMatterValue::Integer(_) | FrontMatterValue::Array(_)
159    );
160    if looks_like_scalar {
161        return value.to_string();
162    }
163
164    let needs_quotes = value.is_empty()
165        || value.starts_with(' ')
166        || value.ends_with(' ')
167        || value.chars().any(|ch| {
168            matches!(
169                ch,
170                ':' | '#'
171                    | '{'
172                    | '}'
173                    | '['
174                    | ']'
175                    | ','
176                    | '&'
177                    | '*'
178                    | '!'
179                    | '|'
180                    | '>'
181                    | '\''
182                    | '"'
183                    | '%'
184                    | '@'
185            )
186        });
187
188    if needs_quotes {
189        format!("\"{}\"", value.replace('"', "\\\""))
190    } else {
191        value.to_string()
192    }
193}
194
195fn parse_value(raw: &str) -> FrontMatterValue {
196    if raw == "true" {
197        return FrontMatterValue::Bool(true);
198    }
199    if raw == "false" {
200        return FrontMatterValue::Bool(false);
201    }
202    if let Ok(value) = raw.parse::<i64>() {
203        return FrontMatterValue::Integer(value);
204    }
205    if raw.starts_with('[') && raw.ends_with(']') {
206        let items = raw[1..raw.len() - 1]
207            .split(',')
208            .map(str::trim)
209            .filter(|item| !item.is_empty())
210            .map(unquote)
211            .collect::<Vec<_>>();
212        return FrontMatterValue::Array(items);
213    }
214
215    FrontMatterValue::String(raw.to_string())
216}
217
218#[cfg(test)]
219mod tests {
220    use super::{FrontMatter, FrontMatterValue, parse_front_matter};
221
222    #[test]
223    fn returns_original_body_when_front_matter_missing() {
224        let text = "# Title\n\nBody";
225        let (front_matter, body) = parse_front_matter(text);
226
227        assert!(front_matter.is_none());
228        assert_eq!(body, text);
229    }
230
231    #[test]
232    fn parses_simple_front_matter() {
233        let text = "---\ntitle: Test\ndraft: true\n---\n# Title\n";
234        let (front_matter, body) = parse_front_matter(text);
235        let front_matter = front_matter.expect("front matter should parse");
236
237        assert_eq!(front_matter.get("title"), Some("Test"));
238        assert_eq!(front_matter.get("draft"), Some("true"));
239        assert_eq!(body, "# Title");
240    }
241
242    #[test]
243    fn parses_quoted_strings_and_arrays() {
244        let text = "---\ntitle: \"Hello: world\"\ntags: [\"rust\", bear]\n---\nBody";
245        let (front_matter, body) = parse_front_matter(text);
246        let front_matter = front_matter.expect("front matter should parse");
247
248        assert_eq!(front_matter.get("title"), Some("Hello: world"));
249        assert_eq!(body, "Body");
250
251        let map = front_matter.to_map();
252        assert_eq!(
253            map.get("tags"),
254            Some(&FrontMatterValue::Array(vec![
255                "rust".to_string(),
256                "bear".to_string()
257            ]))
258        );
259    }
260
261    #[test]
262    fn ignores_unclosed_front_matter_block() {
263        let text = "---\ntitle: Test\nbody";
264        let (front_matter, body) = parse_front_matter(text);
265
266        assert!(front_matter.is_none());
267        assert_eq!(body, text);
268    }
269
270    #[test]
271    fn serializes_front_matter_back_to_note_text() {
272        let mut front_matter = FrontMatter::new(vec![
273            ("title".to_string(), "Test".to_string()),
274            ("tags".to_string(), "[rust, bear]".to_string()),
275        ]);
276        front_matter.set("draft", "false");
277        let text = front_matter.to_note_text("# Title\n\nBody");
278
279        assert_eq!(
280            text,
281            "---\ntitle: Test\ntags: [rust, bear]\ndraft: false\n---\n# Title\n\nBody"
282        );
283    }
284
285    #[test]
286    fn preserves_field_order_when_updating() {
287        let mut front_matter = FrontMatter::new(vec![
288            ("title".to_string(), "One".to_string()),
289            ("draft".to_string(), "true".to_string()),
290        ]);
291
292        front_matter.set("title", "Two");
293        front_matter.set("tags", "bear");
294
295        assert_eq!(
296            front_matter.fields(),
297            &[
298                ("title".to_string(), "Two".to_string()),
299                ("draft".to_string(), "true".to_string()),
300                ("tags".to_string(), "bear".to_string()),
301            ]
302        );
303    }
304}