1use 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}'); 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 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}