use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Frontmatter {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiry: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(flatten)]
pub _extra: HashMap<String, serde_yml::Value>,
}
#[derive(Debug)]
pub struct FrontmatterResult {
pub meta: Option<Frontmatter>,
pub body: String,
}
#[derive(Debug, Default)]
pub struct FrontmatterFields {
pub title: Option<String>,
pub slug: Option<String>,
pub password: Option<String>,
pub expiry: Option<String>,
pub theme: Option<String>,
pub description: Option<String>,
}
pub fn extract_frontmatter(source: &str) -> Result<FrontmatterResult, String> {
let lines: Vec<&str> = source.lines().collect();
if lines.is_empty() || lines[0].trim() != "---" {
return Ok(FrontmatterResult {
meta: None,
body: source.to_string(),
});
}
let mut close_idx = None;
for (i, line) in lines.iter().enumerate().skip(1) {
if line.trim() == "---" {
close_idx = Some(i);
break;
}
}
let close_idx = match close_idx {
Some(i) => i,
None => {
return Ok(FrontmatterResult {
meta: None,
body: source.to_string(),
});
}
};
let yaml_content = lines[1..close_idx].join("\n");
let meta: Frontmatter = if yaml_content.trim().is_empty() {
Frontmatter::default()
} else {
serde_yml::from_str(&yaml_content).map_err(|e| format!("Invalid frontmatter: {e}"))?
};
let body = if close_idx + 1 < lines.len() {
let remaining = &lines[close_idx + 1..];
remaining.join("\n")
} else {
String::new()
};
Ok(FrontmatterResult {
meta: Some(meta),
body,
})
}
pub fn apply_frontmatter(content: &str, fields: FrontmatterFields) -> String {
let has_any = fields.title.is_some()
|| fields.slug.is_some()
|| fields.password.is_some()
|| fields.expiry.is_some()
|| fields.theme.is_some()
|| fields.description.is_some();
if !has_any {
return content.to_string();
}
if content.trim_start().starts_with("---") {
merge_into_existing(content, &fields)
} else {
prepend_new_block(content, &fields)
}
}
fn prepend_new_block(content: &str, fields: &FrontmatterFields) -> String {
let fm = Frontmatter {
title: fields.title.clone(),
slug: fields.slug.clone(),
password: fields.password.clone(),
expiry: fields.expiry.clone(),
theme: fields.theme.clone(),
description: fields.description.clone(),
_extra: HashMap::new(),
};
let yaml_body = serde_yml::to_string(&fm)
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "serde_yml serialization failed — falling back to empty frontmatter");
String::new()
});
let yaml_body = yaml_body.trim_end_matches('\n');
let mut result = String::from("---\n");
if !yaml_body.is_empty() {
result.push_str(yaml_body);
result.push('\n');
}
result.push_str("---\n");
result.push_str(content);
result
}
fn merge_into_existing(content: &str, fields: &FrontmatterFields) -> String {
let lines: Vec<&str> = content.lines().collect();
let mut close_idx = None;
for (i, line) in lines.iter().enumerate().skip(1) {
if line.trim() == "---" {
close_idx = Some(i);
break;
}
}
let close_idx = match close_idx {
Some(i) => i,
None => {
return prepend_new_block(content, fields);
}
};
let mut overrides: HashMap<&str, &str> = HashMap::new();
if let Some(ref t) = fields.title {
overrides.insert("title", t.as_str());
}
if let Some(ref s) = fields.slug {
overrides.insert("slug", s.as_str());
}
if let Some(ref p) = fields.password {
overrides.insert("password", p.as_str());
}
if let Some(ref ex) = fields.expiry {
overrides.insert("expiry", ex.as_str());
}
if let Some(ref th) = fields.theme {
overrides.insert("theme", th.as_str());
}
if let Some(ref d) = fields.description {
overrides.insert("description", d.as_str());
}
let mut written_keys: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut fm_lines: Vec<String> = Vec::new();
fm_lines.push(lines[0].to_string());
for line in &lines[1..close_idx] {
let mut replaced = false;
for &key in overrides.keys() {
let prefix = format!("{}:", key);
if line.trim_start().starts_with(&prefix) {
fm_lines.push(format_yaml_kv(key, overrides[key]));
written_keys.insert(key);
replaced = true;
break;
}
}
if !replaced {
fm_lines.push(line.to_string());
}
}
for &key in overrides.keys() {
if !written_keys.contains(key) {
fm_lines.push(format_yaml_kv(key, overrides[key]));
}
}
fm_lines.push("---".to_string());
let body_lines = &lines[close_idx + 1..];
let mut result = fm_lines.join("\n");
if !body_lines.is_empty() {
result.push('\n');
result.push_str(&body_lines.join("\n"));
}
result
}
fn format_yaml_kv(key: &str, value: &str) -> String {
use std::collections::BTreeMap;
let mut map = BTreeMap::new();
map.insert(key, value);
let yaml = serde_yml::to_string(&map).unwrap_or_else(|_| format!("{key}: {value}"));
yaml.trim_end_matches('\n').to_string()
}
pub fn contains_marker_directive(s: &str) -> bool {
s.lines().any(|line| {
let t = line.trim();
t == "<!-- @agent -->" || t == "<!-- @end -->"
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frontmatter_basic() {
let src = "---\ntitle: Hello\nslug: hello-world\n---\n\n# Body";
let result = extract_frontmatter(src).unwrap();
let meta = result.meta.unwrap();
assert_eq!(meta.title.as_deref(), Some("Hello"));
assert_eq!(meta.slug.as_deref(), Some("hello-world"));
assert!(result.body.contains("# Body"));
assert!(!result.body.contains("---"));
}
#[test]
fn frontmatter_empty_block() {
let src = "---\n---\n\n# Just body";
let result = extract_frontmatter(src).unwrap();
assert!(result.meta.is_some());
assert!(result.body.contains("# Just body"));
}
#[test]
fn no_frontmatter() {
let src = "# No frontmatter\n\nJust content.";
let result = extract_frontmatter(src).unwrap();
assert!(result.meta.is_none());
assert_eq!(result.body, src);
}
#[test]
fn frontmatter_unclosed() {
let src = "---\ntitle: Broken\nNo closing fence.";
let result = extract_frontmatter(src).unwrap();
assert!(result.meta.is_none());
assert_eq!(result.body, src);
}
#[test]
fn frontmatter_invalid_yaml() {
let src = "---\n[invalid: yaml: broken\n---\n\nBody.";
let result = extract_frontmatter(src);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid frontmatter"));
}
#[test]
fn apply_no_fields_returns_unchanged() {
let content = "# Hello\n\nContent.";
let result = apply_frontmatter(content, FrontmatterFields::default());
assert_eq!(result, content);
}
#[test]
fn apply_prepends_when_no_existing_fm() {
let content = "# Hello\n\nContent.";
let fields = FrontmatterFields {
title: Some("My Title".to_string()),
slug: Some("my-slug".to_string()),
..Default::default()
};
let result = apply_frontmatter(content, fields);
assert!(result.starts_with("---\n"));
assert!(result.contains("title: My Title"));
assert!(result.contains("slug: my-slug"));
assert!(result.contains("# Hello"));
}
#[test]
fn apply_merges_into_existing_fm() {
let content = "---\ntitle: Old Title\nslug: old-slug\n---\n# Body";
let fields = FrontmatterFields {
title: Some("New Title".to_string()),
..Default::default()
};
let result = apply_frontmatter(content, fields);
assert!(result.contains("title: New Title"));
assert!(result.contains("slug: old-slug"));
assert!(result.contains("# Body"));
}
#[test]
fn apply_appends_missing_field_to_existing_fm() {
let content = "---\ntitle: Existing\n---\n# Body";
let fields = FrontmatterFields {
expiry: Some("7d".to_string()),
..Default::default()
};
let result = apply_frontmatter(content, fields);
assert!(result.contains("title: Existing"));
assert!(result.contains("expiry:") && result.contains("7d"));
assert!(result.contains("# Body"));
}
#[test]
fn marker_directive_detected() {
assert!(contains_marker_directive(
"some text\n<!-- @agent -->\nmore"
));
assert!(contains_marker_directive("<!-- @end -->"));
}
#[test]
fn marker_directive_inline_not_detected() {
assert!(!contains_marker_directive("Use `<!-- @agent -->` inline."));
}
#[test]
fn marker_directive_absent() {
assert!(!contains_marker_directive(
"# Plain markdown\n\nNo markers."
));
}
}