use crate::invariant::rules::util::list_cargo_tomls;
use crate::invariant::{Category, Context, Invariant, Outcome};
use std::fs;
const FORBIDDEN_EDGES: &[(&str, &str)] = &[
("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"),
];
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 { .. }));
}
}