use crate::invariant::rules::util::rel;
use crate::invariant::{Category, Context, Invariant, Outcome};
use crate::wiki::{extract_frontmatter, parse_yaml_frontmatter};
use std::fs;
const ALLOWED_STATUS: &[&str] = &["accepted", "superseded", "deprecated", "proposed"];
const REQUIRED_KEYS: &[&str] = &["id", "title", "status", "date"];
pub struct AdrFrontmatterValid;
impl Invariant for AdrFrontmatterValid {
fn id(&self) -> &'static str {
"governance.adr-frontmatter-valid"
}
fn category(&self) -> Category {
Category::Governance
}
fn intent(&self) -> &'static str {
"Every ADR file has YAML frontmatter with id / title / status / date and a known status value."
}
fn adr(&self) -> Option<&'static str> {
Some("ADR-0013")
}
fn evaluate(&self, ctx: &Context) -> Outcome {
let dir = ctx.root().join("wiki/decisions");
if !dir.is_dir() {
return Outcome::skip("wiki/decisions/ not found");
}
let entries = match fs::read_dir(&dir) {
Ok(it) => it,
Err(e) => return Outcome::skip(format!("read_dir failed: {e}")),
};
let mut errors = Vec::new();
let mut checked = 0usize;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('_') {
continue;
}
checked += 1;
let display = rel(&path, ctx.root());
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
errors.push(format!("{display}: read failed: {e}"));
continue;
}
};
let Some(fm_text) = extract_frontmatter(&content) else {
errors.push(format!(
"{display}: missing or unterminated `---` frontmatter block"
));
continue;
};
let fields = parse_yaml_frontmatter(fm_text);
for key in REQUIRED_KEYS {
if !fields.contains_key(*key) {
errors.push(format!("{display}: missing required key `{key}`"));
}
}
if let Some(status) = fields.get("status") {
if !ALLOWED_STATUS.contains(&status.as_str()) {
errors.push(format!(
"{display}: status `{status}` not in {ALLOWED_STATUS:?}"
));
}
}
if let Some(title) = fields.get("title") {
if title.is_empty() {
errors.push(format!("{display}: `title` is empty"));
}
}
}
if errors.is_empty() {
Outcome::pass_with(format!("{checked} ADR file(s) valid"))
} else {
Outcome::fail_repro(
format!("{} ADR(s) failed:\n {}", errors.len(), errors.join("\n ")),
"head -10 wiki/decisions/*.md",
)
}
}
}