use crate::invariant::rules::util::walk_repo;
use crate::invariant::{Category, Context, Invariant, Outcome};
use std::fs;
pub struct FeatureFrontmatterValid;
impl Invariant for FeatureFrontmatterValid {
fn id(&self) -> &'static str {
"docs.feature-frontmatter-valid"
}
fn category(&self) -> Category {
Category::Docs
}
fn intent(&self) -> &'static str {
"Every `wiki/features/*.md` carries `id`, `status`, and `owner` \
in its frontmatter."
}
fn adr(&self) -> Option<&'static str> {
Some("ADR-0007")
}
fn evaluate(&self, ctx: &Context) -> Outcome {
let dir = ctx.root().join("wiki/features");
if !dir.is_dir() {
return Outcome::skip("no wiki/features/ dir");
}
let mut missing: Vec<String> = Vec::new();
for entry in walk_repo(&dir) {
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if name.starts_with('_') || !name.ends_with(".md") {
continue;
}
let Ok(text) = fs::read_to_string(path) else {
continue;
};
let needed = ["id", "status", "owner"];
let missing_keys: Vec<&str> = needed
.iter()
.filter(|k| !has_frontmatter_key(&text, k))
.copied()
.collect();
if !missing_keys.is_empty() {
let rel = path
.strip_prefix(ctx.root())
.unwrap_or(path)
.display()
.to_string();
missing.push(format!("{rel} → missing: {}", missing_keys.join(", ")));
}
}
if missing.is_empty() {
Outcome::pass()
} else {
Outcome::fail(format!(
"{} feature(s) with incomplete frontmatter:\n {}",
missing.len(),
missing.join("\n "),
))
}
}
}
fn has_frontmatter_key(text: &str, key: &str) -> bool {
let Some(rest) = text.strip_prefix("---\n") else {
return false;
};
let Some(end) = rest.find("\n---") else {
return false;
};
let front = &rest[..end];
front
.lines()
.any(|l| l.trim_start().starts_with(&format!("{key}:")))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write(tmp: &TempDir, name: &str, body: &str) {
let dir = tmp.path().join("wiki/features");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(name), body).unwrap();
}
#[test]
fn complete_frontmatter_passes() {
let tmp = TempDir::new().unwrap();
write(
&tmp,
"x.md",
"---\nid: x\nstatus: implemented\nowner: crates/x/\n---\n# x\n",
);
let ctx = Context::new(tmp.path().to_path_buf());
assert!(matches!(
FeatureFrontmatterValid.evaluate(&ctx),
Outcome::Pass { .. }
));
}
#[test]
fn missing_owner_fails() {
let tmp = TempDir::new().unwrap();
write(&tmp, "x.md", "---\nid: x\nstatus: implemented\n---\n# x\n");
let ctx = Context::new(tmp.path().to_path_buf());
assert!(matches!(
FeatureFrontmatterValid.evaluate(&ctx),
Outcome::Fail { .. }
));
}
}