use super::map::BodyMap;
use regex::Regex;
use std::sync::LazyLock;
#[derive(Debug, Default, PartialEq)]
pub struct Found {
pub status: Option<String>,
pub date: Option<String>,
pub links: Vec<(String, String)>,
pub missed: Vec<String>,
}
static LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[[^\]]*\]\(([^)\s]+)\)").unwrap());
fn lines(body: &str) -> Vec<&str> {
let mut fenced = false;
let mut out = vec![];
for l in body.lines() {
let t = l.trim();
if t.starts_with("```") || t.starts_with("~~~") {
fenced = !fenced;
continue;
}
if !fenced {
out.push(t);
}
}
out
}
fn bullet<'a>(line: &'a str, name: &str) -> Option<&'a str> {
let rest = line.strip_prefix("* ").or_else(|| line.strip_prefix("- "))?;
labelled(rest, name)
}
fn labelled<'a>(line: &'a str, name: &str) -> Option<&'a str> {
let (label, value) = line.split_once(':')?;
label.trim().eq_ignore_ascii_case(name).then(|| value.trim())
}
fn section<'a>(ls: &[&'a str], name: &str) -> Option<&'a str> {
let at =
ls.iter().position(|l| l.starts_with('#') && l.trim_start_matches('#').trim().eq_ignore_ascii_case(name))?;
ls[at + 1..].iter().take_while(|l| !l.starts_with('#')).find(|l| !l.is_empty()).copied()
}
fn clean(v: &str) -> String {
let v = v.split("<!--").next().unwrap_or(v);
let v = LINK.replace_all(v, "");
v.trim().trim_end_matches(['.', ',']).trim().to_string()
}
fn read(ls: &[&str], how: &str) -> Option<String> {
let (kind, name) = how.split_once(':')?;
let raw = match kind {
"section" => section(ls, name),
"bullet" => ls.iter().find_map(|l| bullet(l, name)),
"line" => ls.iter().find_map(|l| labelled(l, name)),
_ => None,
}?;
let v = clean(raw);
(!v.is_empty()).then_some(v)
}
pub fn valid(how: &str) -> bool {
how.split_once(':').is_some_and(|(k, n)| ["section", "bullet", "line"].contains(&k) && !n.trim().is_empty())
}
pub fn scan(body: &str, map: &BodyMap) -> Found {
let ls = lines(body);
let mut f = Found::default();
if let Some(how) = &map.status {
f.status = read(&ls, how);
if f.status.is_none() {
f.missed.push(format!("no status in the body ({how})"));
}
}
if let Some(how) = &map.date {
f.date = read(&ls, how);
if f.date.is_none() {
f.missed.push(format!("no date in the body ({how})"));
}
}
for l in &ls {
let l = l.strip_prefix("* ").or_else(|| l.strip_prefix("- ")).unwrap_or(l);
let l =
l.split_once(':').filter(|(k, _)| k.trim().eq_ignore_ascii_case("status")).map(|x| x.1.trim()).unwrap_or(l);
let mut phrases: Vec<(&String, &String)> = map.links.iter().collect();
phrases.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
let lower = l.to_lowercase();
let Some((phrase, kind)) = phrases.into_iter().find(|(p, _)| lower.starts_with(&p.to_lowercase())) else {
continue;
};
if kind.as_str() == "-" {
continue;
}
let targets: Vec<String> = LINK.captures_iter(&l[phrase.len()..]).map(|c| c[1].to_string()).collect();
if targets.is_empty() {
f.missed.push(format!("'{phrase}' line without a link: {l}"));
}
for t in targets {
if !t.contains("://") {
f.links.push((kind.clone(), t));
}
}
}
f
}
#[cfg(test)]
mod tests {
use super::*;
fn map(toml: &str) -> BodyMap {
toml::from_str(toml).unwrap()
}
#[test]
fn adr_tools_status_section_date_line_and_links() {
let m = map(
"status = \"section:Status\"\ndate = \"line:Date\"\n[links]\nSupercedes = \"supersedes\"\n\"Superceded by\" = \"-\"\n",
);
let body = "# 2. Second Record\n\nDate: 1992-01-12\n\n## Status\n\nAccepted\n\nSupercedes [1. First Record](0001-first-record.md)\n\n## Context\n";
let f = scan(body, &m);
assert_eq!(f.status.as_deref(), Some("Accepted"));
assert_eq!(f.date.as_deref(), Some("1992-01-12"));
assert_eq!(f.links, vec![("supersedes".to_string(), "0001-first-record.md".to_string())]);
let old = "# 1. First\n\nDate: 1992-01-12\n\n## Status\n\nSuperceded by [2. Second Record](0002-second-record.md)\n\n## Context\n";
let f = scan(old, &m);
assert_eq!(f.status.as_deref(), Some("Superceded by"));
assert!(f.links.is_empty() && f.missed.is_empty(), "{f:?}");
}
#[test]
fn madr2_bullets_and_a_missing_value_is_reported() {
let m = map("status = \"bullet:Status\"\ndate = \"bullet:Date\"\n[links]\n\"superseded by\" = \"-\"\n");
let body = "# Use X\n\n* Status: superseded by [ADR-0005](0005-example.md) <!-- optional -->\n* Deciders: a, b\n\n## Context\n";
let f = scan(body, &m);
assert_eq!(f.status.as_deref(), Some("superseded by"));
assert_eq!(f.date, None);
assert_eq!(f.missed, vec!["no date in the body (bullet:Date)"]);
assert!(valid("bullet:Status") && !valid("para:Status") && !valid("line:"));
}
}