use crate::invariant::{Category, Context, Invariant, Outcome};
use std::fs;
const GLOSSARY_PATH: &str = ".koala/glossary.toml";
pub struct NoAliases;
impl Invariant for NoAliases {
fn id(&self) -> &'static str {
"arch.no-aliases"
}
fn category(&self) -> Category {
Category::Arch
}
fn intent(&self) -> &'static str {
"A concept has one canonical name. `.koala/glossary.toml` lists \
alias → canonical pairs; any source/wiki file mentioning an alias \
outside the glossary itself is rejected."
}
fn adr(&self) -> Option<&'static str> {
Some("ADR-0001")
}
fn evaluate(&self, ctx: &Context) -> Outcome {
let glossary_path = ctx.root().join(GLOSSARY_PATH);
let Ok(text) = fs::read_to_string(&glossary_path) else {
return Outcome::skip(format!(
"{GLOSSARY_PATH} not found; rule is opt-in (create the file with \
`<alias> = \"<canonical>\"` pairs to enable)"
));
};
let parsed: toml::Value = match toml::from_str(&text) {
Ok(v) => v,
Err(e) => return Outcome::fail(format!("failed to parse glossary.toml: {e}")),
};
let table = match parsed.as_table() {
Some(t) => t,
None => {
return Outcome::fail(
"glossary.toml must be a top-level table of \
<alias> = \"<canonical>\" pairs"
.to_string(),
)
}
};
if table.is_empty() {
return Outcome::skip("glossary.toml present but empty");
}
let mut hits: Vec<String> = Vec::new();
for path in walk_text_files(ctx.root()) {
let rel_str = path
.strip_prefix(ctx.root())
.unwrap_or(&path)
.display()
.to_string();
if rel_str.replace('\\', "/") == GLOSSARY_PATH {
continue;
}
let Ok(content) = fs::read_to_string(&path) else {
continue;
};
for (alias, _canonical) in table {
if alias.is_empty() {
continue;
}
if word_present(&content, alias) {
hits.push(format!(
"{rel_str}: alias `{alias}` (use canonical name from {GLOSSARY_PATH})"
));
}
}
}
if hits.is_empty() {
Outcome::pass()
} else {
hits.sort();
hits.dedup();
let detail = format!(
"{} alias use(s) found:\n {}",
hits.len(),
hits.join("\n ")
);
Outcome::fail_repro(detail, "rg -nw '<alias>' --type rust --type md")
}
}
}
fn walk_text_files(root: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in walkdir::WalkDir::new(root).into_iter().flatten() {
if !entry.file_type().is_file() {
continue;
}
let p = entry.path();
let depth_skip = p.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some(".git" | "target" | "node_modules" | ".worktrees" | "koala")
)
});
if depth_skip {
continue;
}
let ok_ext = p
.extension()
.and_then(|s| s.to_str())
.map(|e| matches!(e, "rs" | "md" | "toml"))
.unwrap_or(false);
if ok_ext {
out.push(p.to_path_buf());
}
}
out
}
fn word_present(text: &str, word: &str) -> bool {
let bytes = text.as_bytes();
let needle = word.as_bytes();
if needle.is_empty() {
return false;
}
for i in 0..=bytes.len().saturating_sub(needle.len()) {
if bytes[i..i + needle.len()] != needle[..] {
continue;
}
let before_ok = i == 0 || !is_word_byte(bytes[i - 1]);
let after_idx = i + needle.len();
let after_ok = after_idx >= bytes.len() || !is_word_byte(bytes[after_idx]);
if before_ok && after_ok {
return true;
}
}
false
}
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_glossary(root: &std::path::Path, content: &str) {
let dir = root.join(".koala");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("glossary.toml"), content).unwrap();
}
#[test]
fn skip_when_glossary_missing() {
let tmp = TempDir::new().unwrap();
let ctx = Context::new(tmp.path().to_path_buf());
let out = NoAliases.evaluate(&ctx);
assert!(matches!(out, Outcome::Skip { .. }), "{out:?}");
}
#[test]
fn passes_when_no_alias_appears() {
let tmp = TempDir::new().unwrap();
write_glossary(tmp.path(), "verifier = \"validator\"\n");
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/lib.rs"), "pub fn validator() {}\n").unwrap();
let ctx = Context::new(tmp.path().to_path_buf());
assert!(matches!(NoAliases.evaluate(&ctx), Outcome::Pass { .. }));
}
#[test]
fn naming_alias_detected() {
let tmp = TempDir::new().unwrap();
write_glossary(tmp.path(), "verifier = \"validator\"\n");
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(
tmp.path().join("src/lib.rs"),
"pub fn run_verifier() { println!(\"hi\"); }\n",
)
.unwrap();
let ctx = Context::new(tmp.path().to_path_buf());
let out = NoAliases.evaluate(&ctx);
assert!(matches!(out, Outcome::Fail { .. }), "{out:?}");
}
#[test]
fn substring_in_word_is_not_a_match() {
let tmp = TempDir::new().unwrap();
write_glossary(tmp.path(), "verify = \"validate\"\n");
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/lib.rs"), "pub fn validate_user() {}\n").unwrap();
let ctx = Context::new(tmp.path().to_path_buf());
assert!(matches!(NoAliases.evaluate(&ctx), Outcome::Pass { .. }));
}
}