use std::collections::BTreeMap;
#[derive(Debug)]
pub enum MetaValue {
Scalar(String),
List(Vec<String>),
}
impl MetaValue {
pub fn as_list(&self) -> Option<&[String]> {
match self {
MetaValue::List(items) => Some(items),
_ => None,
}
}
pub fn as_scalar(&self) -> Option<&str> {
match self {
MetaValue::Scalar(s) => Some(s),
_ => None,
}
}
}
#[derive(Debug)]
pub struct FmError {
pub line: Option<usize>,
pub msg: String,
}
impl std::fmt::Display for FmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.line {
Some(n) => write!(f, "line {n}: {}", self.msg),
None => f.write_str(&self.msg),
}
}
}
pub type FmResult<T> = Result<T, FmError>;
fn is_key(key: &str) -> bool {
!key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& !key.chars().next().unwrap().is_ascii_digit()
}
fn clean_scalar(value: &str, line: usize) -> FmResult<String> {
let t = value.trim();
let bytes = t.as_bytes();
if t.len() >= 2
&& ((bytes[0] == b'"' && t.ends_with('"')) || (bytes[0] == b'\'' && t.ends_with('\'')))
{
return Ok(t[1..t.len() - 1].to_string());
}
if let Some(idx) = t.find(" #") {
return Ok(t[..idx].trim().to_string());
}
if t.starts_with('#') {
return Err(FmError {
line: Some(line),
msg: "bare comment where a value is expected".into(),
});
}
Ok(t.to_string())
}
pub fn parse_lines(lines: &[&str]) -> FmResult<BTreeMap<String, MetaValue>> {
let mut out: BTreeMap<String, MetaValue> = BTreeMap::new();
let mut i = 0;
while i < lines.len() {
let line_no = i + 1;
let raw = lines[i];
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
i += 1;
continue;
}
if raw.starts_with(' ') || raw.starts_with('\t') {
return Err(FmError {
line: Some(line_no),
msg: format!("unexpected indented line not inside a list: {trimmed:?}"),
});
}
let Some((key, rest)) = trimmed.split_once(':') else {
return Err(FmError {
line: Some(line_no),
msg: format!("expected `key: value`, got: {trimmed:?}"),
});
};
let key = key.trim();
if !is_key(key) {
return Err(FmError {
line: Some(line_no),
msg: format!("invalid key: {key:?}"),
});
}
if out.contains_key(key) {
return Err(FmError {
line: Some(line_no),
msg: format!("duplicate key: {key:?}"),
});
}
let rest = rest.trim();
if rest.is_empty() {
let mut items: Vec<String> = Vec::new();
let mut j = i + 1;
while j < lines.len() {
let l2 = lines[j];
let t2 = l2.trim();
if t2.is_empty() || t2.starts_with('#') {
j += 1;
continue;
}
if !(l2.starts_with(' ') || l2.starts_with('\t')) {
break; }
let Some(after) = t2.strip_prefix('-') else {
break;
};
if !after.is_empty() && !after.starts_with(' ') {
break; }
let item = after.trim();
if item.is_empty() {
return Err(FmError {
line: Some(j + 1),
msg: "empty list item".into(),
});
}
items.push(clean_scalar(item, j + 1)?);
j += 1;
}
out.insert(key.to_string(), MetaValue::List(items));
i = j;
} else if rest == "[]" {
out.insert(key.to_string(), MetaValue::List(Vec::new()));
i += 1;
} else if rest.starts_with('[') {
let Some(close) = rest.rfind(']') else {
return Err(FmError {
line: Some(line_no),
msg: format!("unclosed inline list: {rest:?}"),
});
};
let inner = &rest[1..close];
let items: Vec<String> = if inner.trim().is_empty() {
Vec::new()
} else {
inner
.split(',')
.map(|s| {
let s = s.trim();
if s.is_empty() {
Err(FmError {
line: Some(line_no),
msg: "empty item in inline list".into(),
})
} else {
clean_scalar(s, line_no)
}
})
.collect::<FmResult<Vec<String>>>()?
};
out.insert(key.to_string(), MetaValue::List(items));
i += 1;
} else {
let scalar = clean_scalar(rest, line_no)?;
out.insert(key.to_string(), MetaValue::Scalar(scalar));
i += 1;
}
}
Ok(out)
}
pub fn split_frontmatter(text: &str) -> FmResult<(BTreeMap<String, MetaValue>, String)> {
let all: Vec<&str> = text.lines().collect();
let Some(first) = all.first() else {
return Err(FmError {
line: Some(1),
msg: "empty file: missing frontmatter".into(),
});
};
if first.trim_end() != "---" {
return Err(FmError {
line: Some(1),
msg: "frontmatter must start on line 1 with `---`".into(),
});
}
let close = all[1..]
.iter()
.position(|l| l.trim_end() == "---")
.map(|idx| idx + 1);
let Some(idx) = close else {
return Err(FmError {
line: None,
msg: "unterminated frontmatter: no closing `---`".into(),
});
};
let meta = parse_lines(&all[1..idx])?;
let body = all[idx + 1..].join("\n");
Ok((meta, body))
}
pub fn yaml_fences(text: &str) -> Vec<(usize, Vec<String>)> {
let lines: Vec<&str> = text.lines().collect();
let mut out = Vec::new();
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim_start();
if let Some(info) = trimmed.strip_prefix("```") {
let info = info.trim();
if info.eq_ignore_ascii_case("yaml") || info.eq_ignore_ascii_case("yml") {
let mut content: Vec<String> = Vec::new();
let mut j = i + 1;
while j < lines.len() && !lines[j].trim_start().starts_with("```") {
content.push(lines[j].to_string());
j += 1;
}
out.push((i + 1, content));
i = j;
}
}
i += 1;
}
out
}
pub fn plan_writes(text: &str) -> FmResult<Option<(usize, Vec<String>)>> {
for (line_no, content) in yaml_fences(text) {
let meta = parse_lines(&content.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
if let Some(items) = meta.get("writes").and_then(|v| v.as_list()) {
if !items.is_empty() {
return Ok(Some((line_no, items.to_vec())));
}
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_scalars_lists_and_quotes() {
let lines = vec![
"doc_tier: \"1\"",
"module: kova-core",
"structs:",
" - KovaEngine",
" - \"RunningGuard\"",
"reads: []",
"writes: [PipelineState, state:pipeline-state]",
];
let meta = parse_lines(&lines).unwrap();
assert_eq!(
meta.get("module").and_then(|v| v.as_scalar()),
Some("kova-core")
);
assert_eq!(
meta.get("structs").and_then(|v| v.as_list()),
Some(&["KovaEngine".to_string(), "RunningGuard".to_string()][..])
);
assert_eq!(meta.get("reads").and_then(|v| v.as_list()), Some(&[][..]));
assert_eq!(
meta.get("writes").and_then(|v| v.as_list()),
Some(
&[
"PipelineState".to_string(),
"state:pipeline-state".to_string()
][..]
)
);
}
#[test]
fn rejects_duplicate_keys_garbage_and_bad_indent() {
assert!(parse_lines(&["a: 1", "a: 2"]).is_err());
assert!(parse_lines(&["no-colon-here"]).is_err());
assert!(parse_lines(&[" indented: x"]).is_err());
assert!(parse_lines(&["a: [1, 2"]).is_err());
assert!(parse_lines(&["a:", " -"]).is_err());
}
#[test]
fn splits_frontmatter_and_body() {
let text =
"---\nmodule: kova-core\nwrites: []\n---\n# Body\n\n[link](../tier2/engine_ops.md)\n";
let (meta, body) = split_frontmatter(text).unwrap();
assert_eq!(
meta.get("module").and_then(|v| v.as_scalar()),
Some("kova-core")
);
assert!(body.contains("# Body"));
assert!(body.contains("../tier2/engine_ops.md"));
}
#[test]
fn rejects_missing_and_unterminated_frontmatter() {
assert!(split_frontmatter("# no frontmatter").is_err());
assert!(split_frontmatter("---\nmodule: kova-core\n").is_err());
}
#[test]
fn strips_comments() {
let lines = vec!["# full line", "a: 1 # trailing", "b: [x, y]"];
let meta = parse_lines(&lines).unwrap();
assert_eq!(meta.get("a").and_then(|v| v.as_scalar()), Some("1"));
}
#[test]
fn finds_yaml_fences_and_writes() {
let plan = "intro text\n\n```yaml\nmodule: mcp\nwrites:\n - research/plans/plan.md\n - state:event-log\n```\n\nmore text\n\n```text\nnot a yaml fence\nwrites:\n - evil\n```\n";
let found = plan_writes(plan).unwrap().unwrap();
assert_eq!(found.0, 3);
assert_eq!(
found.1,
vec![
"research/plans/plan.md".to_string(),
"state:event-log".to_string()
]
);
}
#[test]
fn plan_without_writes_fence_is_none() {
assert!(plan_writes("no fences here").unwrap().is_none());
let plan = "```yaml\nmodule: mcp\nwrites: []\n```\n";
assert!(plan_writes(plan).unwrap().is_none());
}
}