koala-core 1.0.4

Shared types, invariant evaluator, and primitives for the koala framework.
Documentation
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 {
    // Treat `_` as a separator (consistent with snake_case identifiers
    // — `run_verifier` should trip the alias `verifier`).
    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();
        // `verify` is the alias; `verifying` (substring match) must NOT trip.
        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 { .. }));
    }
}