use std::collections::HashMap;
pub fn extract_frontmatter(content: &str) -> Option<&str> {
let rest = content
.strip_prefix("---\r\n")
.or_else(|| content.strip_prefix("---\n"))?;
let end = rest.find("\n---")?;
Some(&rest[..end])
}
pub fn parse_yaml_frontmatter(text: &str) -> HashMap<String, String> {
let mut out = HashMap::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((k, v)) = line.split_once(':') else {
continue;
};
if k.starts_with(' ') || k.starts_with('\t') {
continue;
}
out.insert(k.trim().to_string(), v.trim().to_string());
}
out
}
pub fn section_body<'a>(content: &'a str, heading: &str, prefix_match: bool) -> Option<&'a str> {
let target = format!("## {heading}");
let mut start: Option<usize> = None;
let mut end: Option<usize> = None;
let mut idx = 0usize;
for line in content.split_inclusive('\n') {
let trimmed = line.trim_end_matches('\n');
if start.is_none() {
let hit = if prefix_match {
trimmed.starts_with(&target)
} else {
trimmed == target
};
if hit {
start = Some(idx + line.len());
}
} else if trimmed.starts_with("## ") {
end = Some(idx);
break;
}
idx += line.len();
}
let s = start?;
Some(&content[s..end.unwrap_or(content.len())])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_frontmatter_basic() {
let doc = "---\nid: 0001\ntitle: foo\n---\n\nbody\n";
let fm = extract_frontmatter(doc).unwrap();
let map = parse_yaml_frontmatter(fm);
assert_eq!(map.get("id").map(String::as_str), Some("0001"));
assert_eq!(map.get("title").map(String::as_str), Some("foo"));
}
#[test]
fn extract_frontmatter_missing() {
assert!(extract_frontmatter("no frontmatter\n").is_none());
assert!(extract_frontmatter("---\nunterminated\n").is_none());
}
#[test]
fn extract_frontmatter_crlf() {
let doc = "---\r\nid: 0001\r\ntitle: foo\r\nstatus: accepted\r\n---\r\n\r\nbody\r\n";
let fm = extract_frontmatter(doc).expect("CRLF frontmatter must parse");
let map = parse_yaml_frontmatter(fm);
assert_eq!(map.get("id").map(String::as_str), Some("0001"));
assert_eq!(map.get("status").map(String::as_str), Some("accepted"));
}
#[test]
fn section_body_extracts_until_next_h2() {
let doc = "# Title\n\n## A\nalpha\n\n## B\nbeta\n";
let body = section_body(doc, "A", false).unwrap();
assert_eq!(body.trim(), "alpha");
}
#[test]
fn section_body_prefix_match() {
let doc = "## Acceptance criteria(机械可验证)\n- [x] foo\n\n## Next\n";
let body = section_body(doc, "Acceptance criteria", true).unwrap();
assert!(body.contains("- [x] foo"));
}
}