Skip to main content

rustbrain_core/obsidian/
frontmatter.rs

1//! YAML frontmatter parsing for Obsidian-style Markdown notes.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Parsed YAML frontmatter metadata from a Markdown file.
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct Frontmatter {
9    /// Note tags.
10    #[serde(default)]
11    pub tags: Vec<String>,
12    /// Optional rustbrain node type (`concept`, `adr`, …).
13    #[serde(default)]
14    pub node_type: Option<String>,
15    /// Obsidian-style aliases.
16    #[serde(default)]
17    pub aliases: Vec<String>,
18    /// Additional free-form fields (e.g. `title`).
19    #[serde(flatten)]
20    pub extra: HashMap<String, serde_yaml_ng::Value>,
21}
22
23/// Parse a YAML frontmatter block from a markdown header (`--- ... ---`).
24///
25/// On malformed YAML, returns `(None, original markdown)` without panicking.
26pub fn parse_frontmatter(markdown: &str) -> (Option<Frontmatter>, &str) {
27    let trimmed = markdown.trim_start();
28    if !trimmed.starts_with("---") {
29        return (None, markdown);
30    }
31
32    let rest = &trimmed[3..];
33    // Allow optional trailing spaces after opening ---
34    let rest = rest.strip_prefix('\n').unwrap_or(rest);
35
36    if let Some(end_idx) = rest.find("\n---") {
37        let yaml_str = &rest[..end_idx];
38        let body = rest[end_idx + 4..].trim_start_matches('\n');
39        match serde_yaml_ng::from_str::<Frontmatter>(yaml_str) {
40            Ok(fm) => return (Some(fm), body),
41            Err(_) => return (None, markdown),
42        }
43    }
44
45    (None, markdown)
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51
52    #[test]
53    fn parse_frontmatter_basic() {
54        let content =
55            "---\ntags: [consensus, raft]\nnode_type: concept\naliases: [Raft]\n---\n# Raft Consensus\nNotes here.";
56        let (fm, body) = parse_frontmatter(content);
57        assert!(fm.is_some());
58        let fm = fm.unwrap();
59        assert_eq!(fm.tags, vec!["consensus", "raft"]);
60        assert_eq!(fm.node_type.as_deref(), Some("concept"));
61        assert_eq!(fm.aliases, vec!["Raft"]);
62        assert!(body.starts_with("# Raft Consensus"));
63    }
64
65    #[test]
66    fn no_frontmatter() {
67        let (fm, body) = parse_frontmatter("# Hello");
68        assert!(fm.is_none());
69        assert_eq!(body, "# Hello");
70    }
71}