mod eval;
mod load;
use std::collections::BTreeSet;
use std::fmt::{Display, Formatter, Result as FmtResult};
use std::path::PathBuf;
use crate::graph::DependencyGraphOptions;
use crate::module_path::is_in_subtree;
pub(crate) use eval::evaluate;
pub(crate) use load::{resolve_config_path, scaffold_config};
#[derive(Debug, Clone, Default)]
pub struct CheckOptions {
pub config: Option<PathBuf>,
pub include_tests: bool,
pub show_apis: bool,
}
impl CheckOptions {
pub(crate) const fn graph_opts(&self) -> DependencyGraphOptions {
DependencyGraphOptions {
include_tests: self.include_tests,
depth: None,
show_apis: self.show_apis,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct InitOutcome {
pub path: PathBuf,
pub frozen_cycles: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct ModulePattern {
base: String,
subtree: bool,
}
impl ModulePattern {
pub(crate) fn parse(text: &str) -> Self {
if text == "*" {
return Self {
base: String::new(),
subtree: true,
};
}
text.strip_suffix("::*").map_or_else(
|| Self {
base: text.to_owned(),
subtree: false,
},
|base| Self {
base: base.to_owned(),
subtree: true,
},
)
}
pub(crate) fn parse_subtree(text: &str) -> Self {
let mut pattern = Self::parse(text);
pattern.subtree = true;
pattern
}
fn specificity(&self) -> usize {
if self.base.is_empty() {
0
} else {
self.base.split("::").count()
}
}
pub(crate) fn matches(&self, module: &str) -> bool {
if self.subtree {
is_in_subtree(module, &self.base)
} else {
module == self.base
}
}
fn references_known(&self, modules: &BTreeSet<String>) -> bool {
self.base.is_empty() || modules.iter().any(|m| self.matches(m))
}
fn display(&self) -> String {
if self.base.is_empty() {
"*".to_owned()
} else {
self.base.clone()
}
}
fn pattern_display(&self) -> String {
if self.base.is_empty() {
"*".to_owned()
} else if self.subtree {
format!("{}::*", self.base)
} else {
self.base.clone()
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct DenyRule {
pub(crate) from: ModulePattern,
pub(crate) to: ModulePattern,
}
impl DenyRule {
pub(crate) fn display(&self) -> String {
format!(
"deny {} -> {}",
self.from.pattern_display(),
self.to.pattern_display()
)
}
}
#[derive(Debug, Clone)]
pub(crate) struct RestrictRule {
pub(crate) from: ModulePattern,
pub(crate) to: Vec<ModulePattern>,
}
impl RestrictRule {
pub(crate) fn display(&self) -> String {
let targets = self
.to
.iter()
.map(ModulePattern::pattern_display)
.collect::<Vec<_>>()
.join(", ");
format!("restrict {} -> [{targets}]", self.from.pattern_display())
}
}
fn is_parent_child_cycle(modules: &BTreeSet<String>) -> bool {
modules
.iter()
.any(|root| modules.iter().all(|module| is_in_subtree(module, root)))
}
#[derive(Debug, Clone)]
pub(crate) struct AllowedCycle {
pub(crate) modules: BTreeSet<String>,
pub(crate) reason: Option<String>,
}
impl AllowedCycle {
pub(crate) fn display(&self) -> String {
let list = self
.modules
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ");
format!("allow-cycle [{list}]")
}
pub(crate) fn covers(&self, cycle: &BTreeSet<String>) -> bool {
cycle.is_subset(&self.modules)
}
}
#[derive(Debug, Clone)]
pub(crate) struct LayerRule {
pub(crate) name: String,
pub(crate) order: Vec<ModulePattern>,
pub(crate) deny_same_layer: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct LayerPos {
pub(crate) group: usize,
pub(crate) index: usize,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct RuleSet {
layers: Vec<LayerRule>,
deny: Vec<DenyRule>,
restrict: Vec<RestrictRule>,
allow_cycles: Vec<AllowedCycle>,
strict_layers: bool,
deny_cycles: bool,
deny_parent_child_cycles: bool,
}
impl RuleSet {
pub(crate) fn memberships(&self, module: &str) -> Vec<LayerPos> {
let mut positions = Vec::new();
for (group, layer) in self.layers.iter().enumerate() {
let index = layer
.order
.iter()
.enumerate()
.filter(|(_, pattern)| pattern.matches(module))
.map(|(index, pattern)| (pattern.specificity(), index))
.max_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)))
.map(|(_, index)| index);
if let Some(index) = index {
positions.push(LayerPos { group, index });
}
}
positions
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum ViolationKind {
Deny,
Restrict,
Layer,
Cycle,
}
impl Display for ViolationKind {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
let name = match self {
Self::Deny => "DENY",
Self::Restrict => "RESTRICT",
Self::Layer => "LAYER",
Self::Cycle => "CYCLE",
};
f.pad(name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Violation {
pub kind: ViolationKind,
pub source: String,
pub target: String,
pub rule: String,
pub apis: BTreeSet<String>,
}
#[derive(Debug, Clone, Default)]
pub struct CheckReport {
pub violations: Vec<Violation>,
}
impl CheckReport {
#[must_use]
pub const fn is_clean(&self) -> bool {
self.violations.is_empty()
}
#[must_use]
pub fn exit_code(&self) -> i32 {
i32::from(!self.violations.is_empty())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_exact_pattern_only() {
let pattern = ModulePattern::parse("format");
assert!(pattern.matches("format"));
assert!(!pattern.matches("format::use_cmd"));
}
#[test]
fn matches_subtree_covers_base_and_descendants() {
let pattern = ModulePattern::parse("format::*");
assert!(pattern.matches("format"));
assert!(pattern.matches("format::use_cmd"));
assert!(pattern.matches("format::use_cmd::inner"));
}
#[test]
fn matches_respects_segment_boundary() {
for pattern in [
ModulePattern::parse("format"),
ModulePattern::parse("format::*"),
ModulePattern::parse_subtree("format"),
] {
assert!(!pattern.matches("format_helper"));
}
}
#[test]
fn parse_subtree_upgrades_a_bare_name() {
let pattern = ModulePattern::parse_subtree("format");
assert!(pattern.matches("format"));
assert!(pattern.matches("format::use_cmd"));
}
fn allowed(modules: &[&str]) -> AllowedCycle {
AllowedCycle {
modules: modules.iter().map(ToString::to_string).collect(),
reason: None,
}
}
fn cycle_of(modules: &[&str]) -> BTreeSet<String> {
modules.iter().map(ToString::to_string).collect()
}
#[test]
fn allowed_cycle_covers_subset_and_exact_match() {
let entry = allowed(&["alpha", "beta", "gamma"]);
assert!(entry.covers(&cycle_of(&["alpha", "beta", "gamma"])));
assert!(entry.covers(&cycle_of(&["alpha", "beta"])));
}
#[test]
fn allowed_cycle_does_not_cover_a_grown_loop() {
let entry = allowed(&["alpha", "beta"]);
assert!(!entry.covers(&cycle_of(&["alpha", "beta", "gamma"])));
assert!(!entry.covers(&cycle_of(&["delta", "epsilon"])));
}
#[test]
fn allowed_cycle_display_lists_modules_alphabetically() {
let entry = allowed(&["gamma", "alpha"]);
assert_eq!(entry.display(), "allow-cycle [alpha, gamma]");
}
fn restrict(from: &str, to: &[&str]) -> RestrictRule {
RestrictRule {
from: ModulePattern::parse(from),
to: to
.iter()
.map(|target| ModulePattern::parse(target))
.collect(),
}
}
#[test]
fn restrict_display_cites_a_single_target() {
assert_eq!(
restrict("format::*", &["lib"]).display(),
"restrict format::* -> [lib]"
);
}
#[test]
fn restrict_display_cites_the_full_allowance() {
assert_eq!(
restrict("format::*", &["lib", "graph::*"]).display(),
"restrict format::* -> [lib, graph::*]"
);
}
#[test]
fn restrict_display_with_empty_allowance() {
assert_eq!(restrict("web::*", &[]).display(), "restrict web::* -> []");
}
#[test]
fn star_matches_every_module_including_the_root() {
for text in ["*", "::*"] {
let pattern = ModulePattern::parse(text);
assert!(pattern.matches(""));
assert!(pattern.matches("format"));
assert!(pattern.matches("format::use_cmd"));
}
}
}