use regex::Regex;
use serde_yaml::Value;
pub fn split_frontmatter(blob: &str) -> FrontmatterSplit {
let parts: Vec<&str> = blob.split('\n').collect();
if parts.is_empty() || parts[0].trim_end() != "---" {
return FrontmatterSplit {
fm: String::new(),
body: blob.to_string(),
raw: blob.to_string(),
};
}
let mut close_idx = None;
for (i, line) in parts[1..].iter().enumerate() {
if line.trim_end() == "---" {
close_idx = Some(i + 1); break;
}
}
match close_idx {
None => {
FrontmatterSplit {
fm: String::new(),
body: blob.to_string(),
raw: blob.to_string(),
}
}
Some(idx) => {
let fm = parts[1..idx].join("\n");
let body = parts[idx + 1..].join("\n");
FrontmatterSplit {
fm,
body,
raw: blob.to_string(),
}
}
}
}
pub struct FrontmatterSplit {
pub fm: String,
pub body: String,
#[allow(dead_code)]
pub raw: String,
}
pub fn parse_frontmatter(fm: &str) -> Option<serde_yaml::Value> {
if fm.trim().is_empty() {
return None;
}
match serde_yaml::from_str::<Value>(fm) {
Ok(Value::Mapping(_)) => serde_yaml::from_str(fm).ok(),
Ok(Value::Null) => None,
_ => None,
}
}
pub fn extract_wiki_links(body: &str) -> Vec<String> {
let re_fence = Regex::new(r"```[\s\S]*?```|~~~[\s\S]*?~~~").unwrap();
let without_fences = re_fence.replace_all(body, "").to_string();
let link_re = Regex::new(r"\[\[([^\]|#]+)(?:#[^\]]*)?(?:\|[^\]]*)?\]\]").unwrap();
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for cap in link_re.captures_iter(&without_fences) {
if let Some(slug_match) = cap.get(1) {
let slug = slug_match.as_str().to_string();
if seen.insert(slug.clone()) {
result.push(slug);
}
}
}
result
}
pub fn extract_section(body: &str, name: &str) -> String {
let heading = format!("## {name}");
let lines: Vec<&str> = body.lines().collect();
let mut in_section = false;
let mut section_lines: Vec<&str> = Vec::new();
for line in &lines {
if line.starts_with("## ") {
if in_section {
break;
}
if *line == heading {
in_section = true;
}
} else if in_section {
section_lines.push(line);
}
}
while section_lines.last().is_some_and(|l| l.trim().is_empty()) {
section_lines.pop();
}
while section_lines.first().is_some_and(|l| l.trim().is_empty()) {
section_lines.remove(0);
}
section_lines.join("\n")
}
fn is_heading(line: &str) -> bool {
line.starts_with("## ")
}
pub fn extract_last_review_verdict(body: &str) -> Option<String> {
let lines: Vec<&str> = body.lines().collect();
let mut in_review = false;
let mut last_verdict: Option<String> = None;
for line in &lines {
if is_heading(line) {
in_review = line.trim() == "## Review";
continue;
}
if in_review {
if let Some(stripped) = line.strip_prefix("verdict:") {
let val = stripped.trim();
if !val.is_empty() {
last_verdict = Some(val.to_string());
}
}
}
}
last_verdict
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_canonical() {
let blob = "---\nid: test\nkind: task\n---\n\n## Goal\n\nBody.\n";
let s = split_frontmatter(blob);
assert_eq!(s.fm, "id: test\nkind: task");
assert_eq!(s.body, "\n## Goal\n\nBody.\n");
assert_eq!(s.raw, blob);
}
#[test]
fn test_split_no_opening() {
let blob = "# Just markdown\n\nNo frontmatter.\n";
let s = split_frontmatter(blob);
assert_eq!(s.fm, "");
assert_eq!(s.body, blob);
}
#[test]
fn test_split_malformed_no_closing() {
let blob = "---\nid: test\n";
let s = split_frontmatter(blob);
assert_eq!(s.fm, "");
assert_eq!(s.body, blob);
}
#[test]
fn test_split_no_body() {
let blob = "---\nid: test\n---\n";
let s = split_frontmatter(blob);
assert_eq!(s.fm, "id: test");
assert_eq!(s.body, "");
}
#[test]
fn test_split_thematic_break_no_reentry() {
let blob = "---\nid: thematic-break-test\nkind: task\n---\n\nSome text.\n\n---\n\nid: fake-id\nstatus: done\n\nMore body.\n";
let s = split_frontmatter(blob);
assert!(s.fm.contains("id: thematic-break-test"));
assert!(!s.fm.contains("fake-id"));
assert!(s.body.contains("---"));
assert!(s.body.contains("id: fake-id"));
}
#[test]
fn test_parse_inline_deps() {
let raw = "id: proxy\nkind: task\ndepends_on: [a, b]\n";
let val = parse_frontmatter(raw).unwrap();
assert_eq!(val["id"].as_str(), Some("proxy"));
assert_eq!(val["depends_on"][0].as_str(), Some("a"));
}
#[test]
fn test_parse_block_deps() {
let raw = "id: proxy\ndepends_on:\n - a\n - b\n";
let val = parse_frontmatter(raw).unwrap();
assert_eq!(val["id"].as_str(), Some("proxy"));
assert_eq!(val["depends_on"][0].as_str(), Some("a"));
assert_eq!(val["depends_on"][1].as_str(), Some("b"));
}
#[test]
fn test_parse_empty() {
assert!(parse_frontmatter("").is_none());
assert!(parse_frontmatter(" ").is_none());
}
#[test]
fn test_parse_quoted_status() {
let raw = "status: \"done\"\n";
let val = parse_frontmatter(raw).unwrap();
assert_eq!(val["status"].as_str(), Some("done"));
}
#[test]
fn test_parse_null() {
let raw = "parent:\n";
let val = parse_frontmatter(raw).unwrap();
assert!(val["parent"].is_null());
}
#[test]
fn test_wiki_links_basic() {
let body = "See [[slug]] and [[other|label]] and [[page#heading]].";
let links = extract_wiki_links(body);
assert_eq!(links, vec!["slug", "other", "page"]);
}
#[test]
fn test_wiki_links_dedupe() {
let body = "[[a]] and [[a]] and [[a|label]]";
let links = extract_wiki_links(body);
assert_eq!(links, vec!["a"]);
}
#[test]
fn test_wiki_links_skip_backtick_fence() {
let body = "```\n[[inside]]\n```\n[[outside]]";
let links = extract_wiki_links(body);
assert_eq!(links, vec!["outside"]);
}
#[test]
fn test_wiki_links_skip_tilde_fence() {
let body = "~~~\n[[inside]]\n~~~\n[[outside]]";
let links = extract_wiki_links(body);
assert_eq!(links, vec!["outside"]);
}
#[test]
fn test_extract_section_basic() {
let body = "## Goal\nDo stuff.\n\n## Context\nMore.\n";
let section = extract_section(body, "Goal");
assert_eq!(section, "Do stuff.");
}
#[test]
fn test_extract_section_multiline() {
let body = "## Acceptance\n- [ ] one\n- [ ] two\n\n## Notes\n";
let section = extract_section(body, "Acceptance");
assert_eq!(section, "- [ ] one\n- [ ] two");
}
#[test]
fn test_extract_section_missing() {
let body = "## Goal\nStuff.\n";
let section = extract_section(body, "Acceptance");
assert_eq!(section, "");
}
#[test]
fn test_extract_section_trim_blanks() {
let body = "## Goal\n\n\nContent\n\n\n## Next\n";
let section = extract_section(body, "Goal");
assert_eq!(section, "Content");
}
#[test]
fn test_verdict_last_wins() {
let body = "## Review\nverdict: changes-requested\n\n## Review\nverdict: approved\n";
let v = extract_last_review_verdict(body);
assert_eq!(v.as_deref(), Some("approved"));
}
#[test]
fn test_verdict_none() {
let body = "## Goal\nJust stuff.\n";
let v = extract_last_review_verdict(body);
assert_eq!(v, None);
}
#[test]
fn test_verdict_trimmed() {
let body = "## Review\nverdict: approved \n";
let v = extract_last_review_verdict(body);
assert_eq!(v.as_deref(), Some("approved"));
}
#[test]
fn test_verdict_no_verdict_line() {
let body = "## Review\nJust some notes.\n";
let v = extract_last_review_verdict(body);
assert_eq!(v, None);
}
}