use std::fs;
use std::path::{Path, PathBuf};
const SRC_DIR: &str = "src";
const CORE_SRC_DIR: &str = "crates/llman-core/src";
const FORBIDDEN_FOR_MODULE_DIRS: &[(&str, &[&str])] = &[
("sdd", &["skills", "tool", "x"]),
("skills", &["sdd", "tool", "x"]),
("tool", &["sdd", "skills", "x"]),
("x", &["sdd"]),
];
const FORBIDDEN_FOR_ALL_MODULES: &[&str] = &["sdd", "skills", "tool", "x"];
const UTILITY_LAYER_FILES: &[&str] = &[
"fs_utils.rs",
"path_utils.rs",
"managed_block.rs",
"env_safety.rs",
"git_utils.rs",
"schema_utils.rs",
];
fn src_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join(SRC_DIR)
}
fn core_src_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join(CORE_SRC_DIR)
}
fn rs_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let entries = match fs::read_dir(¤t) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
out.sort();
out
}
fn crate_path_segments(text: &str) -> Vec<(usize, String)> {
let mut found = Vec::new();
for (line_no, line) in text.lines().enumerate() {
let code = line.split("//").next().unwrap_or("");
let mut cursor = 0;
while let Some(pos) = code[cursor..].find("crate::") {
let start = cursor + pos + "crate::".len();
let ident_end = code[start..]
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.map_or(code.len(), |rel| start + rel);
if ident_end > start {
found.push((line_no + 1, code[start..ident_end].to_string()));
}
cursor = ident_end.max(start);
}
}
found
}
fn direction_violations(dir: &str, forbidden: &[&str]) -> Vec<String> {
let root = src_root().join(dir);
let mut violations = Vec::new();
for file in rs_files(&root) {
let text = match fs::read_to_string(&file) {
Ok(text) => text,
Err(_) => continue,
};
for (line_no, segment) in crate_path_segments(&text) {
if forbidden.contains(&segment.as_str()) {
let rel = file
.strip_prefix(src_root())
.unwrap_or(&file)
.display()
.to_string();
violations.push(format!(
"src/{rel}:{line_no}: `crate::{segment}` violates direction rule for `{dir}/`"
));
}
}
}
violations.sort();
violations
}
#[test]
fn sdd_must_not_reference_sibling_feature_modules() {
let violations = direction_violations("sdd", &["skills", "tool", "x"]);
assert!(
violations.is_empty(),
"src/sdd/** must not depend on skills/tool/x:\n{}",
violations.join("\n")
);
}
#[test]
fn skills_must_not_reference_sibling_feature_modules() {
let violations = direction_violations("skills", &["sdd", "tool", "x"]);
assert!(
violations.is_empty(),
"src/skills/** must not depend on sdd/tool/x:\n{}",
violations.join("\n")
);
}
#[test]
fn tool_must_not_reference_sibling_feature_modules() {
let violations = direction_violations("tool", &["sdd", "skills", "x"]);
assert!(
violations.is_empty(),
"src/tool/** must not depend on sdd/skills/x (git merge made this hold, keep it):\n{}",
violations.join("\n")
);
}
#[test]
fn x_must_not_reference_sdd() {
let violations = direction_violations("x", &["sdd"]);
assert!(
violations.is_empty(),
"src/x/** must not depend on sdd:\n{}",
violations.join("\n")
);
}
#[test]
fn utility_layer_must_stay_dependency_free() {
let mut violations = Vec::new();
for file in UTILITY_LAYER_FILES {
let path = core_src_root().join(file);
let text = fs::read_to_string(&path).unwrap_or_else(|_| {
panic!(
"utility-layer module {file} is missing under {CORE_SRC_DIR}; update the direction table if it was renamed"
)
});
for (line_no, segment) in crate_path_segments(&text) {
if FORBIDDEN_FOR_ALL_MODULES.contains(&segment.as_str()) {
violations.push(format!(
"{CORE_SRC_DIR}/{file}:{line_no}: `crate::{segment}` violates the utility-layer rule"
));
}
}
}
assert!(
violations.is_empty(),
"top-level utility layer must not depend on feature modules:\n{}",
violations.join("\n")
);
}
#[test]
fn direction_table_covers_existing_modules() {
let repo = Path::new(env!("CARGO_MANIFEST_DIR"));
assert!(
!FORBIDDEN_FOR_MODULE_DIRS.is_empty(),
"direction table must not be emptied silently"
);
for (module, _) in FORBIDDEN_FOR_MODULE_DIRS {
let dir = if *module == "sdd" {
repo.join("crates/llman-sdd/src/sdd")
} else {
repo.join("src").join(module)
};
assert!(
dir.is_dir(),
"{module} missing but referenced by the direction table ({dir:?})"
);
}
}