use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.map_or_else(|| PathBuf::from("../.."), Path::to_path_buf)
}
fn declared_variants(source: &str) -> Result<Vec<(String, BTreeSet<String>)>, String> {
let start = source
.find("pub enum Event {")
.ok_or("event.rs no longer declares `pub enum Event {`")?;
let body = &source[start..];
let end = body
.find("\n}\n")
.ok_or("the Event enum body has no closing brace")?;
let mut variants = Vec::new();
let mut current: Option<(String, BTreeSet<String>)> = None;
for line in body[..end].lines().skip(1) {
if let Some(rest) = line.strip_prefix(" ")
&& !rest.starts_with(' ')
&& let Some(name) = rest.strip_suffix(" {")
&& name.chars().next().is_some_and(char::is_uppercase)
{
current = Some((name.to_owned(), BTreeSet::new()));
continue;
}
if line == " }," || line == " }" {
if let Some(done) = current.take() {
variants.push(done);
}
continue;
}
if let Some((_, fields)) = current.as_mut()
&& let Some(rest) = line.strip_prefix(" ")
&& !rest.starts_with('/')
&& !rest.starts_with('#')
&& let Some((field, _)) = rest.split_once(':')
&& field.chars().all(|c| c.is_ascii_lowercase() || c == '_')
{
fields.insert(field.to_owned());
}
}
if variants.is_empty() {
return Err(String::from("no variants parsed from the Event enum"));
}
Ok(variants)
}
fn documented_sections(reference: &str) -> Vec<(String, String)> {
let mut sections = Vec::new();
let mut current: Option<(String, String)> = None;
for line in reference.lines() {
if let Some(heading) = line.strip_prefix("### ") {
if let Some(done) = current.take() {
sections.push(done);
}
current = Some((heading.trim().to_owned(), String::new()));
} else if let Some((_, body)) = current.as_mut() {
body.push_str(line);
body.push('\n');
}
}
if let Some(done) = current {
sections.push(done);
}
sections
}
#[test]
fn the_event_reference_names_every_variant_and_every_field() -> Result<(), String> {
let root = repo_root();
let source = fs::read_to_string(root.join("crates/aion-core/src/event.rs"))
.map_err(|error| format!("event.rs unreadable: {error}"))?;
let reference = fs::read_to_string(root.join("docs/reference/events.md")).map_err(|error| {
format!(
"docs/reference/events.md unreadable ({error}); run scripts/render-events-reference.py"
)
})?;
let declared = declared_variants(&source)?;
let documented = documented_sections(&reference);
let declared_names: BTreeSet<&str> = declared.iter().map(|(name, _)| name.as_str()).collect();
let documented_names: BTreeSet<&str> =
documented.iter().map(|(name, _)| name.as_str()).collect();
let mut drift = Vec::new();
for missing in declared_names.difference(&documented_names) {
drift.push(format!(
"variant `{missing}` is declared but has no section"
));
}
for stale in documented_names.difference(&declared_names) {
drift.push(format!("section `{stale}` names no declared variant"));
}
for (name, fields) in &declared {
if let Some((_, body)) = documented.iter().find(|(heading, _)| heading == name) {
for field in fields {
if !body.contains(&format!("| `{field}` |")) {
drift.push(format!(
"variant `{name}` field `{field}` is not in its table"
));
}
}
}
}
let declared_order: Vec<&str> = declared.iter().map(|(name, _)| name.as_str()).collect();
let documented_order: Vec<&str> = documented.iter().map(|(name, _)| name.as_str()).collect();
if drift.is_empty() && declared_order != documented_order {
drift.push(String::from("sections are not in declaration order"));
}
if drift.is_empty() {
Ok(())
} else {
Err(format!(
"docs/reference/events.md has drifted from aion_core::Event; run \
`python3 scripts/render-events-reference.py`:\n {}",
drift.join("\n ")
))
}
}