use std::path::Path;
use globset::{Glob, GlobSet, GlobSetBuilder};
use crate::{
ArchavenError, Dependency, DependencyGraph, Location, ModulePath, PathPattern, Violation,
Violations,
};
pub trait RuleSet {
fn check(&self, graph: &DependencyGraph) -> Result<Violations, ArchavenError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Access {
from: String,
to: Vec<String>,
reason: Option<String>,
}
impl Access {
#[must_use]
pub fn from(pattern: impl Into<String>) -> Self {
Self {
from: pattern.into(),
to: Vec::new(),
reason: None,
}
}
#[must_use]
pub fn to(mut self, pattern: impl Into<String>) -> Self {
self.to.push(pattern.into());
self
}
#[must_use]
pub fn to_any<I, S>(mut self, patterns: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.to.extend(patterns.into_iter().map(Into::into));
self
}
#[must_use]
pub fn because(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
fn compile(&self, rule_name: &str) -> Result<CompiledAccess, ArchavenError> {
if self.to.is_empty() {
return Err(ArchavenError::invalid_rule(
rule_name,
"access rule must define at least one target pattern",
));
}
let from = PathPattern::parse(&self.from)?;
let to = self
.to
.iter()
.map(|pattern| PathPattern::parse(pattern))
.collect::<Result<Vec<_>, _>>()?;
Ok(CompiledAccess {
from,
to,
reason: self.reason.clone(),
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CompiledAccess {
from: PathPattern,
to: Vec<PathPattern>,
reason: Option<String>,
}
impl CompiledAccess {
fn matches(&self, source: &ModulePath, target: &ModulePath) -> bool {
self.from.matches(source) && self.to.iter().any(|pattern| pattern.matches(target))
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum Scope {
Global,
Between(String),
Within(String),
Directories(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum CompiledScope {
Global,
Between(PathPattern),
Within(PathPattern),
Directories(DirectoryPattern),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Rule {
name: String,
scope: Scope,
deny_all: bool,
allows: Vec<Access>,
denies: Vec<Access>,
ignored_files: Vec<String>,
ignore_module_roots: bool,
allow_only_module_roots: bool,
reason: Option<String>,
}
impl Rule {
#[must_use]
pub fn new() -> Self {
Self {
name: "dependency rule".to_owned(),
scope: Scope::Global,
deny_all: false,
allows: Vec::new(),
denies: Vec::new(),
ignored_files: Vec::new(),
ignore_module_roots: false,
allow_only_module_roots: false,
reason: None,
}
}
#[must_use]
pub fn between(scope: impl Into<String>) -> Self {
Self {
scope: Scope::Between(scope.into()),
..Self::new()
}
}
#[must_use]
pub fn within(scope: impl Into<String>) -> Self {
Self {
scope: Scope::Within(scope.into()),
..Self::new()
}
}
#[must_use]
pub fn directories(scope: impl Into<String>) -> Self {
Self {
name: "directory rule".to_owned(),
scope: Scope::Directories(scope.into()),
..Self::new()
}
}
#[must_use]
pub fn named(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
#[must_use]
pub fn deny_all(mut self) -> Self {
self.deny_all = true;
self
}
#[must_use]
pub fn allow(mut self, access: Access) -> Self {
self.allows.push(access);
self
}
#[must_use]
pub fn deny(mut self, access: Access) -> Self {
self.denies.push(access);
self
}
#[must_use]
pub fn ignore_files<I, S>(mut self, patterns: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.ignored_files.extend(
patterns
.into_iter()
.map(|pattern| pattern.as_ref().to_owned()),
);
self
}
#[must_use]
pub fn ignore_module_roots(mut self) -> Self {
self.ignore_module_roots = true;
self
}
#[must_use]
pub fn allow_only_module_roots(mut self) -> Self {
self.allow_only_module_roots = true;
self
}
#[must_use]
pub fn because(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
pub fn check(&self, graph: &DependencyGraph) -> Result<Violations, ArchavenError> {
Ok(self.compile()?.check(graph))
}
fn compile(&self) -> Result<CompiledRule, ArchavenError> {
let scope = match &self.scope {
Scope::Global => CompiledScope::Global,
Scope::Between(pattern) => CompiledScope::Between(PathPattern::parse(pattern)?),
Scope::Within(pattern) => CompiledScope::Within(PathPattern::parse(pattern)?),
Scope::Directories(pattern) => {
CompiledScope::Directories(DirectoryPattern::parse(&self.name, pattern)?)
}
};
if matches!(self.scope, Scope::Directories(_)) && !self.allow_only_module_roots {
return Err(ArchavenError::invalid_rule(
&self.name,
"directory rule must define a directory policy",
));
}
if !matches!(self.scope, Scope::Directories(_)) && self.allow_only_module_roots {
return Err(ArchavenError::invalid_rule(
&self.name,
"`allow_only_module_roots` can only be used with `Rule::directories`",
));
}
let allows = self
.allows
.iter()
.map(|access| access.compile(&self.name))
.collect::<Result<Vec<_>, _>>()?;
let denies = self
.denies
.iter()
.map(|access| access.compile(&self.name))
.collect::<Result<Vec<_>, _>>()?;
let ignored_files = compile_ignored_files(&self.ignored_files)?;
Ok(CompiledRule {
name: self.name.clone(),
scope,
deny_all: self.deny_all,
allows,
denies,
ignored_files,
ignore_module_roots: self.ignore_module_roots,
allow_only_module_roots: self.allow_only_module_roots,
reason: self.reason.clone(),
})
}
}
impl Default for Rule {
fn default() -> Self {
Self::new()
}
}
impl RuleSet for Rule {
fn check(&self, graph: &DependencyGraph) -> Result<Violations, ArchavenError> {
Ok(self.compile()?.check(graph))
}
}
struct CompiledRule {
name: String,
scope: CompiledScope,
deny_all: bool,
allows: Vec<CompiledAccess>,
denies: Vec<CompiledAccess>,
ignored_files: GlobSet,
ignore_module_roots: bool,
allow_only_module_roots: bool,
reason: Option<String>,
}
impl CompiledRule {
fn check(&self, graph: &DependencyGraph) -> Violations {
if matches!(self.scope, CompiledScope::Directories(_)) {
return self.check_directories(graph);
}
let mut violations = Violations::new();
for dependency in graph.dependencies() {
if self.ignores_dependency_file(graph, dependency.location().file()) {
continue;
}
if let Some(context) = self.context(dependency) {
if let Some(deny) = self
.denies
.iter()
.find(|access| access.matches(&context.source, &context.target))
{
violations.push(Violation::new(
&self.name,
self.reason_for_explicit_deny(deny),
dependency,
));
continue;
}
if self.deny_all
&& !self
.allows
.iter()
.any(|access| access.matches(&context.source, &context.target))
{
violations.push(Violation::new(
&self.name,
self.reason_for_default_deny(),
dependency,
));
}
}
}
violations
}
fn check_directories(&self, graph: &DependencyGraph) -> Violations {
let mut violations = Violations::new();
let CompiledScope::Directories(pattern) = &self.scope else {
return violations;
};
if !self.allow_only_module_roots {
return violations;
}
for directory in graph
.directories()
.iter()
.filter(|directory| pattern.matches(directory.module()))
{
for file in directory.files() {
if is_module_root_file(file, directory.child_directories()) {
continue;
}
let module = module_for_directory_file(directory.module(), file);
violations.push(Violation::for_file(
&self.name,
self.reason.clone().unwrap_or_else(|| {
"only module root files are allowed in this directory".to_owned()
}),
module,
Location::new(directory.path().join(file)),
));
}
}
violations
}
fn ignores_dependency_file(&self, graph: &DependencyGraph, file: &Path) -> bool {
self.ignores_file(file) || (self.ignore_module_roots && is_module_root_path(graph, file))
}
fn ignores_file(&self, file: &Path) -> bool {
let normalized = file.to_string_lossy().replace('\\', "/");
if self.ignored_files.is_match(normalized.as_str()) {
return true;
}
let trimmed = normalized.trim_start_matches('/');
let segments = trimmed.split('/').collect::<Vec<_>>();
(1..segments.len()).any(|start| {
let suffix = segments[start..].join("/");
self.ignored_files.is_match(suffix.as_str())
})
}
fn context(&self, dependency: &Dependency) -> Option<EvalContext> {
match &self.scope {
CompiledScope::Global => Some(EvalContext {
source: dependency.source().clone(),
target: dependency.target().clone(),
}),
CompiledScope::Between(pattern) => {
let source = pattern.match_prefix(dependency.source())?;
let target = pattern.match_prefix(dependency.target())?;
(source.matched() != target.matched()).then(|| EvalContext {
source: source.remainder().clone(),
target: target.remainder().clone(),
})
}
CompiledScope::Within(pattern) => {
let source = pattern.match_prefix(dependency.source())?;
let target = pattern.match_prefix(dependency.target())?;
(source.matched() == target.matched()).then(|| EvalContext {
source: source.remainder().clone(),
target: target.remainder().clone(),
})
}
CompiledScope::Directories(_) => None,
}
}
fn reason_for_explicit_deny(&self, deny: &CompiledAccess) -> String {
deny.reason
.clone()
.or_else(|| self.reason.clone())
.unwrap_or_else(|| "dependency is denied by this rule".to_owned())
}
fn reason_for_default_deny(&self) -> String {
if let Some(reason) = &self.reason {
return reason.clone();
}
let reasons = self
.allows
.iter()
.filter_map(|access| access.reason.as_deref())
.collect::<Vec<_>>();
if reasons.is_empty() {
"dependency is not allowed by this rule".to_owned()
} else {
format!(
"dependency is not allowed by this rule; allowed access: {}",
reasons.join("; ")
)
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct DirectoryPattern {
pattern: PathPattern,
}
impl DirectoryPattern {
fn parse(rule_name: &str, pattern: &str) -> Result<Self, ArchavenError> {
if pattern
.split("::")
.map(str::trim)
.any(|segment| segment == "**")
{
return Err(ArchavenError::invalid_rule(
rule_name,
"directory rules do not support `**`",
));
}
let star_count = pattern
.split("::")
.map(str::trim)
.filter(|segment| *segment == "*")
.count();
if star_count == 0 {
return Err(ArchavenError::invalid_rule(
rule_name,
"directory rules support at least one `*` segment",
));
}
Ok(Self {
pattern: PathPattern::parse(pattern)?,
})
}
fn matches(&self, path: &ModulePath) -> bool {
self.pattern.matches(path)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct EvalContext {
source: ModulePath,
target: ModulePath,
}
fn compile_ignored_files(patterns: &[String]) -> Result<GlobSet, ArchavenError> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern)
.map_err(|source| ArchavenError::invalid_pattern(pattern, source.to_string()))?;
builder.add(glob);
}
builder
.build()
.map_err(|source| ArchavenError::invalid_pattern(patterns.join(", "), source.to_string()))
}
fn is_module_root_path(graph: &DependencyGraph, file: &Path) -> bool {
graph.directories().iter().any(|directory| {
file.parent()
.is_some_and(|parent| parent == directory.path())
&& file.file_name().is_some_and(|name| {
is_module_root_file(&name.to_string_lossy(), directory.child_directories())
})
})
}
fn is_module_root_file(
file_name: &str,
child_directories: &std::collections::BTreeSet<String>,
) -> bool {
matches!(file_name, "mod.rs" | "lib.rs")
|| file_name
.strip_suffix(".rs")
.is_some_and(|stem| child_directories.contains(stem))
}
fn module_for_directory_file(directory: &ModulePath, file_name: &str) -> ModulePath {
let Some(stem) = file_name.strip_suffix(".rs") else {
return directory.clone();
};
let mut segments = directory.segments().to_vec();
match stem {
"mod" | "lib" => {}
other => segments.push(other.to_owned()),
}
ModulePath::from_segments(segments)
}