use gray_matter::engine::YAML;
use gray_matter::Matter;
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum FrontmatterError {
#[error("Invalid frontmatter format: {0}")]
InvalidFormat(String),
#[error("YAML parse error: {0}")]
YamlError(#[from] serde_yaml::Error),
}
fn split_title_body(content: &str) -> (String, String) {
let lines: Vec<&str> = content.lines().skip_while(|line| line.is_empty()).collect();
if lines.first().is_some_and(|l| l.starts_with("# ")) {
let title = lines
.first()
.and_then(|l| l.strip_prefix("# "))
.unwrap_or("")
.to_string();
let body = lines
.get(1..)
.unwrap_or(&[])
.iter()
.skip_while(|line| line.is_empty())
.copied()
.collect::<Vec<_>>()
.join("\n")
.trim_end()
.to_string();
(title, body)
} else {
(String::new(), lines.join("\n").trim_end().to_string())
}
}
pub fn parse_frontmatter<T: DeserializeOwned>(
content: &str,
) -> Result<(T, String, String), FrontmatterError> {
let matter = Matter::<YAML>::new();
let result: gray_matter::ParsedEntity<serde_yaml::Value> = matter
.parse(content)
.map_err(|e| FrontmatterError::InvalidFormat(e.to_string()))?;
if result.matter.is_empty() {
return Err(FrontmatterError::InvalidFormat(
"No frontmatter found".to_string(),
));
}
let metadata: T = serde_yaml::from_str(&result.matter)?;
let (title, body) = split_title_body(&result.content);
Ok((metadata, title, body))
}
pub fn parse_frontmatter_raw(
content: &str,
) -> Result<(serde_yaml::Value, String, String), FrontmatterError> {
let matter = Matter::<YAML>::new();
let result: gray_matter::ParsedEntity<serde_yaml::Value> = matter
.parse(content)
.map_err(|e| FrontmatterError::InvalidFormat(e.to_string()))?;
if result.matter.is_empty() {
return Err(FrontmatterError::InvalidFormat(
"No frontmatter found".to_string(),
));
}
let value: serde_yaml::Value = serde_yaml::from_str(&result.matter)?;
let (title, body) = split_title_body(&result.content);
Ok((value, title, body))
}
pub fn extract_frontmatter_comment(content: &str) -> Option<String> {
let content = content.trim_start();
if !content.starts_with("---") {
return None;
}
let rest = content.get(3..)?.trim_start_matches('\n');
let end = rest.find("\n---")?;
let fm_block = &rest[..end];
let mut comment_lines: Vec<&str> = Vec::new();
for line in fm_block.lines() {
if line.starts_with('#') {
comment_lines.push(line);
} else {
break;
}
}
if comment_lines.is_empty() {
None
} else {
Some(comment_lines.join("\n"))
}
}
pub fn generate_frontmatter_raw(
value: &serde_yaml::Value,
title: &str,
body: &str,
comment: Option<&str>,
) -> String {
let yaml = serde_yaml::to_string(value).unwrap_or_default();
let yaml = yaml.trim_end();
let fm_content = match comment {
Some(c) if !c.is_empty() => format!("{c}\n{yaml}"),
_ => yaml.to_string(),
};
let title_trimmed = title.trim();
if title_trimmed.is_empty() {
if body.is_empty() {
format!("---\n{fm_content}\n---\n")
} else {
format!("---\n{fm_content}\n---\n\n{body}\n")
}
} else if body.is_empty() {
format!("---\n{fm_content}\n---\n\n# {title_trimmed}\n")
} else {
format!("---\n{fm_content}\n---\n\n# {title_trimmed}\n\n{body}\n")
}
}
pub fn generate_frontmatter<T: Serialize>(
metadata: &T,
title: &str,
body: &str,
comment: Option<&str>,
) -> String {
let yaml = serde_yaml::to_string(metadata).unwrap_or_default();
let yaml = yaml.trim_end();
let fm_content = match comment {
Some(c) if !c.is_empty() => format!("{c}\n{yaml}"),
_ => yaml.to_string(),
};
let title_trimmed = title.trim();
if title_trimmed.is_empty() {
if body.is_empty() {
format!("---\n{fm_content}\n---\n")
} else {
format!("---\n{fm_content}\n---\n\n{body}\n")
}
} else if body.is_empty() {
format!("---\n{fm_content}\n---\n\n# {title_trimmed}\n")
} else {
format!("---\n{fm_content}\n---\n\n# {title_trimmed}\n\n{body}\n")
}
}
#[cfg(test)]
mod tests {
fn escape_yaml_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct TestMetadata {
display_number: u32,
status: String,
#[serde(default)]
draft: bool,
}
#[test]
fn test_parse_frontmatter_basic() {
let content = r"---
displayNumber: 42
status: open
draft: false
---
# Test Title
This is the body content.";
let (metadata, title, body): (TestMetadata, String, String) =
parse_frontmatter(content).unwrap();
assert_eq!(metadata.display_number, 42);
assert_eq!(metadata.status, "open");
assert!(!metadata.draft);
assert_eq!(title, "Test Title");
assert_eq!(body, "This is the body content.");
}
#[test]
fn test_parse_frontmatter_empty_body() {
let content = r"---
displayNumber: 1
status: closed
---
# Just a Title";
let (metadata, title, body): (TestMetadata, String, String) =
parse_frontmatter(content).unwrap();
assert_eq!(metadata.display_number, 1);
assert_eq!(title, "Just a Title");
assert_eq!(body, "");
}
#[test]
fn test_parse_frontmatter_multiline_body() {
let content = r"---
displayNumber: 5
status: in-progress
---
# Multi Line
Line 1.
Line 2.
Line 3.";
let (metadata, title, body): (TestMetadata, String, String) =
parse_frontmatter(content).unwrap();
assert_eq!(metadata.display_number, 5);
assert_eq!(title, "Multi Line");
assert_eq!(body, "Line 1.\n\nLine 2.\n\nLine 3.");
}
#[test]
fn test_parse_frontmatter_missing_opening() {
let content = "# No Frontmatter\n\nJust content.";
let result: Result<(TestMetadata, String, String), _> = parse_frontmatter(content);
assert!(result.is_err());
}
#[test]
fn test_parse_frontmatter_missing_closing() {
let content = "---\ndisplayNumber: 1\nstatus: open\n# Title";
let result: Result<(TestMetadata, String, String), _> = parse_frontmatter(content);
assert!(result.is_err());
}
#[test]
fn test_generate_frontmatter_basic() {
let metadata = TestMetadata {
display_number: 42,
status: "open".to_string(),
draft: false,
};
let result = generate_frontmatter(&metadata, "Test Title", "Body content.", None);
assert!(result.starts_with("---\n"));
assert!(result.contains("displayNumber: 42"));
assert!(result.contains("status: open"));
assert!(result.contains("# Test Title"));
assert!(result.contains("Body content."));
}
#[test]
fn test_generate_frontmatter_empty_body() {
let metadata = TestMetadata {
display_number: 1,
status: "closed".to_string(),
draft: true,
};
let result = generate_frontmatter(&metadata, "Title Only", "", None);
assert!(result.contains("# Title Only"));
assert!(result.ends_with("# Title Only\n"));
}
#[test]
fn test_generate_frontmatter_with_comment() {
let metadata = TestMetadata {
display_number: 1,
status: "open".to_string(),
draft: false,
};
let comment = "# This file was auto-generated. Do not edit manually.";
let result = generate_frontmatter(&metadata, "My Doc", "Body.", Some(comment));
assert!(result.starts_with("---\n# This file was auto-generated"));
assert!(result.contains("displayNumber: 1"));
}
#[test]
fn test_roundtrip() {
let original_metadata = TestMetadata {
display_number: 99,
status: "review".to_string(),
draft: true,
};
let original_title = "Round Trip Test";
let original_body = "This should survive the round trip.";
let generated =
generate_frontmatter(&original_metadata, original_title, original_body, None);
let (parsed_metadata, parsed_title, parsed_body): (TestMetadata, String, String) =
parse_frontmatter(&generated).unwrap();
assert_eq!(parsed_metadata, original_metadata);
assert_eq!(parsed_title, original_title);
assert_eq!(parsed_body, original_body);
}
#[test]
fn test_roundtrip_with_comment() {
let original_metadata = TestMetadata {
display_number: 5,
status: "open".to_string(),
draft: false,
};
let comment = "# Auto-generated";
let generated = generate_frontmatter(&original_metadata, "Title", "Body.", Some(comment));
let extracted = extract_frontmatter_comment(&generated);
let (parsed_metadata, parsed_title, _): (TestMetadata, String, String) =
parse_frontmatter(&generated).unwrap();
assert_eq!(extracted, Some(comment.to_string()));
assert_eq!(parsed_metadata, original_metadata);
assert_eq!(parsed_title, "Title");
}
#[test]
fn test_extract_frontmatter_comment_none() {
let content = "---\ndisplayNumber: 1\nstatus: open\n---\n\n# Title\n";
assert_eq!(extract_frontmatter_comment(content), None);
}
#[test]
fn test_extract_frontmatter_comment_multiline() {
let content = "---\n# Line 1\n# Line 2\ndisplayNumber: 1\n---\n\n# Title\n";
assert_eq!(
extract_frontmatter_comment(content),
Some("# Line 1\n# Line 2".to_string())
);
}
#[test]
fn test_generate_frontmatter_empty_title_no_body() {
let metadata = TestMetadata {
display_number: 1,
status: "open".to_string(),
draft: false,
};
let result = generate_frontmatter(&metadata, "", "", None);
assert!(!result.contains("# "), "should not contain H1 heading");
assert!(result.starts_with("---\n"));
}
#[test]
fn test_generate_frontmatter_empty_title_with_body() {
let metadata = TestMetadata {
display_number: 1,
status: "open".to_string(),
draft: false,
};
let result = generate_frontmatter(&metadata, "", "body text here", None);
assert!(!result.contains("# "), "should not contain H1 heading");
assert!(result.contains("body text here"));
}
#[test]
fn test_generate_frontmatter_whitespace_only_title() {
let metadata = TestMetadata {
display_number: 1,
status: "open".to_string(),
draft: false,
};
let result = generate_frontmatter(&metadata, " ", "body", None);
assert!(!result.contains("# "), "should not contain H1 heading");
assert!(result.contains("body"));
}
#[test]
fn test_escape_yaml_string() {
assert_eq!(escape_yaml_string("hello"), "hello");
assert_eq!(escape_yaml_string(r#"say "hi""#), r#"say \"hi\""#);
assert_eq!(escape_yaml_string(r"back\slash"), r"back\\slash");
}
}