koala-core 1.0.4

Shared types, invariant evaluator, and primitives for the koala framework.
Documentation
//! `arch.dep-direction` — verifies crate dependencies follow the
//! framework's directional rules. The encoded forbidden edges are the
//! conservative bootstrap set; a project-specific list will move to
//! `.koala/architecture.toml` after v1.0.

use crate::invariant::rules::util::list_cargo_tomls;
use crate::invariant::{Category, Context, Invariant, Outcome};
use std::fs;

/// `(downstream, upstream)` — `downstream` may NOT depend on `upstream`.
const FORBIDDEN_EDGES: &[(&str, &str)] = &[
    // CLI is a shell — domain crates must not depend on it.
    ("koala-core", "koala-cli"),
    ("koala-drift", "koala-cli"),
    ("koala-artifact", "koala-cli"),
    ("koala-health", "koala-cli"),
    ("koala-adr", "koala-cli"),
    ("koala-wiki", "koala-cli"),
    ("koala-workflow", "koala-cli"),
    // koala-core is the foundation — leaf crates should not back-reference it
    // through a dependency cycle. (koala-core depending on itself is fine,
    // but every other crate already lists koala-core; the *reverse* edges
    // here are what we forbid.)
];

pub struct DepDirection;

impl Invariant for DepDirection {
    fn id(&self) -> &'static str {
        "arch.dep-direction"
    }
    fn category(&self) -> Category {
        Category::Arch
    }
    fn intent(&self) -> &'static str {
        "Crate dependency edges follow the framework's layering rules \
         (shells don't bleed into domain crates)."
    }
    fn adr(&self) -> Option<&'static str> {
        Some("ADR-0013")
    }

    fn evaluate(&self, ctx: &Context) -> Outcome {
        let mut violations: Vec<String> = Vec::new();
        for cargo in list_cargo_tomls(ctx.root()) {
            let Ok(text) = fs::read_to_string(&cargo) else {
                continue;
            };
            let Some(name) = parse_package_name(&text) else {
                continue;
            };
            let deps = parse_deps(&text);
            for (downstream, upstream) in FORBIDDEN_EDGES {
                if name == *downstream && deps.iter().any(|d| d == upstream) {
                    violations.push(format!(
                        "{name} depends on {upstream} (forbidden by arch layering)"
                    ));
                }
            }
        }
        if violations.is_empty() {
            Outcome::pass_with(format!(
                "{} forbidden edge(s) checked",
                FORBIDDEN_EDGES.len()
            ))
        } else {
            Outcome::fail_repro(
                format!(
                    "found {} forbidden edge(s):\n  {}",
                    violations.len(),
                    violations.join("\n  ")
                ),
                "rg -nP '^koala-cli\\s*=' crates/*/Cargo.toml",
            )
        }
    }
}

fn parse_package_name(text: &str) -> Option<String> {
    let mut in_pkg = false;
    for line in text.lines() {
        let l = line.trim();
        if l.starts_with('[') {
            in_pkg = l == "[package]";
            continue;
        }
        if in_pkg {
            if let Some(rest) = l.strip_prefix("name") {
                let v = rest.trim_start_matches([' ', '\t', '=']);
                let v = v.trim().trim_matches('"');
                return Some(v.to_string());
            }
        }
    }
    None
}

fn parse_deps(text: &str) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    let mut in_deps = false;
    for line in text.lines() {
        let l = line.trim();
        if l.starts_with('[') {
            in_deps = matches!(
                l,
                "[dependencies]" | "[dev-dependencies]" | "[build-dependencies]"
            );
            continue;
        }
        if !in_deps || l.is_empty() || l.starts_with('#') {
            continue;
        }
        let Some((name, _)) = l.split_once('=') else {
            continue;
        };
        out.push(name.trim().to_string());
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn allowed_dep_passes() {
        let tmp = TempDir::new().unwrap();
        let p = tmp.path().join("crates/koala-drift");
        fs::create_dir_all(&p).unwrap();
        fs::write(
            p.join("Cargo.toml"),
            "[package]\nname = \"koala-drift\"\n[dependencies]\nkoala-core = { path = \"..\" }\n",
        )
        .unwrap();
        let ctx = Context::new(tmp.path().to_path_buf());
        let outcome = DepDirection.evaluate(&ctx);
        assert!(matches!(outcome, Outcome::Pass { .. }));
    }

    #[test]
    fn forbidden_edge_fails() {
        let tmp = TempDir::new().unwrap();
        let p = tmp.path().join("crates/koala-core");
        fs::create_dir_all(&p).unwrap();
        fs::write(
            p.join("Cargo.toml"),
            "[package]\nname = \"koala-core\"\n[dependencies]\nkoala-cli = \"*\"\n",
        )
        .unwrap();
        let ctx = Context::new(tmp.path().to_path_buf());
        let outcome = DepDirection.evaluate(&ctx);
        assert!(matches!(outcome, Outcome::Fail { .. }));
    }
}