use crate::error::{McError, McResult};
use serde_yaml::Value;
use std::path::Path;
pub fn split_frontmatter(content: &str) -> Option<(String, String)> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after_open = &trimmed[3..];
let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
if let Some(end_idx) = after_open.find("\n---") {
let fm = after_open[..end_idx].to_string();
let body = after_open[end_idx + 4..].to_string();
Some((fm, body))
} else {
None
}
}
pub fn parse_raw(fm_str: &str, source: &Path) -> McResult<Value> {
let val: Value = serde_yaml::from_str(fm_str).map_err(|e| McError::Frontmatter {
path: source.to_path_buf(),
message: e.to_string(),
})?;
Ok(val)
}
pub fn parse_file(path: &Path) -> McResult<(Value, String)> {
let content = std::fs::read_to_string(path)?;
match split_frontmatter(&content) {
Some((fm_str, body)) => {
let val: Value = serde_yaml::from_str(&fm_str).map_err(|e| McError::Frontmatter {
path: path.to_path_buf(),
message: e.to_string(),
})?;
Ok((val, body))
}
None => Err(McError::Frontmatter {
path: path.to_path_buf(),
message: "No YAML frontmatter found".into(),
}),
}
}
pub fn serialize_document(frontmatter: &Value, body: &str) -> String {
let yaml = serde_yaml::to_string(frontmatter)
.expect("serializing a serde_yaml::Value to YAML should never fail");
let yaml = yaml.trim_end();
let re_quote = regex::Regex::new(r"'(\[\[.+?\]\])'").unwrap();
let yaml = re_quote.replace_all(yaml, "\"$1\"");
let re_links = regex::Regex::new(r"\[\[(.+?)\]\]").unwrap();
let links: Vec<String> = re_links
.captures_iter(&yaml)
.map(|c| format!("[[{}]]", &c[1]))
.collect();
let re_mc = regex::Regex::new(r"\n?%% mc-links:.*%%\n?").unwrap();
let body = re_mc.replace_all(body, "");
let body = if links.is_empty() {
body.to_string()
} else {
let link_str = links.join(" ");
format!("{}\n%% mc-links: {} %%\n", body.trim_end(), link_str)
};
format!("---\n{}\n---\n{}", yaml, body)
}
pub fn get_str<'a>(val: &'a Value, key: &str) -> Option<&'a str> {
val.as_mapping()
.and_then(|m| m.get(Value::String(key.to_string())))
.and_then(|v| v.as_str())
}
pub fn get_str_or<'a>(val: &'a Value, key: &str, default: &'a str) -> &'a str {
get_str(val, key).unwrap_or(default)
}
pub fn get_string_list(val: &Value, key: &str) -> Vec<String> {
val.as_mapping()
.and_then(|m| m.get(Value::String(key.to_string())))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
pub fn strip_wikilink(s: &str) -> &str {
if let Some(inner) = s.strip_prefix("[[").and_then(|s| s.strip_suffix("]]")) {
inner.split('|').next().unwrap_or(inner)
} else {
s
}
}
pub fn wrap_wikilink(s: &str) -> String {
if s.is_empty() {
String::new()
} else {
format!("[[{}]]", s)
}
}
pub fn get_link_str<'a>(val: &'a Value, key: &str) -> Option<&'a str> {
get_str(val, key).map(strip_wikilink)
}
pub fn get_link_list(val: &Value, key: &str) -> Vec<String> {
get_string_list(val, key)
.into_iter()
.map(|s| strip_wikilink(&s).to_string())
.collect()
}
pub fn set_str(val: &mut Value, key: &str, value: &str) {
if let Some(map) = val.as_mapping_mut() {
map.insert(
Value::String(key.to_string()),
Value::String(value.to_string()),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_frontmatter_basic() {
let content = "---\nid: CUST-001\nname: Acme\n---\n# Acme\n\nBody text.";
let (fm, body) = split_frontmatter(content).unwrap();
assert!(fm.contains("id: CUST-001"));
assert!(fm.contains("name: Acme"));
assert!(body.contains("Body text."));
}
#[test]
fn test_split_frontmatter_no_frontmatter() {
let content = "# Just a heading\n\nSome body.";
assert!(split_frontmatter(content).is_none());
}
#[test]
fn test_parse_raw_and_accessors() {
let fm_str = "id: TASK-001\ntitle: Fix bug\nstatus: todo\ntags:\n - urgent\n - backend";
let fm = parse_raw(fm_str, std::path::Path::new("test.md")).unwrap();
assert_eq!(get_str(&fm, "id").unwrap(), "TASK-001");
assert_eq!(get_str(&fm, "title").unwrap(), "Fix bug");
assert_eq!(get_str(&fm, "status").unwrap(), "todo");
assert_eq!(get_str(&fm, "nonexistent"), None);
let tags = get_string_list(&fm, "tags");
assert_eq!(tags, vec!["urgent", "backend"]);
}
#[test]
fn test_set_str_modifies_value() {
let fm_str = "id: TASK-001\nstatus: todo";
let mut fm = parse_raw(fm_str, std::path::Path::new("test.md")).unwrap();
set_str(&mut fm, "status", "done");
assert_eq!(get_str(&fm, "status").unwrap(), "done");
set_str(&mut fm, "owner", "alice");
assert_eq!(get_str(&fm, "owner").unwrap(), "alice");
}
#[test]
fn test_frontmatter_round_trip() {
let fm_str =
"id: RES-001\ntitle: LLM Benchmarks\nstatus: draft\ntags:\n - ai\n - research";
let fm = parse_raw(fm_str, std::path::Path::new("test.md")).unwrap();
let body = "\n# LLM Benchmarks\n\nResearch body.\n";
let doc = serialize_document(&fm, body);
let (fm_str2, body2) = split_frontmatter(&doc).unwrap();
let fm2 = parse_raw(&fm_str2, std::path::Path::new("test.md")).unwrap();
assert_eq!(get_str(&fm2, "id").unwrap(), "RES-001");
assert_eq!(get_str(&fm2, "title").unwrap(), "LLM Benchmarks");
assert_eq!(get_str(&fm2, "status").unwrap(), "draft");
assert_eq!(get_string_list(&fm2, "tags"), vec!["ai", "research"]);
assert!(body2.contains("Research body."));
}
#[test]
fn test_strip_wikilink() {
assert_eq!(strip_wikilink("[[CUST-001]]"), "CUST-001");
assert_eq!(strip_wikilink("[[target|alias]]"), "target");
assert_eq!(strip_wikilink("CUST-001"), "CUST-001");
assert_eq!(strip_wikilink(""), "");
assert_eq!(strip_wikilink("[[]]"), "");
assert_eq!(
strip_wikilink("[[nested[[brackets]]]]"),
"nested[[brackets]]"
);
}
#[test]
fn test_wrap_wikilink() {
assert_eq!(wrap_wikilink("CUST-001"), "[[CUST-001]]");
assert_eq!(wrap_wikilink(""), "");
}
#[test]
fn test_get_link_str() {
let fm_str = "sprint: '[[SPR-001]]'\ncustomer: CUST-001";
let fm = parse_raw(fm_str, std::path::Path::new("test.md")).unwrap();
assert_eq!(get_link_str(&fm, "sprint"), Some("SPR-001"));
assert_eq!(get_link_str(&fm, "customer"), Some("CUST-001"));
assert_eq!(get_link_str(&fm, "missing"), None);
}
#[test]
fn test_get_link_list() {
let fm_str = "projects:\n - '[[PROJ-001]]'\n - PROJ-002";
let fm = parse_raw(fm_str, std::path::Path::new("test.md")).unwrap();
assert_eq!(get_link_list(&fm, "projects"), vec!["PROJ-001", "PROJ-002"]);
}
#[test]
fn test_serialize_document_format() {
let fm_str = "id: TEST-001\nname: Test";
let fm = parse_raw(fm_str, std::path::Path::new("test.md")).unwrap();
let body = "\n# Test\n";
let doc = serialize_document(&fm, body);
assert!(doc.starts_with("---\n"));
assert!(doc.contains("\n---\n"));
assert!(doc.contains("# Test"));
}
#[test]
fn test_serialize_double_quotes_wikilinks() {
let fm_str = "id: PROJ-001\ncustomer: '[[CUST-001]]'";
let fm = parse_raw(fm_str, std::path::Path::new("test.md")).unwrap();
let doc = serialize_document(&fm, "\n");
assert!(
doc.contains("\"[[CUST-001]]\""),
"expected double-quoted wiki-link, got:\n{doc}"
);
assert!(
!doc.contains("'[[CUST-001]]'"),
"single-quoted wiki-link should not appear"
);
}
#[test]
fn test_serialize_mc_links_comment() {
let fm_str = "id: PROJ-001\ncustomer: '[[CUST-001]]'";
let fm = parse_raw(fm_str, std::path::Path::new("test.md")).unwrap();
let doc = serialize_document(&fm, "\n# Project\n");
assert!(
doc.contains("%% mc-links: [[CUST-001]] %%"),
"expected mc-links comment, got:\n{doc}"
);
}
#[test]
fn test_serialize_mc_links_multiple() {
let fm_str = "id: MTG-001\ncustomers:\n - '[[CUST-001]]'\nprojects:\n - '[[PROJ-001]]'";
let fm = parse_raw(fm_str, std::path::Path::new("test.md")).unwrap();
let doc = serialize_document(&fm, "\n");
assert!(
doc.contains("[[CUST-001]]") && doc.contains("[[PROJ-001]]"),
"expected both links in mc-links comment, got:\n{doc}"
);
let mc_line = doc.lines().find(|l| l.contains("%% mc-links:")).unwrap();
assert!(mc_line.contains("[[CUST-001]]"));
assert!(mc_line.contains("[[PROJ-001]]"));
}
#[test]
fn test_serialize_mc_links_idempotent() {
let fm_str = "id: PROJ-001\ncustomer: '[[CUST-001]]'";
let fm = parse_raw(fm_str, std::path::Path::new("test.md")).unwrap();
let doc1 = serialize_document(&fm, "\n# Project\n");
let (_, body1) = split_frontmatter(&doc1).unwrap();
let doc2 = serialize_document(&fm, &body1);
let count = doc2.matches("%% mc-links:").count();
assert_eq!(count, 1, "mc-links duplicated after re-serialise:\n{doc2}");
}
#[test]
fn test_serialize_no_links_no_comment() {
let fm_str = "id: RES-001\ntitle: Plain research";
let fm = parse_raw(fm_str, std::path::Path::new("test.md")).unwrap();
let doc = serialize_document(&fm, "\n# Research\n");
assert!(
!doc.contains("%% mc-links:"),
"no mc-links comment expected when no wiki-links:\n{doc}"
);
}
}