Skip to main content

mnemo_md_sync/
parser.rs

1//! Markdown frontmatter + body parser (v0.4.0 P2-6).
2//!
3//! Frontmatter shape (YAML-style, but parsed without a full YAML
4//! dependency — we only support the four keys we care about):
5//!
6//! ```markdown
7//! ---
8//! mnemo_id: 0190abcd-...
9//! tags: [project-x, retrospective]
10//! expires_at: 2026-12-31T00:00:00Z
11//! agent_id: prod-runner
12//! ---
13//!
14//! # Heading
15//!
16//! Body...
17//! ```
18//!
19//! Anything that doesn't match the expected key/value shape is
20//! ignored; an unrecognized key on a future Wuphf-flavoured frontmatter
21//! does not fail the parse.
22
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25use uuid::Uuid;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct ParsedMarkdown {
29    pub mnemo_id: Option<Uuid>,
30    pub agent_id: Option<String>,
31    pub tags: Vec<String>,
32    pub expires_at: Option<String>,
33    pub body: String,
34}
35
36#[derive(Debug, Error, PartialEq)]
37pub enum ParseError {
38    #[error("frontmatter is not closed with `---`")]
39    UnterminatedFrontmatter,
40    #[error("invalid `mnemo_id`: {0}")]
41    InvalidId(String),
42}
43
44pub fn parse_markdown(input: &str) -> Result<ParsedMarkdown, ParseError> {
45    let mut mnemo_id = None;
46    let mut agent_id = None;
47    let mut tags = Vec::new();
48    let mut expires_at = None;
49
50    let trimmed = input.trim_start_matches('\u{FEFF}'); // strip BOM
51    let body = if let Some(rest) = trimmed.strip_prefix("---\n") {
52        let close = rest.find("\n---\n").or_else(|| rest.find("\n---"));
53        let Some(close_idx) = close else {
54            return Err(ParseError::UnterminatedFrontmatter);
55        };
56        let header = &rest[..close_idx];
57        for line in header.lines() {
58            let line = line.trim();
59            if line.is_empty() {
60                continue;
61            }
62            let Some((k, v)) = line.split_once(':') else {
63                continue;
64            };
65            let k = k.trim();
66            let v = v.trim();
67            match k {
68                "mnemo_id" if !v.is_empty() => {
69                    mnemo_id =
70                        Some(Uuid::parse_str(v).map_err(|e| ParseError::InvalidId(e.to_string()))?);
71                }
72                "agent_id" if !v.is_empty() => {
73                    agent_id = Some(v.to_string());
74                }
75                "tags" => {
76                    tags = parse_tag_list(v);
77                }
78                "expires_at" if !v.is_empty() => {
79                    expires_at = Some(v.to_string());
80                }
81                _ => {}
82            }
83        }
84        // Body starts after the closing `---\n`. Try the
85        // newline-prefixed close first, then the bare close at end of
86        // file.
87        let body_start = if rest[close_idx..].starts_with("\n---\n") {
88            close_idx + "\n---\n".len()
89        } else {
90            close_idx + "\n---".len()
91        };
92        rest.get(body_start..).unwrap_or("").to_string()
93    } else {
94        input.to_string()
95    };
96
97    Ok(ParsedMarkdown {
98        mnemo_id,
99        agent_id,
100        tags,
101        expires_at,
102        body: body.trim_start_matches('\n').to_string(),
103    })
104}
105
106fn parse_tag_list(raw: &str) -> Vec<String> {
107    let s = raw.trim();
108    let s = s.strip_prefix('[').unwrap_or(s);
109    let s = s.strip_suffix(']').unwrap_or(s);
110    s.split(',')
111        .map(|t| t.trim().trim_matches('"').trim_matches('\'').to_string())
112        .filter(|t| !t.is_empty())
113        .collect()
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn no_frontmatter_returns_full_body() {
122        let p = parse_markdown("# Heading\n\nbody text").unwrap();
123        assert_eq!(p.mnemo_id, None);
124        assert_eq!(p.tags, Vec::<String>::new());
125        assert_eq!(p.body, "# Heading\n\nbody text");
126    }
127
128    #[test]
129    fn frontmatter_with_all_fields_parses() {
130        let id = Uuid::now_v7();
131        let input = format!(
132            "---\nmnemo_id: {id}\nagent_id: prod-runner\ntags: [a, b, c]\nexpires_at: 2026-12-31T00:00:00Z\n---\n# H\n\nbody\n"
133        );
134        let p = parse_markdown(&input).unwrap();
135        assert_eq!(p.mnemo_id, Some(id));
136        assert_eq!(p.agent_id.as_deref(), Some("prod-runner"));
137        assert_eq!(p.tags, vec!["a", "b", "c"]);
138        assert_eq!(p.expires_at.as_deref(), Some("2026-12-31T00:00:00Z"));
139        assert_eq!(p.body, "# H\n\nbody\n");
140    }
141
142    #[test]
143    fn unterminated_frontmatter_errors() {
144        let err = parse_markdown("---\nmnemo_id: x\nbody but no close").unwrap_err();
145        assert_eq!(err, ParseError::UnterminatedFrontmatter);
146    }
147
148    #[test]
149    fn invalid_mnemo_id_errors() {
150        let err = parse_markdown("---\nmnemo_id: not-a-uuid\n---\nbody").unwrap_err();
151        assert!(matches!(err, ParseError::InvalidId(_)));
152    }
153
154    #[test]
155    fn unknown_keys_are_ignored() {
156        let input = "---\nfutureWuphfKey: value\ntags: [x]\n---\nbody";
157        let p = parse_markdown(input).unwrap();
158        assert_eq!(p.tags, vec!["x"]);
159        assert_eq!(p.body, "body");
160    }
161
162    #[test]
163    fn quoted_tags_strip_quotes() {
164        let p = parse_markdown(
165            r#"---
166tags: ["a", 'b', c]
167---
168body"#,
169        )
170        .unwrap();
171        assert_eq!(p.tags, vec!["a", "b", "c"]);
172    }
173}