use crate::manifest::{Claim, ClaimId, is_canonical_id};
use std::collections::BTreeSet;
pub(crate) struct ParsedClaims {
pub claims: Vec<Claim>,
pub duplicate_ids: Vec<String>,
}
pub(crate) fn parse_claims(md: &str) -> ParsedClaims {
let lines: Vec<&str> = md.lines().collect();
let mut claims = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut duplicate_ids = Vec::new();
let mut i = 0;
while i < lines.len() {
let Some((id, title)) = parse_header(lines[i]) else {
i += 1;
continue;
};
let mut j = i + 1;
while j < lines.len() && !is_level2_header(lines[j]) {
j += 1;
}
let body = &lines[i + 1..j];
let mut statement = None;
let mut status = None;
let mut proof = Vec::new();
let mut deps = Vec::new();
for line in body {
let Some((key, value)) = parse_bullet(line) else {
continue;
};
match key.to_ascii_lowercase().as_str() {
"statement" => statement = non_empty(&value),
"status" => status = non_empty(&value),
"proof" => proof = extract_ids(&value, 'E'),
"dependencies" => {
deps = extract_ids(&value, 'C')
.into_iter()
.map(ClaimId::new)
.collect()
}
_ => {}
}
}
if seen.contains(&id) {
duplicate_ids.push(id);
} else {
seen.insert(id.clone());
claims.push(Claim {
id: ClaimId::new(id),
title,
statement,
status,
proof,
deps,
});
}
i = j; }
ParsedClaims {
claims,
duplicate_ids,
}
}
fn parse_header(line: &str) -> Option<(String, String)> {
let rest = line.trim_start().strip_prefix("## ")?;
let (raw_id, raw_title) = rest.split_once(':')?;
let id = raw_id.trim();
if !is_canonical_id(id, 'C') {
return None;
}
let title = raw_title.trim();
if title.is_empty() {
return None;
}
Some((id.to_string(), title.to_string()))
}
fn is_level2_header(line: &str) -> bool {
line.trim_start().starts_with("## ")
}
fn parse_bullet(line: &str) -> Option<(String, String)> {
let t = line.trim_start();
let rest = t.strip_prefix("- ").or_else(|| t.strip_prefix("* "))?;
let rest = rest.trim_start().strip_prefix("**")?;
let (key, rest_after) = rest.split_once("**")?;
let after = rest_after.trim_start();
let value = after.strip_prefix(':').unwrap_or(after).trim();
Some((key.trim().to_string(), value.to_string()))
}
fn extract_ids(value: &str, prefix: char) -> Vec<String> {
value
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|tok| is_canonical_id(tok, prefix))
.map(|s| s.to_string())
.collect()
}
fn non_empty(s: &str) -> Option<String> {
let t = s.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_canonical_claims() {
let md = "\
# Claims
## C01: Attention-only architecture achieves SOTA
- **Statement**: A model based entirely on self-attention achieves SOTA.
- **Status**: supported
- **Proof**: [E01]
- **Dependencies**: []
- **Tags**: architecture, translation
## C02: Transformers train faster
- **Statement**: The Transformer requires less training time.
- **Status**: supported
- **Proof**: [E02]
- **Dependencies**: [C01]
";
let out = parse_claims(md);
assert!(out.duplicate_ids.is_empty());
assert_eq!(out.claims.len(), 2);
let c1 = &out.claims[0];
assert_eq!(c1.id, ClaimId::new("C01"));
assert_eq!(c1.title, "Attention-only architecture achieves SOTA");
assert!(c1.statement.as_deref().unwrap().starts_with("A model"));
assert_eq!(c1.status.as_deref(), Some("supported"));
assert_eq!(c1.proof, vec!["E01"]);
assert!(c1.deps.is_empty());
let c2 = &out.claims[1];
assert_eq!(c2.deps, vec![ClaimId::new("C01")]);
}
#[test]
fn tolerates_dependency_drift() {
for (dep_line, expected) in [
("- **Dependencies**: none", Vec::<&str>::new()),
("- **Dependencies**: []", vec![]),
("- **Dependencies**: C01", vec!["C01"]),
("- **Dependencies**: C02, C04", vec!["C02", "C04"]),
("- **Dependencies**: [C01, C03]", vec!["C01", "C03"]),
] {
let md = format!("## C09: Drift\n- **Statement**: x\n{dep_line}\n");
let out = parse_claims(&md);
let deps: Vec<String> = out.claims[0].deps.iter().map(|d| d.to_string()).collect();
assert_eq!(deps, expected, "for line: {dep_line}");
}
}
#[test]
fn tolerates_missing_bullets() {
let out = parse_claims("## C01: Bare claim, no bullets at all\n");
assert_eq!(out.claims.len(), 1);
let c = &out.claims[0];
assert_eq!(c.title, "Bare claim, no bullets at all");
assert!(c.statement.is_none());
assert!(c.status.is_none());
assert!(c.proof.is_empty());
assert!(c.deps.is_empty());
}
#[test]
fn detects_duplicate_claim_id() {
let md = "## C01: First\n- **Statement**: a\n## C01: Second\n- **Statement**: b\n";
let out = parse_claims(md);
assert_eq!(out.claims.len(), 1);
assert_eq!(out.claims[0].statement.as_deref(), Some("a")); assert_eq!(out.duplicate_ids, vec!["C01"]);
}
#[test]
fn ignores_non_claim_headers_and_deeper_levels() {
let md = "\
# Claims
## Overview: not a claim
### C01: too deep, not a claim
## C01: real claim
- **Statement**: ok
";
let out = parse_claims(md);
assert_eq!(out.claims.len(), 1);
assert_eq!(out.claims[0].statement.as_deref(), Some("ok"));
}
#[test]
fn proof_extracts_multiple_evidence_ids() {
let md = "## C02: multi\n- **Proof**: [E01, E02]\n";
let out = parse_claims(md);
assert_eq!(out.claims[0].proof, vec!["E01", "E02"]);
}
}