use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Frontmatter {
pub title: Option<String>,
pub slug: Option<String>,
pub theme: Option<String>,
pub expiry: Option<String>,
pub password: Option<String>,
pub description: Option<String>,
#[serde(flatten)]
pub _extra: HashMap<String, serde_yaml::Value>,
}
#[derive(Debug)]
pub struct FrontmatterResult {
pub meta: Option<Frontmatter>,
pub body: 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_yaml::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,
})
}
fn is_marker(line: &str, tag: &str) -> bool {
let t = line.trim();
if !t.starts_with("<!--") || !t.ends_with("-->") {
return false;
}
let inner = &t["<!--".len()..t.len() - "-->".len()];
inner.trim() == tag
}
pub struct ParseResult {
pub human: String,
pub agent: Option<String>,
}
pub fn parse_document(source: &str, slug: &str) -> ParseResult {
let mut human_lines: Vec<&str> = Vec::new();
let mut agent_lines: Vec<&str> = Vec::new();
let mut in_agent_section = false;
for line in source.lines() {
if !in_agent_section && is_marker(line, "@agent") {
in_agent_section = true;
} else if in_agent_section && is_marker(line, "@end") {
in_agent_section = false;
} else if in_agent_section {
agent_lines.push(line);
} else {
human_lines.push(line);
}
}
if in_agent_section {
tracing::warn!(
slug = %slug,
"Unclosed @agent marker in document — all content after the open marker \
is hidden from human view"
);
}
ParseResult {
human: human_lines.join("\n"),
agent: if agent_lines.is_empty() {
None
} else {
Some(agent_lines.join("\n"))
},
}
}
pub fn extract_title(source: &str, slug: &str) -> String {
for line in source.lines() {
if let Some(rest) = line.strip_prefix("# ") {
let title = rest.trim();
if !title.is_empty() {
return title.to_string();
}
}
}
slug.to_string()
}
pub fn parse_expiry(s: &str) -> Result<u64, String> {
let s = s.trim();
if s.is_empty() {
return Err("Expiry must not be empty".to_string());
}
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str
.parse()
.map_err(|_| format!("Invalid expiry format: '{s}' (expected e.g., 7d, 24h, 30m, 2w)"))?;
let seconds = match unit {
"m" => num * 60,
"h" => num * 3600,
"d" => num * 86400,
"w" => num * 604800,
_ => return Err(format!("Invalid expiry unit: '{unit}' (expected m, h, d, or w)")),
};
let min_seconds = 5 * 60; let max_seconds = 365 * 86400;
if seconds < min_seconds {
return Err("Expiry must be at least 5 minutes".to_string());
}
if seconds > max_seconds {
return Err("Expiry must not exceed 365 days".to_string());
}
Ok(seconds)
}
pub fn validate_slug(slug: &str) -> Result<(), String> {
if slug.len() < 3 {
return Err("Slug must be at least 3 characters".to_string());
}
if slug.len() > 128 {
return Err("Slug must not exceed 128 characters".to_string());
}
if slug.starts_with('-') || slug.ends_with('-') {
return Err("Slug must not start or end with a hyphen".to_string());
}
for ch in slug.chars() {
if !ch.is_ascii_alphanumeric() && ch != '-' {
return Err(format!(
"Slug contains invalid character '{ch}' (only alphanumeric and hyphen allowed)"
));
}
}
let reserved = ["api", "health", "status", "favicon.ico", "robots.txt"];
if reserved.contains(&slug) {
return Err(format!("Slug '{slug}' is reserved"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_single_agent_section() {
let src = "Before.\n<!-- @agent -->\nHidden.\n<!-- @end -->\nAfter.";
let r = parse_document(src, "test");
assert!(r.human.contains("Before."));
assert!(r.human.contains("After."));
assert!(!r.human.contains("Hidden."));
assert!(!r.human.contains("@agent"));
}
#[test]
fn strips_multiple_sections() {
let src = "A.\n<!-- @agent -->\nH1.\n<!-- @end -->\nB.\n<!-- @agent -->\nH2.\n<!-- @end -->\nC.";
let r = parse_document(src, "test");
assert!(r.human.contains("A.") && r.human.contains("B.") && r.human.contains("C."));
assert!(!r.human.contains("H1.") && !r.human.contains("H2."));
}
#[test]
fn tight_spacing_markers() {
let src = "X.\n<!--@agent-->\nHidden.\n<!--@end-->\nY.";
let r = parse_document(src, "test");
assert!(r.human.contains("X.") && r.human.contains("Y."));
assert!(!r.human.contains("Hidden."));
}
#[test]
fn loose_spacing_markers() {
let src = "X.\n<!-- @agent -->\nHidden.\n<!-- @end -->\nY.";
let r = parse_document(src, "test");
assert!(r.human.contains("X.") && r.human.contains("Y."));
assert!(!r.human.contains("Hidden."));
}
#[test]
fn inline_marker_not_parsed() {
let src = "Use `<!-- @agent -->` inline.\nStill visible.";
let r = parse_document(src, "test");
assert!(r.human.contains("Use"));
assert!(r.human.contains("Still visible."));
}
#[test]
fn orphan_close_ignored() {
let src = "A.\n<!-- @end -->\nB.";
let r = parse_document(src, "test");
assert!(r.human.contains("A.") && r.human.contains("B."));
}
#[test]
fn unclosed_hides_remainder() {
let src = "Visible.\n<!-- @agent -->\nHidden forever.";
let r = parse_document(src, "test");
assert!(r.human.contains("Visible."));
assert!(!r.human.contains("Hidden forever."));
}
#[test]
fn nested_agent_marker_treated_as_content() {
let src = "Before.\n<!-- @agent -->\nAgent line 1.\n<!-- @agent -->\nAgent line 2.\n<!-- @end -->\nAfter.";
let r = parse_document(src, "test");
assert!(r.human.contains("Before."));
assert!(r.human.contains("After."));
assert!(!r.human.contains("Agent line 1."));
assert!(!r.human.contains("Agent line 2."));
}
#[test]
fn extract_title_basic() {
assert_eq!(extract_title("# Hello World\n\nContent.", "fallback"), "Hello World");
}
#[test]
fn extract_title_falls_back_to_slug() {
assert_eq!(extract_title("No heading here.", "my-slug"), "my-slug");
}
#[test]
fn extract_title_in_agent_section() {
let src = "<!-- @agent -->\n# Hidden Title\n<!-- @end -->\nContent.";
assert_eq!(extract_title(src, "fallback"), "Hidden Title");
}
#[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 parse_expiry_valid() {
assert_eq!(parse_expiry("30m").unwrap(), 1800);
assert_eq!(parse_expiry("1h").unwrap(), 3600);
assert_eq!(parse_expiry("7d").unwrap(), 604800);
assert_eq!(parse_expiry("2w").unwrap(), 1209600);
}
#[test]
fn parse_expiry_too_short() {
assert!(parse_expiry("4m").is_err());
}
#[test]
fn parse_expiry_too_long() {
assert!(parse_expiry("366d").is_err());
}
#[test]
fn parse_expiry_invalid_unit() {
assert!(parse_expiry("7x").is_err());
}
#[test]
fn validate_slug_valid() {
assert!(validate_slug("hello-world").is_ok());
assert!(validate_slug("abc").is_ok());
assert!(validate_slug("MyReport2024").is_ok());
}
#[test]
fn validate_slug_too_short() {
assert!(validate_slug("ab").is_err());
}
#[test]
fn validate_slug_invalid_chars() {
assert!(validate_slug("has spaces").is_err());
assert!(validate_slug("under_score").is_err());
}
#[test]
fn validate_slug_hyphen_start_end() {
assert!(validate_slug("-starts").is_err());
assert!(validate_slug("ends-").is_err());
}
#[test]
fn validate_slug_reserved() {
assert!(validate_slug("api").is_err());
}
}