use std::collections::BTreeMap;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Frontmatter {
pub name: Option<String>,
pub description: Option<String>,
pub mem_type: Option<String>,
pub extras: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Split<'a> {
pub frontmatter: Frontmatter,
pub body: &'a str,
}
pub fn split(input: &str) -> Split<'_> {
let trimmed = input.trim_start_matches('\u{feff}'); if !trimmed.starts_with("---") {
return Split {
frontmatter: Frontmatter::default(),
body: input,
};
}
let after_open = match trimmed.find('\n') {
Some(i) => &trimmed[i + 1..],
None => {
return Split {
frontmatter: Frontmatter::default(),
body: input,
}
}
};
let close_idx = match find_close(after_open) {
Some(i) => i,
None => {
return Split {
frontmatter: Frontmatter::default(),
body: input,
}
}
};
let yaml = &after_open[..close_idx];
let after_close = &after_open[close_idx..];
let body_start = match after_close.find('\n') {
Some(i) => &after_close[i + 1..],
None => "",
};
Split {
frontmatter: parse_yaml(yaml),
body: body_start,
}
}
fn find_close(s: &str) -> Option<usize> {
let mut offset = 0usize;
for line in s.split_inclusive('\n') {
let trimmed_eol = line.trim_end_matches('\n').trim_end_matches('\r');
if trimmed_eol == "---" {
return Some(offset);
}
offset += line.len();
}
None
}
fn parse_yaml(yaml: &str) -> Frontmatter {
let mut out = Frontmatter::default();
let mut top_level_type: Option<String> = None;
let mut metadata_type: Option<String> = None;
let mut in_metadata = false;
for raw_line in yaml.lines() {
let line = raw_line.trim_end();
if line.is_empty() || line.starts_with('#') {
continue;
}
let indent = line.bytes().take_while(|b| *b == b' ').count();
if indent == 0 {
in_metadata = false;
if let Some((k, v)) = split_key_value(line) {
match k {
"name" => out.name = Some(v),
"description" => out.description = Some(v),
"type" => {
top_level_type = Some(v);
}
"metadata" => {
in_metadata = v.is_empty();
}
_ => {
if !v.is_empty() {
out.extras.insert(k.to_string(), v);
}
}
}
}
} else if in_metadata {
if let Some((k, v)) = split_key_value(line.trim_start()) {
if k == "type" {
metadata_type = Some(v);
}
}
}
}
out.mem_type = metadata_type.or(top_level_type);
out
}
fn split_key_value(line: &str) -> Option<(&str, String)> {
let colon = line.find(':')?;
let key = line[..colon].trim();
if key.is_empty() {
return None;
}
let raw_value = line[colon + 1..].trim();
let value = raw_value
.trim_start_matches(['"', '\''])
.trim_end_matches(['"', '\''])
.to_string();
Some((key, value))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_typical_user_memory() {
let input = r#"---
name: user-prefers-vim
description: User uses vim for everything
metadata:
type: user
---
User prefers vim. Stop suggesting nano.
"#;
let s = split(input);
assert_eq!(s.frontmatter.name.as_deref(), Some("user-prefers-vim"));
assert_eq!(
s.frontmatter.description.as_deref(),
Some("User uses vim for everything")
);
assert_eq!(s.frontmatter.mem_type.as_deref(), Some("user"));
assert!(s.body.contains("User prefers vim"));
}
#[test]
fn parses_feedback_with_extra_metadata() {
let input = r#"---
name: no-mocked-db
description: Use real DB in integration tests
metadata:
type: feedback
added: 2026-05-16
---
Body."#;
let fm = split(input).frontmatter;
assert_eq!(fm.mem_type.as_deref(), Some("feedback"));
assert_eq!(fm.name.as_deref(), Some("no-mocked-db"));
}
#[test]
fn no_frontmatter_returns_whole_input_as_body() {
let input = "just markdown, no fences\n";
let s = split(input);
assert!(s.frontmatter == Frontmatter::default());
assert_eq!(s.body, input);
}
#[test]
fn unterminated_frontmatter_falls_back_to_body() {
let input = "---\nname: x\nno closing fence\n";
let s = split(input);
assert!(s.frontmatter == Frontmatter::default());
assert_eq!(s.body, input);
}
#[test]
fn handles_bom() {
let input = "\u{feff}---\nname: x\nmetadata:\n type: user\n---\nbody";
let fm = split(input).frontmatter;
assert_eq!(fm.name.as_deref(), Some("x"));
assert_eq!(fm.mem_type.as_deref(), Some("user"));
}
#[test]
fn strips_surrounding_quotes() {
let input = "---\nname: \"quoted-name\"\ndescription: 'apostrophed'\nmetadata:\n type: project\n---\nx";
let fm = split(input).frontmatter;
assert_eq!(fm.name.as_deref(), Some("quoted-name"));
assert_eq!(fm.description.as_deref(), Some("apostrophed"));
assert_eq!(fm.mem_type.as_deref(), Some("project"));
}
#[test]
fn body_excludes_frontmatter_block() {
let input = "---\nname: x\nmetadata:\n type: user\n---\nhello\nworld\n";
let s = split(input);
assert_eq!(s.body, "hello\nworld\n");
}
#[test]
fn top_level_type_is_used_when_no_metadata_block() {
let input = r#"---
name: project-location
description: prefer ~/Desktop over /tmp
type: feedback
originSessionId: 76a78a2d-e2af-4a15-9be4-f970d9e26e41
---
body"#;
let fm = split(input).frontmatter;
assert_eq!(
fm.mem_type.as_deref(),
Some("feedback"),
"top-level type: feedback must resolve when metadata.type is absent"
);
assert_eq!(
fm.extras.get("originSessionId").map(String::as_str),
Some("76a78a2d-e2af-4a15-9be4-f970d9e26e41"),
"originSessionId must be preserved for the normalizer"
);
}
#[test]
fn metadata_type_wins_over_top_level_type() {
let input = r#"---
name: x
type: feedback
metadata:
type: reference
---
body"#;
let fm = split(input).frontmatter;
assert_eq!(
fm.mem_type.as_deref(),
Some("reference"),
"metadata.type overrides top-level type"
);
}
#[test]
fn extras_capture_unknown_top_level_keys() {
let input = r#"---
name: x
description: y
metadata:
type: reference
originSessionId: abc-123
node_type: memory
custom_tag: foo
---
body"#;
let fm = split(input).frontmatter;
assert_eq!(fm.mem_type.as_deref(), Some("reference"));
let snapshot: Vec<_> = fm.extras.iter().collect();
assert_eq!(
snapshot,
vec![
(&"custom_tag".to_string(), &"foo".to_string()),
(&"node_type".to_string(), &"memory".to_string()),
(&"originSessionId".to_string(), &"abc-123".to_string()),
]
);
}
#[test]
fn reserved_keys_do_not_leak_into_extras() {
let input = r#"---
name: x
description: y
type: feedback
metadata:
type: feedback
---
body"#;
let fm = split(input).frontmatter;
assert!(
fm.extras.is_empty(),
"reserved keys must not show up in extras"
);
}
#[test]
fn unrecognized_type_value_passes_through_to_normalizer() {
let input = "---\ntype: weird-experimental\n---\nbody";
let fm = split(input).frontmatter;
assert_eq!(fm.mem_type.as_deref(), Some("weird-experimental"));
}
}