use crate::error::{VaultError, VaultResult};
pub fn extract_raw_frontmatter(content: &str) -> Option<(&str, usize)> {
let after_open = if content.starts_with("---\n") {
4
} else if content.starts_with("---\r\n") {
5
} else {
return None;
};
let rest = &content[after_open..];
let mut pos = 0;
loop {
let remaining = &rest[pos..];
let line_end = remaining.find('\n');
let line = match line_end {
Some(end) => &remaining[..end],
None => remaining,
};
if line.trim_end_matches('\r') == "---" {
let yaml_str = &content[after_open..after_open + pos];
let body_start = match line_end {
Some(end) => after_open + pos + end + 1,
None => content.len(),
};
return Some((yaml_str, body_start));
}
match line_end {
Some(end) => pos += end + 1,
None => return None,
}
}
}
pub fn get_body(content: &str) -> &str {
match extract_raw_frontmatter(content) {
Some((_, body_start)) => &content[body_start..],
None => content,
}
}
pub fn parse_frontmatter(content: &str) -> VaultResult<Option<serde_json::Value>> {
let (yaml_str, _) = match extract_raw_frontmatter(content) {
Some(v) => v,
None => return Ok(None),
};
if yaml_str.trim().is_empty() {
return Ok(Some(serde_json::Value::Object(serde_json::Map::new())));
}
let value: serde_json::Value = serde_yaml::from_str(yaml_str)
.map_err(|e| VaultError::Other(format!("Frontmatter YAML parse error: {e}")))?;
match &value {
serde_json::Value::Object(_) => Ok(Some(value)),
other => Err(VaultError::Other(format!(
"Frontmatter must be a YAML mapping, got {}",
json_type_name(other),
))),
}
}
fn json_type_name(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Array(_) => "array",
serde_json::Value::String(_) => "string",
serde_json::Value::Number(_) => "number",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Null => "null",
serde_json::Value::Object(_) => "object",
}
}
fn extract_string_list(frontmatter: &serde_json::Value, key: &str) -> Vec<String> {
let Some(val) = frontmatter.get(key) else {
return Vec::new();
};
match val {
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Null => None,
other => Some(other.to_string()),
})
.collect(),
serde_json::Value::String(s) => s
.split(',')
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
.collect(),
_ => Vec::new(),
}
}
pub fn extract_frontmatter_tags(frontmatter: &serde_json::Value) -> Vec<String> {
extract_string_list(frontmatter, "tags")
}
pub fn extract_aliases(frontmatter: &serde_json::Value) -> Vec<String> {
extract_string_list(frontmatter, "aliases")
}
pub fn set_frontmatter_field(
content: &str,
key: &str,
value: serde_json::Value,
) -> VaultResult<String> {
let body = get_body(content);
let mut fm = parse_frontmatter(content)?
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
fm.as_object_mut()
.ok_or_else(|| VaultError::Other("Frontmatter is not a YAML mapping".into()))?
.insert(key.to_string(), value);
Ok(rebuild_content(Some(&fm), body))
}
pub fn remove_frontmatter_field(content: &str, key: &str) -> VaultResult<String> {
let mut fm = match parse_frontmatter(content)? {
Some(fm) => fm,
None => return Ok(content.to_string()),
};
let map = fm
.as_object_mut()
.ok_or_else(|| VaultError::Other("Frontmatter is not a YAML mapping".into()))?;
if !map.contains_key(key) {
return Ok(content.to_string());
}
map.remove(key);
let body = get_body(content);
if map.is_empty() {
Ok(body.to_string())
} else {
Ok(rebuild_content(Some(&fm), body))
}
}
pub fn rebuild_content(frontmatter: Option<&serde_json::Value>, body: &str) -> String {
match frontmatter {
Some(fm) if fm.as_object().is_some_and(|m| !m.is_empty()) => {
match serde_yaml::to_string(fm) {
Ok(yaml) => format!("---\n{yaml}---\n{body}"),
Err(e) => {
tracing::warn!("Failed to serialize frontmatter, dropping it: {e}");
body.to_string()
}
}
}
_ => body.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extract_no_frontmatter() {
assert!(extract_raw_frontmatter("# Just a heading\n").is_none());
}
#[test]
fn extract_empty_frontmatter() {
let content = "---\n---\n";
let (yaml, body_start) = extract_raw_frontmatter(content).unwrap();
assert_eq!(yaml, "");
assert_eq!(&content[body_start..], "");
}
#[test]
fn extract_empty_frontmatter_with_body() {
let content = "---\n---\nbody";
let (yaml, body_start) = extract_raw_frontmatter(content).unwrap();
assert_eq!(yaml, "");
assert_eq!(&content[body_start..], "body");
}
#[test]
fn extract_standard_frontmatter() {
let content = "---\ntags: [a, b]\ntitle: Hello\n---\n# Body\n";
let (yaml, body_start) = extract_raw_frontmatter(content).unwrap();
assert_eq!(yaml, "tags: [a, b]\ntitle: Hello\n");
assert_eq!(&content[body_start..], "# Body\n");
}
#[test]
fn extract_frontmatter_only_no_trailing_newline() {
let content = "---\ntags: [a]\n---";
let (yaml, body_start) = extract_raw_frontmatter(content).unwrap();
assert_eq!(yaml, "tags: [a]\n");
assert_eq!(body_start, content.len());
assert_eq!(&content[body_start..], "");
}
#[test]
fn extract_crlf_frontmatter() {
let content = "---\r\ntags: [a]\r\n---\r\nbody";
let (yaml, body_start) = extract_raw_frontmatter(content).unwrap();
assert_eq!(yaml, "tags: [a]\r\n");
assert_eq!(&content[body_start..], "body");
}
#[test]
fn extract_no_closing_delimiter() {
assert!(extract_raw_frontmatter("---\ntags: [a]\n").is_none());
}
#[test]
fn extract_four_dashes_not_frontmatter() {
assert!(extract_raw_frontmatter("----\ntags: [a]\n---\n").is_none());
}
#[test]
fn extract_delimiter_with_trailing_space_not_matched() {
assert!(extract_raw_frontmatter("--- \ntags: [a]\n---\n").is_none());
}
#[test]
fn extract_hr_in_body_not_confused() {
let content = "---\ntitle: Test\n---\nSome text\n\n---\n\nMore text\n";
let (yaml, body_start) = extract_raw_frontmatter(content).unwrap();
assert_eq!(yaml, "title: Test\n");
assert_eq!(&content[body_start..], "Some text\n\n---\n\nMore text\n");
}
#[test]
fn body_with_frontmatter() {
assert_eq!(get_body("---\na: 1\n---\nbody"), "body");
}
#[test]
fn body_without_frontmatter() {
assert_eq!(get_body("just text"), "just text");
}
#[test]
fn body_empty_after_frontmatter() {
assert_eq!(get_body("---\na: 1\n---\n"), "");
}
#[test]
fn parse_no_frontmatter() {
assert_eq!(parse_frontmatter("# Title").unwrap(), None);
}
#[test]
fn parse_empty_frontmatter() {
let result = parse_frontmatter("---\n---\n").unwrap().unwrap();
assert_eq!(result, json!({}));
}
#[test]
fn parse_standard_frontmatter() {
let content = "---\ntags:\n - rust\n - mcp\ntitle: Hello\n---\nbody";
let fm = parse_frontmatter(content).unwrap().unwrap();
assert_eq!(fm["tags"], json!(["rust", "mcp"]));
assert_eq!(fm["title"], json!("Hello"));
}
#[test]
fn parse_various_value_types() {
let content = "---\ncount: 42\nactive: true\ntags: [a, b]\n---\n";
let fm = parse_frontmatter(content).unwrap().unwrap();
assert_eq!(fm["count"], json!(42));
assert_eq!(fm["active"], json!(true));
assert_eq!(fm["tags"], json!(["a", "b"]));
}
#[test]
fn parse_malformed_yaml() {
let content = "---\n[invalid yaml\n---\nbody";
assert!(parse_frontmatter(content).is_err());
}
#[test]
fn parse_non_mapping_yaml_array() {
let content = "---\n- a\n- b\n---\nbody";
let err = parse_frontmatter(content).unwrap_err();
assert!(err.to_string().contains("array"));
}
#[test]
fn parse_non_mapping_yaml_scalar() {
let content = "---\njust a string\n---\nbody";
let err = parse_frontmatter(content).unwrap_err();
assert!(err.to_string().contains("string"));
}
#[test]
fn tags_from_array() {
let fm = json!({"tags": ["rust", "mcp"]});
assert_eq!(extract_frontmatter_tags(&fm), vec!["rust", "mcp"]);
}
#[test]
fn tags_from_comma_string() {
let fm = json!({"tags": "rust, mcp, tools"});
assert_eq!(extract_frontmatter_tags(&fm), vec!["rust", "mcp", "tools"]);
}
#[test]
fn tags_missing_key() {
let fm = json!({"title": "Hello"});
assert!(extract_frontmatter_tags(&fm).is_empty());
}
#[test]
fn tags_with_non_string_elements() {
let fm = json!({"tags": [42, "rust", true, null]});
let tags = extract_frontmatter_tags(&fm);
assert_eq!(tags, vec!["42", "rust", "true"]);
}
#[test]
fn aliases_from_array() {
let fm = json!({"aliases": ["server", "mcp-server"]});
assert_eq!(extract_aliases(&fm), vec!["server", "mcp-server"]);
}
#[test]
fn aliases_from_comma_string() {
let fm = json!({"aliases": "server, mcp-server"});
assert_eq!(extract_aliases(&fm), vec!["server", "mcp-server"]);
}
#[test]
fn aliases_missing() {
assert!(extract_aliases(&json!({})).is_empty());
}
#[test]
fn set_field_in_existing_frontmatter() {
let content = "---\ntags:\n- a\n---\nbody";
let result = set_frontmatter_field(content, "title", json!("Hello")).unwrap();
let fm = parse_frontmatter(&result).unwrap().unwrap();
assert_eq!(fm["title"], json!("Hello"));
assert_eq!(fm["tags"], json!(["a"]));
assert_eq!(get_body(&result), "body");
}
#[test]
fn set_field_creates_frontmatter() {
let content = "just body";
let result = set_frontmatter_field(content, "title", json!("Hello")).unwrap();
let fm = parse_frontmatter(&result).unwrap().unwrap();
assert_eq!(fm["title"], json!("Hello"));
assert_eq!(get_body(&result), "just body");
}
#[test]
fn set_field_overwrites_existing() {
let content = "---\ntitle: Old\n---\nbody";
let result = set_frontmatter_field(content, "title", json!("New")).unwrap();
let fm = parse_frontmatter(&result).unwrap().unwrap();
assert_eq!(fm["title"], json!("New"));
}
#[test]
fn remove_existing_field() {
let content = "---\ntags:\n- a\ntitle: Hello\n---\nbody";
let result = remove_frontmatter_field(content, "tags").unwrap();
let fm = parse_frontmatter(&result).unwrap().unwrap();
assert!(fm.get("tags").is_none());
assert_eq!(fm["title"], json!("Hello"));
assert_eq!(get_body(&result), "body");
}
#[test]
fn remove_nonexistent_field() {
let content = "---\ntags:\n- a\n---\nbody";
let result = remove_frontmatter_field(content, "missing").unwrap();
assert_eq!(result, content);
}
#[test]
fn remove_last_field_strips_frontmatter() {
let content = "---\ntags:\n- a\n---\nbody";
let result = remove_frontmatter_field(content, "tags").unwrap();
assert_eq!(result, "body");
assert!(parse_frontmatter(&result).unwrap().is_none());
}
#[test]
fn remove_from_no_frontmatter() {
let content = "just body";
let result = remove_frontmatter_field(content, "tags").unwrap();
assert_eq!(result, "just body");
}
#[test]
fn rebuild_with_frontmatter() {
let fm = json!({"title": "Hello"});
let result = rebuild_content(Some(&fm), "body\n");
assert!(result.starts_with("---\n"));
assert!(result.ends_with("---\nbody\n"));
assert!(result.contains("title:"));
}
#[test]
fn rebuild_no_frontmatter() {
assert_eq!(rebuild_content(None, "body"), "body");
}
#[test]
fn rebuild_empty_frontmatter_returns_body() {
assert_eq!(rebuild_content(Some(&json!({})), "body"), "body");
}
#[test]
fn roundtrip_preserves_body_exactly() {
let body = "\n# Heading\n\nSome text with **bold** and `code`.\n\n- list item\n";
let content = format!("---\ntags:\n- rust\n---\n{body}");
let fm = parse_frontmatter(&content).unwrap().unwrap();
let rebuilt = rebuild_content(Some(&fm), get_body(&content));
assert_eq!(get_body(&rebuilt), body);
}
#[test]
fn roundtrip_set_then_remove_restores_body() {
let original = "just the body\nwith multiple lines\n";
let with_field = set_frontmatter_field(original, "title", json!("Test")).unwrap();
let restored = remove_frontmatter_field(&with_field, "title").unwrap();
assert_eq!(restored, original);
}
}