use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use cargo_metadata::MetadataCommand;
use serde::Deserialize;
use syn::spanned::Spanned;
use syn::visit::{self, Visit};
use syn::{ItemUse, UseTree};
use crate::finding::{EvidenceClass, Finding, Location, OneBasedLine, Origin, Severity};
use crate::health_score::DeductionMultiplier;
use crate::ingest::{CrateInfo, Workspace};
use crate::rules::slopsquat::SlopsquatConfig;
pub const BOUNDARY_VIOLATION_RULE: &str = "crate-boundary-violation";
pub const BOUNDARY_VIOLATION_RULE_REVISION: u32 = 1;
pub const DEPENDENCY_CYCLE_RULE: &str = "dependency-cycle";
pub const DEPENDENCY_CYCLE_RULE_REVISION: u32 = 1;
pub const MODULE_BOUNDARY_VIOLATION_RULE: &str = "module-boundary-violation";
pub const MODULE_BOUNDARY_VIOLATION_RULE_REVISION: u32 = 1;
pub const FEATURE_GRAPH_CYCLE_RULE: &str = "feature-graph-cycle";
pub const FEATURE_GRAPH_CYCLE_RULE_REVISION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Reach {
Direct,
Transitive,
}
impl Reach {
fn label(self) -> &'static str {
match self {
Self::Direct => "direct",
Self::Transitive => "transitive",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct BoundaryRule {
pub name: String,
pub from: Vec<String>,
#[serde(default)]
pub forbidden: Vec<String>,
#[serde(default)]
pub required: Vec<String>,
pub reach: Reach,
#[serde(default)]
pub allow_empty: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ModuleBoundaryRule {
pub name: String,
#[serde(rename = "crate")]
pub krate: String,
pub from: String,
#[serde(default)]
pub forbidden: Vec<String>,
#[serde(default)]
pub reach: Option<Reach>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CrateProfile {
pub name: String,
pub crates: Vec<String>,
#[serde(default)]
pub deduction_multiplier: DeductionMultiplier,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LayerPreset {
Layered,
Hexagonal,
FeatureSliced,
}
impl LayerPreset {
fn label(self) -> &'static str {
match self {
Self::Layered => "layered",
Self::Hexagonal => "hexagonal",
Self::FeatureSliced => "feature-sliced",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct LayersConfig {
pub preset: LayerPreset,
#[serde(default)]
pub order: Vec<String>,
#[serde(default)]
pub shared: Option<String>,
#[serde(default)]
pub assign: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct BoundaryConfig {
#[serde(rename = "boundary", default)]
pub boundaries: Vec<BoundaryRule>,
#[serde(rename = "module_boundary", default)]
pub module_boundaries: Vec<ModuleBoundaryRule>,
#[serde(default)]
pub layers: Option<LayersConfig>,
#[serde(rename = "crate_profile", default)]
pub crate_profiles: Vec<CrateProfile>,
#[serde(default)]
pub internal_crates: Vec<String>,
#[serde(default)]
pub slopsquat: SlopsquatConfig,
#[serde(default)]
pub rules: RulesConfig,
#[serde(default)]
pub feature_matrix: FeatureMatrixConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct FeatureMatrixConfig {
#[serde(default)]
pub combinations: Vec<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RulesConfig {
#[serde(rename = "catch-all-error", default)]
pub catch_all_error: CatchAllErrorConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct CatchAllErrorConfig {
#[serde(default)]
pub allow_anyhow_at_boundary: bool,
}
#[derive(Debug)]
pub enum BoundaryConfigError {
Metadata(cargo_metadata::Error),
UnknownCrate {
rule: String,
crate_name: String,
},
InvalidLayers(String),
InvalidModuleBoundary(String),
}
impl std::fmt::Display for BoundaryConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Metadata(err) => write!(f, "failed to read cargo metadata: {err}"),
Self::UnknownCrate { rule, crate_name } => write!(
f,
"boundary rule `{rule}` references unknown crate `{crate_name}` (set allow_empty = true to permit this)"
),
Self::InvalidLayers(message) => write!(f, "invalid [layers] config: {message}"),
Self::InvalidModuleBoundary(message) => {
write!(f, "invalid [[module_boundary]] config: {message}")
}
}
}
}
impl std::error::Error for BoundaryConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Metadata(err) => Some(err),
Self::UnknownCrate { .. } | Self::InvalidLayers(_) | Self::InvalidModuleBoundary(_) => {
None
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CrateGraph {
pub edges: HashMap<String, Vec<String>>,
}
fn workspace_metadata(
manifest_path: Option<&Path>,
) -> Result<cargo_metadata::Metadata, BoundaryConfigError> {
let mut cmd = MetadataCommand::new();
if let Some(path) = manifest_path {
cmd.manifest_path(path);
}
cmd.no_deps().exec().map_err(BoundaryConfigError::Metadata)
}
pub fn build_crate_graph(manifest_path: Option<&Path>) -> Result<CrateGraph, BoundaryConfigError> {
let metadata = workspace_metadata(manifest_path)?;
let known_crate_names: HashSet<String> = metadata
.packages
.iter()
.map(|package| package.name.to_string())
.collect();
let mut edges: HashMap<String, Vec<String>> = HashMap::new();
for package in &metadata.packages {
let mut deps: Vec<String> = package
.dependencies
.iter()
.map(|dep| dep.name.clone())
.filter(|name| known_crate_names.contains(name))
.collect();
deps.sort();
deps.dedup();
edges.insert(package.name.to_string(), deps);
}
Ok(CrateGraph { edges })
}
fn mermaid_node_id(name: &str) -> String {
name.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
impl CrateGraph {
fn sorted_names(&self) -> Vec<&String> {
let mut names: Vec<&String> = self.edges.keys().collect();
names.sort();
names
}
fn render_lines(
&self,
mut node_line: impl FnMut(&str) -> String,
mut edge_line: impl FnMut(&str, &str) -> String,
) -> String {
let names = self.sorted_names();
let mut out = String::new();
for name in &names {
out.push_str(&node_line(name));
}
for name in &names {
for dep in &self.edges[*name] {
out.push_str(&edge_line(name, dep));
}
}
out
}
pub fn to_dot(&self) -> String {
let mut out = String::from("digraph crates {\n");
out.push_str(&self.render_lines(
|name| format!(" \"{name}\";\n"),
|name, dep| format!(" \"{name}\" -> \"{dep}\";\n"),
));
out.push_str("}\n");
out
}
pub fn to_mermaid(&self) -> String {
let mut out = String::from("flowchart TD\n");
out.push_str(&self.render_lines(
|name| format!(" {}[\"{name}\"]\n", mermaid_node_id(name)),
|name, dep| format!(" {} --> {}\n", mermaid_node_id(name), mermaid_node_id(dep)),
));
out
}
}
#[derive(Debug, Default)]
pub struct WorkspaceBoundaries {
pub findings: Vec<Finding>,
}
fn require_known_crate(
crate_names: &HashSet<&str>,
rule_name: &str,
crate_name: &str,
) -> Result<(), BoundaryConfigError> {
if crate_names.contains(crate_name) {
Ok(())
} else {
Err(BoundaryConfigError::UnknownCrate {
rule: rule_name.to_string(),
crate_name: crate_name.to_string(),
})
}
}
fn workspace_crate_names(workspace: &Workspace) -> HashSet<&str> {
workspace
.crates
.iter()
.map(|krate| krate.name.as_str())
.collect()
}
fn validate_config(
config: &BoundaryConfig,
crate_names: &HashSet<&str>,
) -> Result<(), BoundaryConfigError> {
for rule in &config.boundaries {
if rule.allow_empty {
continue;
}
for name in rule
.from
.iter()
.chain(rule.forbidden.iter())
.chain(rule.required.iter())
{
require_known_crate(crate_names, &rule.name, name)?;
}
}
Ok(())
}
fn validate_internal_crates_config(
config: &BoundaryConfig,
crate_names: &HashSet<&str>,
) -> Result<(), BoundaryConfigError> {
for name in &config.internal_crates {
require_known_crate(crate_names, "internal_crates", name)?;
}
Ok(())
}
pub fn validate_internal_crates(
workspace: &Workspace,
config: &BoundaryConfig,
) -> Result<(), BoundaryConfigError> {
validate_internal_crates_config(config, &workspace_crate_names(workspace))
}
fn validate_module_boundary_config(
config: &BoundaryConfig,
crate_names: &HashSet<&str>,
) -> Result<(), BoundaryConfigError> {
for rule in &config.module_boundaries {
require_known_crate(crate_names, &rule.name, &rule.krate)?;
if rule.forbidden.is_empty() {
return Err(BoundaryConfigError::InvalidModuleBoundary(format!(
"module boundary rule `{}` has no forbidden targets — a rule with no forbidden targets is vacuous",
rule.name
)));
}
if rule.reach == Some(Reach::Transitive) {
return Err(BoundaryConfigError::InvalidModuleBoundary(format!(
"module boundary rule `{}`: module boundaries only support direct reach in the Fast Tier",
rule.name
)));
}
}
Ok(())
}
fn group_crates(assigned: &[(&str, &str)], group_name: &str) -> Vec<String> {
assigned
.iter()
.filter(|(_, group)| *group == group_name)
.map(|(krate, _)| (*krate).to_string())
.collect()
}
const HEXAGONAL_ROLES: [&str; 3] = ["core", "ports", "adapters"];
fn transitive_preset_rule(name: String, from: Vec<String>, forbidden: Vec<String>) -> BoundaryRule {
BoundaryRule {
name,
from,
forbidden,
required: Vec::new(),
reach: Reach::Transitive,
allow_empty: false,
}
}
fn generate_layered_rules(
layers: &LayersConfig,
assigned: &[(&str, &str)],
) -> Result<Vec<BoundaryRule>, BoundaryConfigError> {
if layers.order.len() < 2 {
return Err(BoundaryConfigError::InvalidLayers(
"preset \"layered\" needs at least 2 entries in `order`".to_string(),
));
}
let known_layers: HashSet<&str> = layers.order.iter().map(String::as_str).collect();
for (krate, group) in assigned {
if !known_layers.contains(group) {
return Err(BoundaryConfigError::InvalidLayers(format!(
"`layers.assign` names crate `{krate}` as layer `{group}`, which is not in `order`"
)));
}
}
let mut rules = Vec::new();
for (i, inner) in layers.order.iter().enumerate() {
let from = group_crates(assigned, inner);
if from.is_empty() {
continue;
}
let forbidden: Vec<String> = layers.order[i + 1..]
.iter()
.flat_map(|outer| group_crates(assigned, outer))
.collect();
if forbidden.is_empty() {
continue;
}
rules.push(transitive_preset_rule(
format!("preset:layered:{inner}"),
from,
forbidden,
));
}
Ok(rules)
}
fn generate_hexagonal_rules(
assigned: &[(&str, &str)],
) -> Result<Vec<BoundaryRule>, BoundaryConfigError> {
for (krate, role) in assigned {
if !HEXAGONAL_ROLES.contains(role) {
return Err(BoundaryConfigError::InvalidLayers(format!(
"`layers.assign` names crate `{krate}` as role `{role}`, but preset \"hexagonal\" only knows {HEXAGONAL_ROLES:?}"
)));
}
}
let core = group_crates(assigned, "core");
let ports = group_crates(assigned, "ports");
let adapters = group_crates(assigned, "adapters");
if core.is_empty() || ports.is_empty() || adapters.is_empty() {
return Err(BoundaryConfigError::InvalidLayers(
"preset \"hexagonal\" needs at least one crate assigned to each of \"core\", \"ports\", \"adapters\"".to_string(),
));
}
Ok(vec![
BoundaryRule {
name: "preset:hexagonal:core".to_string(),
from: core.clone(),
forbidden: adapters.clone(),
required: Vec::new(),
reach: Reach::Direct,
allow_empty: false,
},
BoundaryRule {
name: "preset:hexagonal:adapters".to_string(),
from: adapters,
forbidden: core,
required: ports,
reach: Reach::Direct,
allow_empty: false,
},
])
}
fn generate_feature_sliced_rules(
layers: &LayersConfig,
assigned: &[(&str, &str)],
) -> Result<Vec<BoundaryRule>, BoundaryConfigError> {
if assigned.is_empty() {
return Err(BoundaryConfigError::InvalidLayers(
"preset \"feature-sliced\" needs at least one crate in `layers.assign`".to_string(),
));
}
let mut groups: Vec<&str> = assigned.iter().map(|(_, group)| *group).collect();
groups.sort();
groups.dedup();
if let Some(shared) = &layers.shared
&& !groups.contains(&shared.as_str())
{
return Err(BoundaryConfigError::InvalidLayers(format!(
"`layers.shared = \"{shared}\"` does not match any group in `layers.assign`"
)));
}
let non_shared: Vec<&str> = groups
.into_iter()
.filter(|group| Some(*group) != layers.shared.as_deref())
.collect();
if non_shared.len() < 2 {
return Err(BoundaryConfigError::InvalidLayers(
"preset \"feature-sliced\" needs at least 2 non-shared groups in `layers.assign`"
.to_string(),
));
}
let mut rules = Vec::new();
for &from_group in &non_shared {
let from = group_crates(assigned, from_group);
let forbidden: Vec<String> = non_shared
.iter()
.filter(|&&group| group != from_group)
.flat_map(|group| group_crates(assigned, group))
.collect();
rules.push(transitive_preset_rule(
format!("preset:feature-sliced:{from_group}"),
from,
forbidden,
));
}
Ok(rules)
}
fn generate_layer_rules(
layers: &LayersConfig,
crate_names: &HashSet<&str>,
) -> Result<Vec<BoundaryRule>, BoundaryConfigError> {
let mut assigned: Vec<(&str, &str)> = layers
.assign
.iter()
.map(|(krate, group)| (krate.as_str(), group.as_str()))
.collect();
assigned.sort();
for (krate, _) in &assigned {
require_known_crate(crate_names, "layers", krate)?;
}
match layers.preset {
LayerPreset::Layered => generate_layered_rules(layers, &assigned),
LayerPreset::Hexagonal => generate_hexagonal_rules(&assigned),
LayerPreset::FeatureSliced => generate_feature_sliced_rules(layers, &assigned),
}
}
pub fn evaluate(
workspace: &Workspace,
config: &BoundaryConfig,
) -> Result<WorkspaceBoundaries, BoundaryConfigError> {
let crate_names = workspace_crate_names(workspace);
validate_config(config, &crate_names)?;
validate_module_boundary_config(config, &crate_names)?;
validate_internal_crates_config(config, &crate_names)?;
let layer_rules = match &config.layers {
Some(layers) => generate_layer_rules(layers, &crate_names)?,
None => Vec::new(),
};
let manifest = workspace.root.join("Cargo.toml");
let graph = build_crate_graph(Some(&manifest))?;
let cargo_toml = workspace.root.join("Cargo.toml");
let mut findings = Vec::new();
for rule in &config.boundaries {
findings.extend(evaluate_rule(rule, &graph, &cargo_toml));
}
if let Some(layers) = &config.layers {
let preset_source = format!("preset:{}", layers.preset.label());
for rule in &layer_rules {
for mut finding in evaluate_rule(rule, &graph, &cargo_toml) {
finding.evidence = Some(serde_json::json!({ "source": preset_source }));
findings.push(finding);
}
}
}
for cycle in find_cycles(&graph) {
findings.push(cycle_finding(&cycle, &cargo_toml));
}
for rule in &config.module_boundaries {
if let Some(krate) = workspace
.crates
.iter()
.find(|krate| krate.name == rule.krate)
{
findings.extend(evaluate_module_boundary_rule(rule, krate));
}
}
Ok(WorkspaceBoundaries { findings })
}
fn bfs_from(graph: &CrateGraph, start: &str) -> (HashSet<String>, HashMap<String, String>) {
let mut visited: HashSet<String> = HashSet::new();
let mut parent: HashMap<String, String> = HashMap::new();
let mut queue: VecDeque<String> = VecDeque::new();
visited.insert(start.to_string());
queue.push_back(start.to_string());
while let Some(current) = queue.pop_front() {
if let Some(neighbors) = graph.edges.get(current.as_str()) {
for neighbor in neighbors {
if visited.insert(neighbor.clone()) {
parent.insert(neighbor.clone(), current.clone());
queue.push_back(neighbor.clone());
}
}
}
}
visited.remove(start);
(visited, parent)
}
fn reconstruct_path(parent: &HashMap<String, String>, start: &str, target: &str) -> Vec<String> {
let mut path = vec![target.to_string()];
let mut current = target.to_string();
while current != start {
let Some(prev) = parent.get(¤t) else {
break;
};
path.push(prev.clone());
current = prev.clone();
}
path.reverse();
path
}
fn evaluate_rule(rule: &BoundaryRule, graph: &CrateGraph, cargo_toml: &Path) -> Vec<Finding> {
let mut findings = Vec::new();
for from in &rule.from {
match rule.reach {
Reach::Direct => {
let neighbors: &[String] = graph
.edges
.get(from.as_str())
.map(Vec::as_slice)
.unwrap_or(&[]);
for forbidden in &rule.forbidden {
if neighbors.iter().any(|n| n == forbidden) {
let path = vec![from.clone(), forbidden.clone()];
findings.push(violation_finding(rule, &path, cargo_toml));
}
}
if !rule.required.is_empty()
&& !rule
.required
.iter()
.any(|r| neighbors.iter().any(|n| n == r))
{
findings.push(missing_required_finding(rule, from, cargo_toml));
}
}
Reach::Transitive => {
let (reachable, parent) = bfs_from(graph, from);
for forbidden in &rule.forbidden {
if reachable.contains(forbidden) {
let path = reconstruct_path(&parent, from, forbidden);
findings.push(violation_finding(rule, &path, cargo_toml));
}
}
if !rule.required.is_empty() && !rule.required.iter().any(|r| reachable.contains(r))
{
findings.push(missing_required_finding(rule, from, cargo_toml));
}
}
}
}
findings
}
fn boundary_violation_finding(
rule_name: &str,
id_suffix: &str,
item_path: String,
cargo_toml: &Path,
) -> Finding {
Finding::new(
format!("{BOUNDARY_VIOLATION_RULE}:{rule_name}:{id_suffix}"),
BOUNDARY_VIOLATION_RULE,
Severity::Fail,
Location {
file: cargo_toml.to_path_buf(),
line: OneBasedLine::FIRST,
item_path,
},
EvidenceClass::BoundedSemantic,
Origin::Code,
None,
)
}
fn violation_finding(rule: &BoundaryRule, path: &[String], cargo_toml: &Path) -> Finding {
let path_str = path.join(" -> ");
boundary_violation_finding(
&rule.name,
&path_str,
format!("{} [{}]: {path_str}", rule.name, rule.reach.label()),
cargo_toml,
)
}
fn missing_required_finding(rule: &BoundaryRule, from: &str, cargo_toml: &Path) -> Finding {
boundary_violation_finding(
&rule.name,
&format!("missing-required:{from}"),
format!(
"{} [{}]: {from} does not reach any of [{}]",
rule.name,
rule.reach.label(),
rule.required.join(", ")
),
cargo_toml,
)
}
fn cycle_finding(cycle: &[String], cargo_toml: &Path) -> Finding {
let path_str = cycle.join(" -> ");
Finding {
id: format!("{DEPENDENCY_CYCLE_RULE}:{path_str}").into(),
rule: DEPENDENCY_CYCLE_RULE.into(),
severity: Severity::Warn,
location: Location {
file: cargo_toml.to_path_buf(),
line: OneBasedLine::FIRST,
item_path: path_str,
},
evidence_class: EvidenceClass::BoundedSemantic,
origin: Origin::Code,
evidence: None,
limitations: None,
caused_by: Vec::new(),
causes: Vec::new(),
}
}
pub(crate) fn module_path_for_file(crate_root: &Path, file_path: &Path) -> Option<String> {
let relative = file_path.strip_prefix(crate_root).ok()?;
let mut components: Vec<String> = relative
.components()
.filter_map(|component| match component {
std::path::Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
_ => None,
})
.collect();
if components.first().map(String::as_str) != Some("src") {
return None;
}
components.remove(0);
if components.is_empty() {
return None;
}
if components.len() == 1 && matches!(components[0].as_str(), "lib.rs" | "main.rs") {
return Some(String::new());
}
if components.first().map(String::as_str) == Some("bin") {
return Some(String::new());
}
let last = components.last().cloned()?;
if last == "mod.rs" {
components.pop();
} else {
let stem = last.strip_suffix(".rs")?;
let stem = stem.to_string();
*components.last_mut().expect("just checked non-empty") = stem;
}
Some(components.join("::"))
}
pub(crate) fn module_path_under(module_path: &str, prefix: &str) -> bool {
module_path == prefix || module_path.starts_with(&format!("{prefix}::"))
}
pub(crate) fn files_with_module_path(
krate: &CrateInfo,
) -> impl Iterator<Item = (&crate::ingest::SourceFile, String)> {
krate
.source_files
.iter()
.filter_map(|file| module_path_for_file(&krate.root, &file.path).map(|path| (file, path)))
}
fn segments_match_forbidden(segments: &[String], forbidden: &str) -> bool {
let forbidden_segments: Vec<&str> = forbidden.split("::").collect();
if segments.len() < forbidden_segments.len() {
return false;
}
segments
.iter()
.zip(forbidden_segments.iter())
.all(|(segment, forbidden_segment)| segment == forbidden_segment)
}
fn resolve_leading_segments(
current_module: &str,
mut segments: Vec<String>,
) -> Option<Vec<String>> {
if segments.is_empty() {
return None;
}
let head = segments.remove(0);
let mut resolved: Vec<String> = match head.as_str() {
"crate" => Vec::new(),
"super" => {
let mut parts = current_module_segments(current_module);
parts.pop()?;
parts
}
_ => return None,
};
pop_leading_supers(&mut resolved, &mut segments)?;
resolved.extend(segments);
Some(resolved)
}
pub(crate) fn pop_leading_supers(
parts: &mut Vec<String>,
segments: &mut Vec<String>,
) -> Option<()> {
while segments.first().map(String::as_str) == Some("super") {
segments.remove(0);
parts.pop()?;
}
Some(())
}
fn current_module_segments(current_module: &str) -> Vec<String> {
if current_module.is_empty() {
Vec::new()
} else {
current_module.split("::").map(str::to_string).collect()
}
}
pub(crate) fn use_tree_leaf_segments(
tree: &UseTree,
acc: &mut Vec<String>,
out: &mut Vec<Vec<String>>,
) {
match tree {
UseTree::Path(use_path) => {
acc.push(use_path.ident.to_string());
use_tree_leaf_segments(&use_path.tree, acc, out);
acc.pop();
}
UseTree::Name(use_name) => {
let mut leaf = acc.clone();
leaf.push(use_name.ident.to_string());
out.push(leaf);
}
UseTree::Rename(use_rename) => {
let mut leaf = acc.clone();
leaf.push(use_rename.ident.to_string());
out.push(leaf);
}
UseTree::Glob(_) => out.push(acc.clone()),
UseTree::Group(group) => {
for item in &group.items {
use_tree_leaf_segments(item, acc, out);
}
}
}
}
pub(crate) fn path_segments(path: &syn::Path) -> Vec<String> {
path.segments.iter().map(|s| s.ident.to_string()).collect()
}
struct ModuleBoundaryCollector<'a> {
current_module: &'a str,
forbidden: &'a [String],
hits: Vec<(usize, String)>,
}
impl ModuleBoundaryCollector<'_> {
fn record_if_forbidden(&mut self, segments: &[String], line: usize) {
if let Some(forbidden) = self
.forbidden
.iter()
.find(|forbidden| segments_match_forbidden(segments, forbidden))
{
self.hits.push((line, forbidden.clone()));
}
}
}
impl<'ast> Visit<'ast> for ModuleBoundaryCollector<'_> {
fn visit_item_use(&mut self, node: &'ast ItemUse) {
let mut leaves = Vec::new();
use_tree_leaf_segments(&node.tree, &mut Vec::new(), &mut leaves);
let line = node.span().start().line;
for leaf in leaves {
if let Some(resolved) = resolve_leading_segments(self.current_module, leaf) {
self.record_if_forbidden(&resolved, line);
}
}
}
fn visit_path(&mut self, node: &'ast syn::Path) {
if let Some(resolved) = resolve_leading_segments(self.current_module, path_segments(node)) {
self.record_if_forbidden(&resolved, node.span().start().line);
}
visit::visit_path(self, node);
}
}
fn evaluate_module_boundary_rule(rule: &ModuleBoundaryRule, krate: &CrateInfo) -> Vec<Finding> {
let mut findings = Vec::new();
for (file, module_path) in files_with_module_path(krate) {
if !module_path_under(&module_path, &rule.from) {
continue;
}
let Ok((_, ast)) = crate::functions::read_and_parse_source(&file.path, |_| (), |_| ())
else {
continue;
};
let mut collector = ModuleBoundaryCollector {
current_module: &module_path,
forbidden: &rule.forbidden,
hits: Vec::new(),
};
collector.visit_file(&ast);
if let Some((line, forbidden)) = collector.hits.into_iter().min_by_key(|(line, _)| *line) {
findings.push(module_boundary_finding(
rule,
&file.path,
line,
&module_path,
&forbidden,
));
}
}
findings
}
fn module_boundary_finding(
rule: &ModuleBoundaryRule,
file: &Path,
line: usize,
module_path: &str,
forbidden: &str,
) -> Finding {
let line = OneBasedLine::new(line).unwrap_or(OneBasedLine::FIRST);
Finding {
id: format!(
"{MODULE_BOUNDARY_VIOLATION_RULE}:{}:{}:{line}",
rule.name,
file.display()
)
.into(),
rule: MODULE_BOUNDARY_VIOLATION_RULE.into(),
severity: Severity::Warn,
location: Location {
file: file.to_path_buf(),
line,
item_path: format!("{} [direct]: {module_path} -> {forbidden}", rule.name),
},
evidence_class: EvidenceClass::BoundedSemantic,
origin: Origin::Code,
evidence: Some(serde_json::json!({
"module_path_resolution": "directory-convention heuristic, not module-graph resolution — e.g. #[path = \"...\"] attributes are not recognized"
})),
limitations: None,
caused_by: Vec::new(),
causes: Vec::new(),
}
}
pub fn find_cycles(graph: &CrateGraph) -> Vec<Vec<String>> {
let mut node_names: Vec<String> = graph.edges.keys().cloned().collect();
node_names.sort();
fn visit_from(
start: &str,
current: &str,
graph: &CrateGraph,
visited: &mut HashSet<String>,
path: &mut Vec<String>,
cycles: &mut HashSet<Vec<String>>,
) {
if let Some(neighbors) = graph.edges.get(current) {
for neighbor in neighbors {
if neighbor == start {
let mut cycle = path.clone();
cycle.push(start.to_string());
cycles.insert(cycle);
} else if neighbor.as_str() >= start && visited.insert(neighbor.clone()) {
path.push(neighbor.clone());
visit_from(start, neighbor, graph, visited, path, cycles);
path.pop();
visited.remove(neighbor);
}
}
}
}
let mut found = HashSet::new();
for start in &node_names {
let mut visited = HashSet::from([start.clone()]);
let mut path = vec![start.clone()];
visit_from(start, start, graph, &mut visited, &mut path, &mut found);
}
let mut cycles: Vec<Vec<String>> = found.into_iter().collect();
cycles.sort();
cycles
}
fn feature_implication_graph(package: &cargo_metadata::Package) -> CrateGraph {
let mut edges: HashMap<String, Vec<String>> = HashMap::new();
for (feature_name, implications) in &package.features {
let mut sibling_edges: Vec<String> = implications
.iter()
.filter(|name| package.features.contains_key(name.as_str()))
.cloned()
.collect();
sibling_edges.sort();
edges.insert(feature_name.clone(), sibling_edges);
}
CrateGraph { edges }
}
pub fn feature_graph_cycles(
manifest_path: Option<&Path>,
) -> Result<Vec<Finding>, BoundaryConfigError> {
let metadata = workspace_metadata(manifest_path)?;
let mut findings = Vec::new();
for package in &metadata.packages {
let graph = feature_implication_graph(package);
let manifest = std::path::PathBuf::from(package.manifest_path.as_str());
for cycle in find_cycles(&graph) {
findings.push(feature_graph_cycle_finding(
&cycle,
&manifest,
&package.name,
));
}
}
findings.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
Ok(findings)
}
fn feature_graph_cycle_finding(
cycle: &[String],
manifest_path: &Path,
package_name: &str,
) -> Finding {
let path_str = cycle.join(" -> ");
Finding::new(
format!("{FEATURE_GRAPH_CYCLE_RULE}:{package_name}:{path_str}"),
FEATURE_GRAPH_CYCLE_RULE,
Severity::Warn,
Location {
file: manifest_path.to_path_buf(),
line: OneBasedLine::FIRST,
item_path: format!("{package_name}: {path_str}"),
},
EvidenceClass::DerivedFact,
Origin::Code,
Some(serde_json::json!({
"package": package_name,
"cycle": cycle,
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::TempDir;
use std::path::PathBuf;
fn write_crate(dir: &TempDir, name: &str, deps: &[(&str, &str)]) {
std::fs::create_dir_all(dir.join(name).join("src")).unwrap();
let mut manifest =
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
if !deps.is_empty() {
manifest.push_str("\n[dependencies]\n");
for (dep_name, rel_path) in deps {
manifest.push_str(&format!("{dep_name} = {{ path = \"{rel_path}\" }}\n"));
}
}
std::fs::write(dir.join(name).join("Cargo.toml"), manifest).unwrap();
std::fs::write(
dir.join(name).join("src/lib.rs"),
format!("pub fn {name}() {{}}\n"),
)
.unwrap();
}
fn write_workspace_manifest(dir: &TempDir, members: &[&str]) {
let members_toml = members
.iter()
.map(|m| format!("\"{m}\""))
.collect::<Vec<_>>()
.join(", ");
std::fs::write(
dir.join("Cargo.toml"),
format!("[workspace]\nmembers = [{members_toml}]\nresolver = \"2\"\n"),
)
.unwrap();
}
fn rule(name: &str, from: &[&str], reach: Reach) -> BoundaryRule {
BoundaryRule {
name: name.to_string(),
from: from.iter().map(|s| s.to_string()).collect(),
forbidden: Vec::new(),
required: Vec::new(),
reach,
allow_empty: false,
}
}
#[test]
fn transitive_forbidden_violation_reports_the_shortest_path() {
let dir = TempDir::new("boundaries-transitive");
write_crate(&dir, "ui", &[("core", "../core")]);
write_crate(&dir, "core", &[("db", "../db")]);
write_crate(&dir, "db", &[]);
write_workspace_manifest(&dir, &["ui", "core", "db"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let mut r = rule("ui-must-not-touch-db", &["ui"], Reach::Transitive);
r.forbidden = vec!["db".to_string()];
let config = BoundaryConfig {
boundaries: vec![r],
crate_profiles: Vec::new(),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert_eq!(result.findings.len(), 1);
let finding = &result.findings[0];
assert_eq!(finding.rule, BOUNDARY_VIOLATION_RULE);
assert_eq!(finding.severity, Severity::Fail);
assert!(finding.location.item_path.contains("ui -> core -> db"));
}
#[test]
fn direct_reach_does_not_flag_a_transitive_only_dependency() {
let dir = TempDir::new("boundaries-direct-no-violation");
write_crate(&dir, "ui", &[("core", "../core")]);
write_crate(&dir, "core", &[("db", "../db")]);
write_crate(&dir, "db", &[]);
write_workspace_manifest(&dir, &["ui", "core", "db"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let mut r = rule("ui-must-not-touch-db-direct", &["ui"], Reach::Direct);
r.forbidden = vec!["db".to_string()];
let config = BoundaryConfig {
boundaries: vec![r],
crate_profiles: Vec::new(),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert!(result.findings.is_empty());
}
#[test]
fn crate_boundary_violation_fires_regardless_of_cfg_gating_on_the_dependency_declaration() {
let dir = TempDir::new("boundaries-cfg-gated-dependency");
std::fs::create_dir_all(dir.join("ui").join("src")).unwrap();
std::fs::write(
dir.join("ui").join("Cargo.toml"),
"[package]\nname = \"ui\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[target.'cfg(target_os = \"windows\")'.dependencies]\ndb = { path = \"../db\" }\n",
)
.unwrap();
std::fs::write(dir.join("ui").join("src/lib.rs"), "pub fn ui() {}\n").unwrap();
write_crate(&dir, "db", &[]);
write_workspace_manifest(&dir, &["ui", "db"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let mut r = rule("ui-must-not-touch-db", &["ui"], Reach::Direct);
r.forbidden = vec!["db".to_string()];
let config = BoundaryConfig {
boundaries: vec![r],
crate_profiles: Vec::new(),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert_eq!(result.findings.len(), 1);
assert_eq!(result.findings[0].rule, BOUNDARY_VIOLATION_RULE);
}
#[test]
fn required_rule_violates_when_unreachable() {
let dir = TempDir::new("boundaries-required-missing");
write_crate(&dir, "core", &[]);
write_crate(&dir, "io-abstraction", &[]);
write_workspace_manifest(&dir, &["core", "io-abstraction"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let mut r = rule("core-needs-approved-io", &["core"], Reach::Direct);
r.required = vec!["io-abstraction".to_string()];
let config = BoundaryConfig {
boundaries: vec![r],
crate_profiles: Vec::new(),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert_eq!(result.findings.len(), 1);
assert_eq!(result.findings[0].rule, BOUNDARY_VIOLATION_RULE);
}
#[test]
fn required_rule_passes_when_reachable_direct() {
let dir = TempDir::new("boundaries-required-direct-ok");
write_crate(&dir, "io-abstraction", &[]);
write_crate(&dir, "core", &[("io-abstraction", "../io-abstraction")]);
write_workspace_manifest(&dir, &["core", "io-abstraction"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let mut r = rule("core-needs-approved-io", &["core"], Reach::Direct);
r.required = vec!["io-abstraction".to_string()];
let config = BoundaryConfig {
boundaries: vec![r],
crate_profiles: Vec::new(),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert!(result.findings.is_empty());
}
#[test]
fn required_rule_passes_when_reachable_transitively() {
let dir = TempDir::new("boundaries-required-transitive-ok");
write_crate(&dir, "io-abstraction", &[]);
write_crate(&dir, "core", &[("mid", "../mid")]);
write_crate(&dir, "mid", &[("io-abstraction", "../io-abstraction")]);
write_workspace_manifest(&dir, &["core", "mid", "io-abstraction"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let mut r = rule("core-needs-approved-io", &["core"], Reach::Transitive);
r.required = vec!["io-abstraction".to_string()];
let config = BoundaryConfig {
boundaries: vec![r],
crate_profiles: Vec::new(),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert!(result.findings.is_empty());
}
#[test]
fn a_rule_naming_an_unknown_crate_is_a_config_error_by_default() {
let dir = TempDir::new("boundaries-unknown-crate");
write_crate(&dir, "ui", &[]);
write_workspace_manifest(&dir, &["ui"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let mut r = rule("ui-must-not-touch-db", &["ui"], Reach::Transitive);
r.forbidden = vec!["db".to_string()]; let config = BoundaryConfig {
boundaries: vec![r],
crate_profiles: Vec::new(),
..Default::default()
};
let err = evaluate(&workspace, &config).unwrap_err();
assert!(matches!(err, BoundaryConfigError::UnknownCrate { .. }));
}
#[test]
fn internal_crates_naming_an_unknown_crate_is_a_config_error() {
let dir = TempDir::new("boundaries-internal-crates-unknown");
write_crate(&dir, "ui", &[]);
write_workspace_manifest(&dir, &["ui"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
internal_crates: vec!["not-a-workspace-crate".to_string()],
crate_profiles: Vec::new(),
..Default::default()
};
let err = evaluate(&workspace, &config).unwrap_err();
assert!(matches!(err, BoundaryConfigError::UnknownCrate { .. }));
let err = validate_internal_crates(&workspace, &config).unwrap_err();
assert!(matches!(err, BoundaryConfigError::UnknownCrate { .. }));
}
#[test]
fn internal_crates_naming_a_real_workspace_crate_is_accepted() {
let dir = TempDir::new("boundaries-internal-crates-known");
write_crate(&dir, "ui", &[]);
write_crate(&dir, "other", &[]);
write_workspace_manifest(&dir, &["ui", "other"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
internal_crates: vec!["other".to_string()],
crate_profiles: Vec::new(),
..Default::default()
};
assert!(evaluate(&workspace, &config).is_ok());
assert!(validate_internal_crates(&workspace, &config).is_ok());
}
#[test]
fn allow_empty_permits_a_rule_naming_an_unknown_crate() {
let dir = TempDir::new("boundaries-unknown-crate-allowed");
write_crate(&dir, "ui", &[]);
write_workspace_manifest(&dir, &["ui"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let mut r = rule("ui-must-not-touch-db", &["ui"], Reach::Transitive);
r.forbidden = vec!["db".to_string()];
r.allow_empty = true;
let config = BoundaryConfig {
boundaries: vec![r],
crate_profiles: Vec::new(),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert!(result.findings.is_empty());
}
#[test]
fn a_real_circular_workspace_produces_exactly_one_deduped_cycle_finding() {
let dir = TempDir::new("boundaries-cycle");
write_crate(&dir, "a", &[("b", "../b")]);
write_crate(&dir, "b", &[("a", "../a")]);
write_workspace_manifest(&dir, &["a", "b"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig::default();
let result = evaluate(&workspace, &config).unwrap();
let cycle_findings: Vec<_> = result
.findings
.iter()
.filter(|f| f.rule == DEPENDENCY_CYCLE_RULE)
.collect();
assert_eq!(cycle_findings.len(), 1);
assert_eq!(cycle_findings[0].severity, Severity::Warn);
}
#[test]
fn dependency_cycle_fires_for_a_cycle_formed_only_through_a_dev_dependency() {
let dir = TempDir::new("boundaries-dev-dependency-cycle");
std::fs::create_dir_all(dir.join("a").join("src")).unwrap();
std::fs::write(
dir.join("a").join("Cargo.toml"),
"[package]\nname = \"a\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nb = { path = \"../b\" }\n",
)
.unwrap();
std::fs::write(dir.join("a").join("src/lib.rs"), "pub fn a() {}\n").unwrap();
std::fs::create_dir_all(dir.join("b").join("src")).unwrap();
std::fs::write(
dir.join("b").join("Cargo.toml"),
"[package]\nname = \"b\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dev-dependencies]\na = { path = \"../a\" }\n",
)
.unwrap();
std::fs::write(dir.join("b").join("src/lib.rs"), "pub fn b() {}\n").unwrap();
write_workspace_manifest(&dir, &["a", "b"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig::default();
let result = evaluate(&workspace, &config).unwrap();
let cycle_findings: Vec<_> = result
.findings
.iter()
.filter(|f| f.rule == DEPENDENCY_CYCLE_RULE)
.collect();
assert_eq!(cycle_findings.len(), 1);
}
#[test]
fn find_cycles_dedupes_rotations_of_the_same_cycle() {
let mut edges = HashMap::new();
edges.insert("a".to_string(), vec!["b".to_string()]);
edges.insert("b".to_string(), vec!["c".to_string()]);
edges.insert("c".to_string(), vec!["a".to_string()]);
let graph = CrateGraph { edges };
let cycles = find_cycles(&graph);
assert_eq!(cycles.len(), 1);
assert_eq!(
cycles[0],
vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"a".to_string()
]
);
}
#[test]
fn find_cycles_returns_empty_for_an_acyclic_graph() {
let mut edges = HashMap::new();
edges.insert("a".to_string(), vec!["b".to_string()]);
edges.insert("b".to_string(), vec!["c".to_string()]);
edges.insert("c".to_string(), vec![]);
let graph = CrateGraph { edges };
assert!(find_cycles(&graph).is_empty());
}
#[test]
fn find_cycles_reports_overlapping_cycles() {
let graph = CrateGraph {
edges: HashMap::from([
("a".to_string(), vec!["b".to_string(), "c".to_string()]),
("b".to_string(), vec!["c".to_string()]),
("c".to_string(), vec!["a".to_string()]),
]),
};
let cycles = find_cycles(&graph);
assert_eq!(
cycles,
vec![
vec!["a", "b", "c", "a"]
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>(),
vec!["a", "c", "a"]
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>(),
]
);
}
fn write_crate_with_features(dir: &TempDir, features_block: &str) -> PathBuf {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!(
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n{features_block}\n"
),
)
.unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
dir.join("Cargo.toml")
}
#[test]
fn feature_graph_cycles_fires_for_a_two_feature_cycle() {
let dir = TempDir::new("boundaries-feature-cycle-two");
let manifest = write_crate_with_features(&dir, "[features]\na = [\"b\"]\nb = [\"a\"]\n");
let findings = feature_graph_cycles(Some(&manifest)).unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule, FEATURE_GRAPH_CYCLE_RULE);
assert_eq!(findings[0].evidence_class, EvidenceClass::DerivedFact);
assert!(findings[0].is_gating());
let cycle = findings[0].evidence.as_ref().unwrap()["cycle"].clone();
assert_eq!(cycle, serde_json::json!(["a", "b", "a"]));
}
#[test]
fn feature_graph_cycle_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(FEATURE_GRAPH_CYCLE_RULE)
.expect("feature-graph-cycle has a registry entry")
.example
.expect("feature-graph-cycle has a curated example")
.before;
let dir = TempDir::new("boundaries-feature-cycle-registry-example");
let manifest = write_crate_with_features(&dir, example);
let findings = feature_graph_cycles(Some(&manifest)).unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule, FEATURE_GRAPH_CYCLE_RULE);
}
#[test]
fn feature_graph_cycles_returns_empty_for_an_acyclic_feature_graph() {
let dir = TempDir::new("boundaries-feature-cycle-acyclic");
let manifest =
write_crate_with_features(&dir, "[features]\ndefault = []\na = []\nfull = [\"a\"]\n");
let findings = feature_graph_cycles(Some(&manifest)).unwrap();
assert!(findings.is_empty());
}
#[test]
fn feature_graph_cycles_ignores_dependency_activation_entries() {
let dir = TempDir::new("boundaries-feature-cycle-dep-activation");
let manifest = write_crate_with_features(
&dir,
"[features]\na = [\"dep:foo\", \"bar/baz\"]\n\n[dependencies]\nfoo = { version = \"1\", optional = true }\nbar = \"1\"\n",
);
let findings = feature_graph_cycles(Some(&manifest)).unwrap();
assert!(findings.is_empty());
}
#[test]
fn feature_graph_cycles_errors_for_a_nonexistent_manifest() {
let err = feature_graph_cycles(Some(Path::new("/nonexistent/judge-test/Cargo.toml")))
.unwrap_err();
assert!(matches!(err, BoundaryConfigError::Metadata(_)));
}
#[test]
fn to_dot_renders_nodes_and_edges_in_sorted_order() {
let graph = CrateGraph {
edges: HashMap::from([
("b".to_string(), vec!["a".to_string()]),
("a".to_string(), vec![]),
]),
};
assert_eq!(
graph.to_dot(),
"digraph crates {\n \"a\";\n \"b\";\n \"b\" -> \"a\";\n}\n"
);
}
#[test]
fn to_dot_declares_an_isolated_crate_with_no_edges() {
let graph = CrateGraph {
edges: HashMap::from([("lonely".to_string(), vec![])]),
};
assert_eq!(graph.to_dot(), "digraph crates {\n \"lonely\";\n}\n");
}
#[test]
fn to_mermaid_sanitizes_hyphenated_crate_names_into_node_ids() {
let graph = CrateGraph {
edges: HashMap::from([
("cargo-judge".to_string(), vec!["judge-mcp".to_string()]),
("judge-mcp".to_string(), vec![]),
]),
};
assert_eq!(
graph.to_mermaid(),
"flowchart TD\n cargo_judge[\"cargo-judge\"]\n judge_mcp[\"judge-mcp\"]\n cargo_judge --> judge_mcp\n"
);
}
#[test]
fn to_mermaid_declares_an_isolated_crate_with_no_edges() {
let graph = CrateGraph {
edges: HashMap::from([("lonely".to_string(), vec![])]),
};
assert_eq!(graph.to_mermaid(), "flowchart TD\n lonely[\"lonely\"]\n");
}
#[test]
fn toml_from_str_round_trips_a_judge_toml_fixture() {
let source = r#"
[[boundary]]
name = "ui-must-not-touch-db"
from = ["ui"]
forbidden = ["db"]
reach = "transitive"
[[boundary]]
name = "core-needs-approved-io"
from = ["core"]
required = ["io-abstraction"]
reach = "direct"
allow_empty = false
"#;
let config: BoundaryConfig = toml::from_str(source).unwrap();
assert_eq!(config.boundaries.len(), 2);
assert_eq!(config.boundaries[0].name, "ui-must-not-touch-db");
assert_eq!(config.boundaries[0].from, vec!["ui".to_string()]);
assert_eq!(config.boundaries[0].forbidden, vec!["db".to_string()]);
assert!(config.boundaries[0].required.is_empty());
assert_eq!(config.boundaries[0].reach, Reach::Transitive);
assert!(!config.boundaries[0].allow_empty);
assert_eq!(config.boundaries[1].name, "core-needs-approved-io");
assert_eq!(
config.boundaries[1].required,
vec!["io-abstraction".to_string()]
);
assert_eq!(config.boundaries[1].reach, Reach::Direct);
assert!(!config.boundaries[1].allow_empty);
}
#[test]
fn toml_from_str_round_trips_crate_profiles() {
let source = r#"
[[crate_profile]]
name = "lenient"
crates = ["parser"]
deduction_multiplier = 0.5
[[crate_profile]]
name = "strict"
crates = ["cli"]
"#;
let config: BoundaryConfig = toml::from_str(source).unwrap();
assert_eq!(config.crate_profiles.len(), 2);
assert_eq!(config.crate_profiles[0].name, "lenient");
assert_eq!(config.crate_profiles[0].crates, vec!["parser".to_string()]);
assert_eq!(config.crate_profiles[0].deduction_multiplier.value(), 0.5);
assert_eq!(config.crate_profiles[1].deduction_multiplier.value(), 1.0);
}
#[test]
fn toml_from_str_round_trips_catch_all_error_rule_config() {
let source = r#"
[rules.catch-all-error]
allow-anyhow-at-boundary = true
"#;
let config: BoundaryConfig = toml::from_str(source).unwrap();
assert!(config.rules.catch_all_error.allow_anyhow_at_boundary);
}
#[test]
fn missing_rules_table_defaults_to_false() {
let config: BoundaryConfig = toml::from_str("").unwrap();
assert!(!config.rules.catch_all_error.allow_anyhow_at_boundary);
}
#[test]
fn boundary_config_error_source_preserves_the_metadata_error() {
let err =
build_crate_graph(Some(Path::new("/nonexistent/judge-test/Cargo.toml"))).unwrap_err();
let source = std::error::Error::source(&err).expect("Metadata must carry a source");
assert!(source.downcast_ref::<cargo_metadata::Error>().is_some());
}
#[test]
fn toml_from_str_round_trips_a_layers_preset_fixture() {
let source = r#"
[layers]
preset = "layered"
order = ["domain", "application", "infrastructure"]
[layers.assign]
"core-domain" = "domain"
"app-service" = "application"
"infra-io" = "infrastructure"
"#;
let config: BoundaryConfig = toml::from_str(source).unwrap();
let layers = config.layers.expect("layers table must be present");
assert_eq!(layers.preset, LayerPreset::Layered);
assert_eq!(
layers.order,
vec![
"domain".to_string(),
"application".to_string(),
"infrastructure".to_string()
]
);
assert_eq!(
layers.assign.get("core-domain").map(String::as_str),
Some("domain")
);
assert!(layers.shared.is_none());
}
fn layers_config(
preset: LayerPreset,
order: &[&str],
shared: Option<&str>,
assign: &[(&str, &str)],
) -> LayersConfig {
LayersConfig {
preset,
order: order.iter().map(|s| s.to_string()).collect(),
shared: shared.map(str::to_string),
assign: assign
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
#[test]
fn layered_preset_flags_inner_layer_reaching_outer_layer() {
let dir = TempDir::new("layers-layered-violation");
write_crate(&dir, "core-domain", &[("app-service", "../app-service")]);
write_crate(&dir, "app-service", &[]);
write_crate(&dir, "infra-io", &[]);
write_workspace_manifest(&dir, &["core-domain", "app-service", "infra-io"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
layers: Some(layers_config(
LayerPreset::Layered,
&["domain", "application", "infrastructure"],
None,
&[
("core-domain", "domain"),
("app-service", "application"),
("infra-io", "infrastructure"),
],
)),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert_eq!(result.findings.len(), 1);
let finding = &result.findings[0];
assert_eq!(finding.rule, BOUNDARY_VIOLATION_RULE);
assert!(
finding
.location
.item_path
.contains("core-domain -> app-service")
);
assert_eq!(
finding.evidence,
Some(serde_json::json!({ "source": "preset:layered" }))
);
}
#[test]
fn layered_preset_allows_outer_layer_reaching_inner_layer() {
let dir = TempDir::new("layers-layered-allowed");
write_crate(&dir, "core-domain", &[]);
write_crate(&dir, "app-service", &[("core-domain", "../core-domain")]);
write_crate(&dir, "infra-io", &[]);
write_workspace_manifest(&dir, &["core-domain", "app-service", "infra-io"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
layers: Some(layers_config(
LayerPreset::Layered,
&["domain", "application", "infrastructure"],
None,
&[
("core-domain", "domain"),
("app-service", "application"),
("infra-io", "infrastructure"),
],
)),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert!(result.findings.is_empty());
}
#[test]
fn feature_sliced_preset_flags_cross_group_reference_but_allows_shared() {
let dir = TempDir::new("layers-feature-sliced");
write_crate(
&dir,
"feature-a",
&[("feature-b", "../feature-b"), ("common", "../common")],
);
write_crate(&dir, "feature-b", &[("common", "../common")]);
write_crate(&dir, "common", &[]);
write_workspace_manifest(&dir, &["feature-a", "feature-b", "common"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
layers: Some(layers_config(
LayerPreset::FeatureSliced,
&[],
Some("shared"),
&[
("feature-a", "group-a"),
("feature-b", "group-b"),
("common", "shared"),
],
)),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert_eq!(result.findings.len(), 1);
let finding = &result.findings[0];
assert_eq!(finding.rule, BOUNDARY_VIOLATION_RULE);
assert!(
finding
.location
.item_path
.contains("feature-a -> feature-b")
);
assert_eq!(
finding.evidence,
Some(serde_json::json!({ "source": "preset:feature-sliced" }))
);
}
#[test]
fn hexagonal_preset_flags_core_reaching_adapters() {
let dir = TempDir::new("layers-hexagonal");
write_crate(&dir, "core-crate", &[("adapter-crate", "../adapter-crate")]);
write_crate(&dir, "adapter-crate", &[("port-crate", "../port-crate")]);
write_crate(&dir, "port-crate", &[]);
write_workspace_manifest(&dir, &["core-crate", "adapter-crate", "port-crate"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
layers: Some(layers_config(
LayerPreset::Hexagonal,
&[],
None,
&[
("core-crate", "core"),
("adapter-crate", "adapters"),
("port-crate", "ports"),
],
)),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert_eq!(result.findings.len(), 1);
let finding = &result.findings[0];
assert_eq!(finding.rule, BOUNDARY_VIOLATION_RULE);
assert!(
finding
.location
.item_path
.contains("core-crate -> adapter-crate")
);
assert_eq!(
finding.evidence,
Some(serde_json::json!({ "source": "preset:hexagonal" }))
);
}
#[test]
fn hexagonal_preset_cannot_express_a_crate_that_legitimately_holds_two_roles_at_once() {
let dir = TempDir::new("layers-hexagonal-dual-role");
write_crate(&dir, "core-domain", &[]);
write_crate(&dir, "shared-types", &[]);
write_crate(
&dir,
"adapter-crate",
&[
("shared-types", "../shared-types"),
("port-iface", "../port-iface"),
],
);
write_crate(&dir, "port-iface", &[]);
write_workspace_manifest(
&dir,
&["core-domain", "shared-types", "adapter-crate", "port-iface"],
);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config_core = BoundaryConfig {
layers: Some(layers_config(
LayerPreset::Hexagonal,
&[],
None,
&[
("core-domain", "core"),
("shared-types", "core"),
("adapter-crate", "adapters"),
("port-iface", "ports"),
],
)),
..Default::default()
};
let result_core = evaluate(&workspace, &config_core).unwrap();
assert_eq!(result_core.findings.len(), 1, "{:?}", result_core.findings);
assert!(
result_core.findings[0]
.location
.item_path
.contains("adapter-crate -> shared-types"),
"classified as core, the adapter's legitimate use of the crate's port half now \
reads as a domain-boundary violation: {:?}",
result_core.findings[0]
);
let config_ports = BoundaryConfig {
layers: Some(layers_config(
LayerPreset::Hexagonal,
&[],
None,
&[
("core-domain", "core"),
("shared-types", "ports"),
("adapter-crate", "adapters"),
("port-iface", "ports"),
],
)),
..Default::default()
};
let result_ports = evaluate(&workspace, &config_ports).unwrap();
assert!(
result_ports.findings.is_empty(),
"classified as ports, the identical edge is now the expected \
adapters-reach-ports wiring: {:?}",
result_ports.findings
);
}
#[test]
fn unknown_crate_in_layers_assign_is_a_config_error() {
let dir = TempDir::new("layers-unknown-crate");
write_crate(&dir, "ui", &[]);
write_workspace_manifest(&dir, &["ui"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
layers: Some(layers_config(
LayerPreset::Layered,
&["domain", "application"],
None,
&[("does-not-exist", "domain")],
)),
..Default::default()
};
let err = evaluate(&workspace, &config).unwrap_err();
assert!(matches!(err, BoundaryConfigError::UnknownCrate { .. }));
}
#[test]
fn handwritten_boundary_rule_and_layers_preset_both_fire() {
let dir = TempDir::new("layers-and-handwritten-boundary");
write_crate(&dir, "ui", &[("db", "../db")]);
write_crate(&dir, "db", &[]);
write_crate(&dir, "core-domain", &[("app-service", "../app-service")]);
write_crate(&dir, "app-service", &[]);
write_workspace_manifest(&dir, &["ui", "db", "core-domain", "app-service"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let mut handwritten = rule("ui-must-not-touch-db", &["ui"], Reach::Direct);
handwritten.forbidden = vec!["db".to_string()];
let config = BoundaryConfig {
boundaries: vec![handwritten],
layers: Some(layers_config(
LayerPreset::Layered,
&["domain", "application"],
None,
&[("core-domain", "domain"), ("app-service", "application")],
)),
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert_eq!(result.findings.len(), 2);
let handwritten_finding = result
.findings
.iter()
.find(|f| f.location.item_path.contains("ui -> db"))
.expect("handwritten rule must still fire");
assert!(handwritten_finding.evidence.is_none());
let preset_finding = result
.findings
.iter()
.find(|f| f.location.item_path.contains("core-domain -> app-service"))
.expect("preset rule must also fire");
assert_eq!(
preset_finding.evidence,
Some(serde_json::json!({ "source": "preset:layered" }))
);
}
fn module_boundary_rule(
name: &str,
krate: &str,
from: &str,
forbidden: &[&str],
) -> ModuleBoundaryRule {
ModuleBoundaryRule {
name: name.to_string(),
krate: krate.to_string(),
from: from.to_string(),
forbidden: forbidden.iter().map(|s| s.to_string()).collect(),
reach: None,
}
}
#[test]
fn module_boundary_flags_a_forbidden_qualified_path_reference() {
let dir = TempDir::new("module-boundary-violation");
write_crate(&dir, "my-core", &[]);
write_workspace_manifest(&dir, &["my-core"]);
std::fs::write(
dir.join("my-core/src/lib.rs"),
"pub mod domain;\npub mod io;\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("my-core/src/domain")).unwrap();
std::fs::write(
dir.join("my-core/src/domain/mod.rs"),
"pub fn run() {\n crate::io::read_file();\n}\n",
)
.unwrap();
std::fs::write(dir.join("my-core/src/io.rs"), "pub fn read_file() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-io",
"my-core",
"domain",
&["io"],
)],
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
let findings: Vec<_> = result
.findings
.iter()
.filter(|f| f.rule == MODULE_BOUNDARY_VIOLATION_RULE)
.collect();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Warn);
assert_eq!(findings[0].evidence_class, EvidenceClass::BoundedSemantic);
assert!(findings[0].location.item_path.contains("domain -> io"));
assert!(findings[0].location.file.ends_with("src/domain/mod.rs"));
}
#[test]
fn module_boundary_does_not_flag_a_domain_module_with_no_io_reference() {
let dir = TempDir::new("module-boundary-clean");
write_crate(&dir, "my-core", &[]);
write_workspace_manifest(&dir, &["my-core"]);
std::fs::write(
dir.join("my-core/src/lib.rs"),
"pub mod domain;\npub mod io;\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("my-core/src/domain")).unwrap();
std::fs::write(
dir.join("my-core/src/domain/mod.rs"),
"pub fn run() -> u32 {\n 42\n}\n",
)
.unwrap();
std::fs::write(dir.join("my-core/src/io.rs"), "pub fn read_file() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-io",
"my-core",
"domain",
&["io"],
)],
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert!(
result
.findings
.iter()
.all(|f| f.rule != MODULE_BOUNDARY_VIOLATION_RULE)
);
}
#[test]
fn module_boundary_does_not_flag_a_reference_from_outside_the_from_module() {
let dir = TempDir::new("module-boundary-out-of-scope");
write_crate(&dir, "my-core", &[]);
write_workspace_manifest(&dir, &["my-core"]);
std::fs::write(
dir.join("my-core/src/lib.rs"),
"pub mod application;\npub mod io;\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("my-core/src/application")).unwrap();
std::fs::write(
dir.join("my-core/src/application/mod.rs"),
"pub fn run() {\n crate::io::read_file();\n}\n",
)
.unwrap();
std::fs::write(dir.join("my-core/src/io.rs"), "pub fn read_file() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-io",
"my-core",
"domain",
&["io"],
)],
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert!(
result
.findings
.iter()
.all(|f| f.rule != MODULE_BOUNDARY_VIOLATION_RULE)
);
}
#[test]
fn module_boundary_does_not_flag_a_self_qualified_path_reference() {
let dir = TempDir::new("module-boundary-self-path");
write_crate(&dir, "my-core", &[]);
write_workspace_manifest(&dir, &["my-core"]);
std::fs::write(
dir.join("my-core/src/lib.rs"),
"pub mod domain;\npub mod io;\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("my-core/src/domain")).unwrap();
std::fs::write(
dir.join("my-core/src/domain/mod.rs"),
"pub fn run() {\n self::io::read_file();\n}\n",
)
.unwrap();
std::fs::write(dir.join("my-core/src/io.rs"), "pub fn read_file() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-io",
"my-core",
"domain",
&["io"],
)],
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert!(
result
.findings
.iter()
.all(|f| f.rule != MODULE_BOUNDARY_VIOLATION_RULE),
"a `self::`-qualified reference is not resolved, so it is never flagged \
even though the equivalent `crate::io::...` reference would be"
);
}
#[test]
fn module_boundary_misses_a_violation_in_a_file_relocated_via_path_attribute() {
let dir = TempDir::new("module-boundary-path-attribute");
write_crate(&dir, "my-core", &[]);
write_workspace_manifest(&dir, &["my-core"]);
std::fs::write(
dir.join("my-core/src/lib.rs"),
"pub mod domain;\npub mod io;\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("my-core/src/domain")).unwrap();
std::fs::write(
dir.join("my-core/src/domain/mod.rs"),
"#[path = \"../shared/domain_impl.rs\"]\nmod domain_impl;\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("my-core/src/shared")).unwrap();
std::fs::write(
dir.join("my-core/src/shared/domain_impl.rs"),
"pub fn run() {\n crate::io::read_file();\n}\n",
)
.unwrap();
std::fs::write(dir.join("my-core/src/io.rs"), "pub fn read_file() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-io",
"my-core",
"domain",
&["io"],
)],
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
assert!(
result
.findings
.iter()
.all(|f| f.rule != MODULE_BOUNDARY_VIOLATION_RULE),
"the relocated file resolves to module path `shared::domain_impl`, \
not `domain::domain_impl`, so it falls outside the rule's `from` scope \
and the real crate::io reference inside it is missed"
);
}
#[test]
fn module_boundary_rule_naming_an_unknown_crate_is_a_config_error() {
let dir = TempDir::new("module-boundary-unknown-crate");
write_crate(&dir, "my-core", &[]);
write_workspace_manifest(&dir, &["my-core"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-io",
"does-not-exist",
"domain",
&["io"],
)],
..Default::default()
};
let err = evaluate(&workspace, &config).unwrap_err();
assert!(matches!(err, BoundaryConfigError::UnknownCrate { .. }));
}
#[test]
fn module_boundary_rule_with_empty_forbidden_is_a_config_error() {
let dir = TempDir::new("module-boundary-empty-forbidden");
write_crate(&dir, "my-core", &[]);
write_workspace_manifest(&dir, &["my-core"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-io",
"my-core",
"domain",
&[],
)],
..Default::default()
};
let err = evaluate(&workspace, &config).unwrap_err();
assert!(matches!(err, BoundaryConfigError::InvalidModuleBoundary(_)));
}
#[test]
fn module_boundary_rule_with_transitive_reach_is_a_config_error() {
let dir = TempDir::new("module-boundary-transitive-reach");
write_crate(&dir, "my-core", &[]);
write_workspace_manifest(&dir, &["my-core"]);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let mut rule = module_boundary_rule("domain-no-io", "my-core", "domain", &["io"]);
rule.reach = Some(Reach::Transitive);
let config = BoundaryConfig {
module_boundaries: vec![rule],
..Default::default()
};
let err = evaluate(&workspace, &config).unwrap_err();
match err {
BoundaryConfigError::InvalidModuleBoundary(message) => {
assert!(message.contains("direct reach"));
}
other => panic!("expected InvalidModuleBoundary, got {other:?}"),
}
}
#[test]
fn module_boundary_matches_nested_modules_under_from_by_prefix() {
let dir = TempDir::new("module-boundary-nested");
write_crate(&dir, "my-core", &[]);
write_workspace_manifest(&dir, &["my-core"]);
std::fs::write(
dir.join("my-core/src/lib.rs"),
"pub mod domain;\npub mod io;\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("my-core/src/domain/inner")).unwrap();
std::fs::write(dir.join("my-core/src/domain/mod.rs"), "pub mod inner;\n").unwrap();
std::fs::write(
dir.join("my-core/src/domain/inner/thing.rs"),
"pub fn run() {\n crate::io::read_file();\n}\n",
)
.unwrap();
std::fs::write(dir.join("my-core/src/io.rs"), "pub fn read_file() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-io",
"my-core",
"domain",
&["io"],
)],
..Default::default()
};
let result = evaluate(&workspace, &config).unwrap();
let findings: Vec<_> = result
.findings
.iter()
.filter(|f| f.rule == MODULE_BOUNDARY_VIOLATION_RULE)
.collect();
assert_eq!(findings.len(), 1);
assert!(
findings[0]
.location
.file
.ends_with("src/domain/inner/thing.rs")
);
}
#[test]
fn module_path_for_file_resolves_directory_convention() {
let root = Path::new("/ws/my-core");
assert_eq!(
module_path_for_file(root, Path::new("/ws/my-core/src/lib.rs")),
Some(String::new())
);
assert_eq!(
module_path_for_file(root, Path::new("/ws/my-core/src/main.rs")),
Some(String::new())
);
assert_eq!(
module_path_for_file(root, Path::new("/ws/my-core/src/bin/tool.rs")),
Some(String::new())
);
assert_eq!(
module_path_for_file(root, Path::new("/ws/my-core/src/domain.rs")),
Some("domain".to_string())
);
assert_eq!(
module_path_for_file(root, Path::new("/ws/my-core/src/domain/mod.rs")),
Some("domain".to_string())
);
assert_eq!(
module_path_for_file(root, Path::new("/ws/my-core/src/domain/inner/thing.rs")),
Some("domain::inner::thing".to_string())
);
assert_eq!(
module_path_for_file(root, Path::new("/ws/my-core/build.rs")),
None
);
}
}