use std::collections::{HashMap, HashSet};
use std::fmt;
use std::path::{Path, PathBuf};
pub fn arch_check() -> ArchCheck {
ArchCheck { rules: Vec::new(), src_dir: PathBuf::from("src") }
}
pub struct ArchCheck {
rules: Vec<Rule>,
src_dir: PathBuf,
}
impl ArchCheck {
pub fn src_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.src_dir = path.into();
self
}
pub fn module(mut self, name: &str) -> ModuleRuleBuilder {
self.rules.push(Rule::Module {
name: name.to_owned(),
allowed_deps: None,
forbidden_deps: None,
});
ModuleRuleBuilder { check: self, module_name: name.to_owned() }
}
pub fn all_modules(self) -> AllModulesRuleBuilder {
AllModulesRuleBuilder { check: self }
}
pub fn assert_all_pass(self) {
if let Err(msg) = self.run() {
panic!("Architecture violations:\n{}", msg);
}
}
pub fn run(mut self) -> Result<(), String> {
let graph = DependencyGraph::from_dir(&self.src_dir)?;
for rule in &mut self.rules {
if let Rule::Module { allowed_deps, forbidden_deps, .. } = rule {
if allowed_deps.is_none() && forbidden_deps.is_none() {
*forbidden_deps = Some(HashSet::new());
}
}
}
let mut violations = Vec::new();
for rule in &self.rules {
match rule {
Rule::Module { name, allowed_deps, forbidden_deps } => {
let deps = graph.dependencies_of(name);
if let Some(allowed) = allowed_deps {
for dep in &deps {
if !allowed.contains(dep) {
violations.push(format!(
" {} must not depend on {} (allowed: {})",
name,
dep,
allowed.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
));
}
}
}
if let Some(forbidden) = forbidden_deps {
for dep in &deps {
if forbidden.contains(dep) {
violations.push(format!(
" {} must not depend on {}",
name, dep
));
}
}
}
}
Rule::NoCycles => {
for cycle in &graph.find_cycles() {
violations.push(format!(
" cycle detected: {}",
cycle.join(" → ")
));
}
}
Rule::PublicApiDocRequired => {
for (file, item) in find_undocumented_pub_items(&self.src_dir) {
violations.push(format!(
" {}:{} — public item `{}` is missing a doc comment",
file.display(), item.line, item.name
));
}
}
}
}
if violations.is_empty() {
Ok(())
} else {
Err(violations.join("\n"))
}
}
}
pub struct ModuleRuleBuilder {
check: ArchCheck,
module_name: String,
}
impl ModuleRuleBuilder {
fn find_mut(&mut self) -> &mut Rule {
self.check
.rules
.iter_mut()
.find(|r| matches!(r, Rule::Module { name, .. } if *name == self.module_name))
.expect("module rule not found")
}
pub fn may_depend_on(mut self, deps: &[&str]) -> ArchCheck {
let set: HashSet<String> = deps.iter().map(|s| s.to_string()).collect();
if let Rule::Module { allowed_deps, .. } = self.find_mut() {
*allowed_deps = Some(set);
}
self.check
}
pub fn may_not_depend_on(mut self, deps: &[&str]) -> ArchCheck {
let set: HashSet<String> = deps.iter().map(|s| s.to_string()).collect();
if let Rule::Module { forbidden_deps, .. } = self.find_mut() {
let current = forbidden_deps.get_or_insert_with(HashSet::new);
current.extend(set);
}
self.check
}
}
impl From<ModuleRuleBuilder> for ArchCheck {
fn from(b: ModuleRuleBuilder) -> Self {
b.check
}
}
pub struct AllModulesRuleBuilder {
check: ArchCheck,
}
impl AllModulesRuleBuilder {
pub fn must_not_have_cycles(mut self) -> ArchCheck {
self.check.rules.push(Rule::NoCycles);
self.check
}
pub fn public_api_doc_required(mut self) -> ArchCheck {
self.check.rules.push(Rule::PublicApiDocRequired);
self.check
}
}
enum Rule {
Module {
name: String,
allowed_deps: Option<HashSet<String>>,
forbidden_deps: Option<HashSet<String>>,
},
NoCycles,
PublicApiDocRequired,
}
impl fmt::Debug for Rule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Rule::Module { name, .. } => write!(f, "Module({})", name),
Rule::NoCycles => write!(f, "NoCycles"),
Rule::PublicApiDocRequired => write!(f, "PublicApiDocRequired"),
}
}
}
struct DependencyGraph {
edges: HashMap<String, HashSet<String>>,
}
impl DependencyGraph {
fn from_dir(dir: &Path) -> Result<Self, String> {
if !dir.is_dir() {
return Err(format!("directory not found: {:?}", dir));
}
let mut edges: HashMap<String, HashSet<String>> = HashMap::new();
let mut files: Vec<PathBuf> = Vec::new();
collect_rs_files(dir, &mut files, dir);
for file in &files {
let rel = file.strip_prefix(dir).map_err(|e| format!("path: {e}"))?;
let module = path_to_module(rel);
let content = std::fs::read_to_string(file)
.map_err(|e| format!("read {:?}: {e}", file))?;
let deps = parse_deps(&content);
let entry: &mut HashSet<String> = edges.entry(module).or_default();
for dep in deps {
if dep.starts_with("crate::") {
entry.insert(dep.trim_start_matches("crate::").to_owned());
}
}
}
Ok(DependencyGraph { edges })
}
fn dependencies_of(&self, module: &str) -> HashSet<String> {
self.edges.get(module).cloned().unwrap_or_default()
}
fn find_cycles(&self) -> Vec<Vec<String>> {
let nodes: Vec<&String> = self.edges.keys().collect();
let mut visited: HashSet<&String> = HashSet::new();
let mut in_stack: HashSet<&String> = HashSet::new();
let mut stack: Vec<&String> = Vec::new();
let mut cycles: Vec<Vec<String>> = Vec::new();
fn dfs<'a>(
node: &'a String,
graph: &'a HashMap<String, HashSet<String>>,
visited: &mut HashSet<&'a String>,
in_stack: &mut HashSet<&'a String>,
stack: &mut Vec<&'a String>,
cycles: &mut Vec<Vec<String>>,
) {
if !visited.insert(node) {
return;
}
in_stack.insert(node);
stack.push(node);
if let Some(deps) = graph.get(node) {
for dep in deps {
if in_stack.contains(dep) {
let pos = stack.iter().position(|n| *n == dep).unwrap();
let cycle: Vec<String> = stack[pos..]
.iter()
.map(|s| (*s).clone())
.collect();
cycles.push(cycle);
} else {
dfs(dep, graph, visited, in_stack, stack, cycles);
}
}
}
stack.pop();
in_stack.remove(node);
}
for node in &nodes {
dfs(node, &self.edges, &mut visited, &mut in_stack, &mut stack, &mut cycles);
}
cycles
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_to_module_simple() {
assert_eq!(path_to_module(Path::new("core.rs")), "core");
}
#[test]
fn path_to_module_mod_rs() {
assert_eq!(path_to_module(Path::new("mod.rs")), "crate_root");
}
#[test]
fn path_to_module_subdir_mod() {
assert_eq!(path_to_module(Path::new("sub/mod.rs")), "sub");
}
#[test]
fn parse_deps_use_crate() {
let deps = parse_deps("use crate::core::TestSuite;\n");
assert!(deps.contains("core"));
assert_eq!(deps.len(), 1);
}
#[test]
fn parse_deps_pub_mod() {
let deps = parse_deps("pub mod spec;\n");
assert!(deps.contains("spec"));
}
#[test]
fn parse_deps_private_mod() {
let deps = parse_deps("mod internal;\n");
assert!(deps.contains("internal"));
}
#[test]
fn parse_deps_comment_ignored() {
let deps = parse_deps("// use crate::something;\nmod real;\n");
assert!(!deps.contains("something"));
assert!(deps.contains("real"));
}
#[test]
fn parse_deps_empty() {
let deps = parse_deps("");
assert!(deps.is_empty());
}
#[test]
fn parse_deps_multiple() {
let code = "use crate::core;\nuse crate::report;\npub mod runner;\n";
let deps = parse_deps(code);
assert!(deps.contains("core"));
assert!(deps.contains("report"));
assert!(deps.contains("runner"));
assert_eq!(deps.len(), 3);
}
#[test]
fn collect_rs_files_finds_rs_files() {
let dir = std::env::temp_dir().join("rvtest_arch_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("foo.rs"), "").unwrap();
std::fs::write(dir.join("bar.txt"), "").unwrap();
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub").join("baz.rs"), "").unwrap();
let mut files = Vec::new();
collect_rs_files(&dir, &mut files, &dir);
let names: Vec<String> = files.iter().map(|f| f.file_name().unwrap().to_str().unwrap().to_owned()).collect();
assert!(names.contains(&"foo.rs".into()));
assert!(names.contains(&"baz.rs".into()));
assert!(!names.contains(&"bar.txt".into()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn collect_rs_files_skips_hidden_dir() {
let dir = std::env::temp_dir().join("rvtest_arch_hidden");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(dir.join(".hidden")).unwrap();
std::fs::write(dir.join(".hidden").join("lib.rs"), "").unwrap();
let mut files = Vec::new();
collect_rs_files(&dir, &mut files, &dir);
let has_hidden = files.iter().any(|f| f.to_string_lossy().contains(".hidden"));
assert!(!has_hidden, ".hidden directory contents should be skipped");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn collect_rs_files_nonexistent_dir() {
let mut files = Vec::new();
collect_rs_files(Path::new("/nonexistent_dir_xyz"), &mut files, Path::new("/"));
assert!(files.is_empty());
}
#[test]
fn dependency_graph_empty_edges() {
let graph = DependencyGraph { edges: std::collections::HashMap::new() };
let deps = graph.dependencies_of("nonexistent");
assert!(deps.is_empty());
}
#[test]
fn dependency_graph_dependencies_of() {
let mut edges = std::collections::HashMap::new();
let mut deps = std::collections::HashSet::new();
deps.insert("core".to_string());
deps.insert("report".to_string());
edges.insert("spec".to_string(), deps);
let graph = DependencyGraph { edges };
let deps = graph.dependencies_of("spec");
assert_eq!(deps.len(), 2);
assert!(deps.contains("core"));
assert!(deps.contains("report"));
}
#[test]
fn dependency_graph_no_cycles() {
let mut edges = std::collections::HashMap::new();
edges.insert("a".to_string(), {
let mut s = std::collections::HashSet::new();
s.insert("b".to_string());
s
});
edges.insert("b".to_string(), {
let mut s = std::collections::HashSet::new();
s.insert("c".to_string());
s
});
edges.insert("c".to_string(), std::collections::HashSet::new());
let graph = DependencyGraph { edges };
let cycles = graph.find_cycles();
assert!(cycles.is_empty(), "should have no cycles: {:?}", cycles);
}
#[test]
fn dependency_graph_detects_cycles() {
let mut edges = std::collections::HashMap::new();
edges.insert("a".to_string(), {
let mut s = std::collections::HashSet::new();
s.insert("b".to_string());
s
});
edges.insert("b".to_string(), {
let mut s = std::collections::HashSet::new();
s.insert("a".to_string());
s
});
let graph = DependencyGraph { edges };
let cycles = graph.find_cycles();
assert!(!cycles.is_empty(), "should detect a-b-a cycle");
}
#[test]
fn dependency_graph_self_loop() {
let mut edges = std::collections::HashMap::new();
edges.insert("a".to_string(), {
let mut s = std::collections::HashSet::new();
s.insert("a".to_string());
s
});
let graph = DependencyGraph { edges };
let cycles = graph.find_cycles();
assert!(!cycles.is_empty(), "self-loop should be a cycle");
}
#[test]
fn has_preceding_doc_comment_with_doc_comment() {
let lines = vec!["/// Does stuff", "pub fn foo() {}"];
assert!(has_preceding_doc_comment(&lines, 1));
}
#[test]
fn has_preceding_doc_comment_without_doc_comment() {
let lines = vec!["", "pub fn foo() {}"];
assert!(!has_preceding_doc_comment(&lines, 1));
}
#[test]
fn has_preceding_doc_comment_first_line() {
let lines = vec!["pub fn foo() {}"];
assert!(!has_preceding_doc_comment(&lines, 0));
}
#[test]
fn has_preceding_doc_comment_inner_doc() {
let lines = vec!["//! Module docs", "pub fn foo() {}"];
assert!(has_preceding_doc_comment(&lines, 1));
}
#[test]
fn find_undocumented_pub_items_empty() {
let dir = std::env::temp_dir().join("rvtest_arch_pub_empty");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let items = find_undocumented_pub_items(&dir);
assert!(items.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_undocumented_pub_items_no_pub() {
let dir = std::env::temp_dir().join("rvtest_arch_pub_nopub");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("lib.rs"), "fn private() {}").unwrap();
let items = find_undocumented_pub_items(&dir);
assert!(items.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_undocumented_pub_items_documented() {
let dir = std::env::temp_dir().join("rvtest_arch_pub_docd");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("lib.rs"), "/// documented\npub fn foo() {}").unwrap();
let items = find_undocumented_pub_items(&dir);
assert!(items.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_undocumented_pub_items_undocumented() {
let dir = std::env::temp_dir().join("rvtest_arch_pub_undoc");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("lib.rs"), "pub fn foo() {}").unwrap();
let items = find_undocumented_pub_items(&dir);
assert_eq!(items.len(), 1);
assert_eq!(items[0].1.name, "foo");
let _ = std::fs::remove_dir_all(&dir);
}
}
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>, root: &Path) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !name.starts_with('.') && name != "target" {
collect_rs_files(&path, out, root);
}
} else if path.extension().map_or(false, |e| e == "rs") {
out.push(path);
}
}
}
fn path_to_module(rel: &Path) -> String {
let s = rel.to_string_lossy();
let stem = s.strip_suffix(".rs").unwrap_or(&s);
if stem.ends_with("/mod") || stem == "mod" {
let parent = rel.parent().and_then(|p| p.to_str()).unwrap_or("");
return if parent.is_empty() { "crate_root".into() } else { parent.replace('/', "::") };
}
stem.replace('/', "::")
}
fn parse_deps(content: &str) -> HashSet<String> {
let mut deps = HashSet::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
if let Some(rest) = trimmed.strip_prefix("use crate::") {
let path = rest.trim_end_matches(';');
let top = path.split("::").next().unwrap_or(path);
if !top.is_empty() {
deps.insert(top.to_owned());
}
}
if let Some(rest) = trimmed.strip_prefix("pub mod ") {
let name = rest.split(';').next().unwrap_or(rest).trim();
deps.insert(name.to_owned());
} else if let Some(rest) = trimmed.strip_prefix("mod ") {
let name = rest.split(';').next().unwrap_or(rest).trim();
deps.insert(name.to_owned());
}
}
deps
}
struct UndocumentedItem {
name: String,
line: usize,
}
fn find_undocumented_pub_items(src_dir: &Path) -> Vec<(PathBuf, UndocumentedItem)> {
let mut result = Vec::new();
let mut files = Vec::new();
collect_rs_files(src_dir, &mut files, src_dir);
for file in &files {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(_) => continue,
};
let lines: Vec<&str> = content.lines().collect();
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim();
let pub_kw = if let Some(rest) = trimmed.strip_prefix("pub ") {
rest.trim()
} else {
i += 1;
continue;
};
if has_preceding_doc_comment(&lines, i) {
i += 1;
continue;
}
let item_name = if let Some(name) = pub_kw
.strip_prefix("fn ")
.or_else(|| pub_kw.strip_prefix("struct "))
.or_else(|| pub_kw.strip_prefix("enum "))
.or_else(|| pub_kw.strip_prefix("trait "))
.or_else(|| pub_kw.strip_prefix("type "))
.or_else(|| pub_kw.strip_prefix("const "))
.or_else(|| pub_kw.strip_prefix("mod "))
.or_else(|| pub_kw.strip_prefix("use "))
{
name.split(|c: char| c == '(' || c == '{' || c == ';' || c == '=' || c == '<' || c == ':')
.next()
.unwrap_or("")
.trim()
.split_whitespace()
.next()
.unwrap_or("")
.to_owned()
} else {
i += 1;
continue;
};
if !item_name.is_empty() && item_name != "_" {
result.push((
file.clone(),
UndocumentedItem { name: item_name, line: i + 1 },
));
}
i += 1;
}
}
result
}
fn has_preceding_doc_comment(lines: &[&str], idx: usize) -> bool {
if idx == 0 {
return false;
}
let prev = lines[idx.saturating_sub(1)].trim();
prev.starts_with("///") || prev.starts_with("/**") || prev.starts_with("//!")
}