use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
use super::manifest::{AUTO_API_ID_PREFIX, ArchitectureGraph, GraphNode};
use super::{Access, NodeKind, Provenance};
pub const CONFIG_FILE: &str = "boundaries.toml";
pub const BASELINE_VERSION: u32 = 1;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BoundaryConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baseline: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<String>,
#[serde(default)]
pub boundaries: BTreeMap<String, Boundary>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Boundary {
#[serde(default)]
pub owns: Vec<String>,
#[serde(default)]
pub crates: Vec<String>,
#[serde(default)]
pub modules: Vec<String>,
#[serde(default)]
pub may_read: Vec<String>,
#[serde(default)]
pub may_write: Vec<String>,
#[serde(default)]
pub schema_crates: Vec<String>,
}
impl BoundaryConfig {
pub fn parse(text: &str) -> Result<Self, BoundaryError> {
let config: Self = toml::from_str(text).map_err(|e| BoundaryError::Parse(e.to_string()))?;
for (name, boundary) in &config.boundaries {
if boundary.owns.is_empty() && boundary.crates.is_empty() && boundary.modules.is_empty()
{
return Err(BoundaryError::Empty(name.clone()));
}
}
Ok(config)
}
pub fn add_plugin(&mut self, krate: &str, owns: &[String]) {
let key = crate_key(krate);
let tables: Vec<String> = owns
.iter()
.filter(|t| {
let t = t.to_ascii_lowercase();
!self
.boundaries
.values()
.flat_map(|b| &b.owns)
.any(|p| glob(&p.to_ascii_lowercase(), &t))
})
.cloned()
.collect();
let listed = self
.boundaries
.iter()
.flat_map(|(name, b)| b.crates.iter().map(move |p| (name, p)))
.filter(|(_, p)| glob(&crate_key(p), &key))
.max_by_key(|(_, p)| specificity(p))
.map(|(name, _)| name.clone());
let name = listed.clone().unwrap_or_else(|| krate.to_owned());
let boundary = self.boundaries.entry(name).or_default();
for table in tables {
if !boundary.owns.contains(&table) {
boundary.owns.push(table);
}
}
if listed.is_none() && !boundary.crates.iter().any(|c| c == krate) {
boundary.crates.push(krate.to_owned());
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BoundaryError {
#[error("boundaries.toml is not valid: {0}")]
Parse(String),
#[error("boundary `{0}` declares no `owns`, `crates` or `modules`")]
Empty(String),
#[error(
"{what} `{name}` matches boundary `{first}` and boundary `{second}` equally; \
make one pattern more specific"
)]
Ambiguous {
what: &'static str,
name: String,
first: String,
second: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CrateGraph {
pub deps: BTreeMap<String, BTreeSet<String>>,
pub libs: BTreeMap<String, String>,
}
impl CrateGraph {
pub fn add(&mut self, krate: &str, dep: &str) {
self.deps
.entry(krate.to_owned())
.or_default()
.insert(dep.to_owned());
self.deps.entry(dep.to_owned()).or_default();
}
fn names(&self, package: &str) -> Vec<String> {
let mut names = vec![crate_key(package_name(package))];
if let Some(lib) = self.libs.get(package) {
names.push(crate_key(lib));
}
names
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableViolation {
pub boundary: String,
pub owner: String,
pub node: String,
pub label: String,
pub kind: NodeKind,
pub table: String,
pub access: Access,
pub provenance: Provenance,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub symbol: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub via: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrateViolation {
pub boundary: String,
pub owner: String,
#[serde(rename = "crate")]
pub krate: String,
pub schema_crate: String,
pub path: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "rule", rename_all = "snake_case")]
pub enum Violation {
Table(TableViolation),
Crate(CrateViolation),
}
impl Violation {
#[must_use]
pub fn key(&self) -> BaselineEntry {
match self {
Self::Table(v) => BaselineEntry::Table {
boundary: v.boundary.clone(),
owner: v.owner.clone(),
node: base_id(&v.node).to_owned(),
table: v.table.clone(),
access: v.access,
},
Self::Crate(v) => BaselineEntry::Crate {
boundary: v.boundary.clone(),
owner: v.owner.clone(),
krate: package_name(&v.krate).to_owned(),
schema_crate: package_name(&v.schema_crate).to_owned(),
},
}
}
#[must_use]
pub fn render(&self) -> String {
match self {
Self::Table(v) => {
let verb = match v.access {
Access::Read => "reads",
Access::Write => "writes",
Access::ReadWrite => "reads and writes",
};
let mut text = format!(
"{} -> {}: {} {} {verb} table `{}`\n node: {}\n edge: {}",
v.boundary, v.owner, v.kind, v.label, v.table, v.node, v.provenance
);
if !v.symbol.is_empty() {
let _ = write!(text, ", symbol `{}`", v.symbol);
}
if let Some(via) = &v.via {
let _ = write!(text, ", via {via}");
}
text
}
Self::Crate(v) => format!(
"{} -> {}: crate {} depends on {}, which holds the schema of `{}`\n path: {}",
v.boundary,
v.owner,
v.krate,
v.schema_crate,
v.owner,
v.path.join(" -> ")
),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RulesRun {
pub graph: bool,
pub crates: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Report {
pub violations: Vec<Violation>,
pub warnings: Vec<String>,
pub rules: RulesRun,
}
pub fn check(
config: &BoundaryConfig,
graph: Option<&ArchitectureGraph>,
crates: Option<&CrateGraph>,
) -> Result<Report, BoundaryError> {
let resolver = Resolver::new(config, graph, crates);
let mut found = Found::default();
let mut warnings = Vec::new();
let mut rules = RulesRun::default();
if let Some(graph) = graph {
graph_rule(&resolver, graph, &mut found)?;
rules.graph = true;
}
if let Some(crates) = crates {
rules.crates = crate_rule(&resolver, graph, crates, &mut found, &mut warnings)?;
}
unmatched_patterns(&resolver, graph, crates, &mut warnings);
let mut violations: BTreeMap<BaselineEntry, Violation> = BTreeMap::new();
for v in found.tables.into_values() {
let v = Violation::Table(v);
violations.insert(v.key(), v);
}
for v in found.crates {
violations.entry(v.key()).or_insert(v);
}
Ok(Report {
violations: violations.into_values().collect(),
warnings,
rules,
})
}
#[derive(Default)]
struct Found {
tables: BTreeMap<(String, String, String, String), TableViolation>,
crates: Vec<Violation>,
}
impl Found {
fn add_table(&mut self, v: TableViolation) {
let key = (
v.boundary.clone(),
v.owner.clone(),
base_id(&v.node).to_owned(),
v.table.clone(),
);
match self.tables.get_mut(&key) {
None => {
self.tables.insert(key, v);
}
Some(old) => {
let access = stronger(old.access, v.access);
if old.via.is_some() && v.via.is_none() {
*old = v;
}
old.access = access;
}
}
}
}
const fn stronger(a: Access, b: Access) -> Access {
match (a, b) {
(Access::ReadWrite, _)
| (_, Access::ReadWrite)
| (Access::Read, Access::Write)
| (Access::Write, Access::Read) => Access::ReadWrite,
(Access::Write, Access::Write) => Access::Write,
(Access::Read, Access::Read) => Access::Read,
}
}
fn graph_rule(
r: &Resolver<'_>,
graph: &ArchitectureGraph,
found: &mut Found,
) -> Result<(), BoundaryError> {
for node in &graph.nodes {
if let Some(model) = &node.model {
r.owner(&model.table)?;
}
if let Some(repository) = &node.repository {
r.owner(&repository.table)?;
}
r.node_boundary(node)?;
}
let nodes: BTreeMap<&str, &GraphNode> =
graph.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let mut report = |from: &GraphNode, boundary: &str, table: String, how: Edge| {
let Ok(Some(owner)) = r.owner(&table) else {
return;
};
if owner == boundary || r.allows(boundary, &table, how.access) {
return;
}
found.add_table(TableViolation {
boundary: boundary.to_owned(),
owner: owner.to_owned(),
node: from.id.clone(),
label: from.label(),
kind: from.kind,
table,
access: how.access,
provenance: how.provenance,
symbol: how.symbol,
via: how.via,
});
};
for node in &graph.nodes {
let (Some(repository), Some(boundary)) = (&node.repository, r.node_boundary(node)?) else {
continue;
};
let how = Edge {
access: Access::ReadWrite,
provenance: Provenance::Declaration,
symbol: String::new(),
via: None,
};
report(node, boundary, repository.table.clone(), how);
}
for edge in &graph.edges {
let Some(from) = nodes.get(edge.from.as_str()) else {
continue;
};
let Some(boundary) = r.node_boundary(from)? else {
continue;
};
for (table, via) in reached_tables(graph, &nodes, &edge.to) {
let how = Edge {
access: edge.access,
provenance: edge.provenance,
symbol: edge.symbol.clone(),
via,
};
report(from, boundary, table, how);
}
}
Ok(())
}
struct Edge {
access: Access,
provenance: Provenance,
symbol: String,
via: Option<String>,
}
fn reached_tables(
graph: &ArchitectureGraph,
nodes: &BTreeMap<&str, &GraphNode>,
target: &str,
) -> Vec<(String, Option<String>)> {
let Some(node) = nodes.get(target) else {
return Vec::new();
};
if let Some(model) = &node.model {
return vec![(model.table.clone(), None)];
}
let Some(repository) = &node.repository else {
return Vec::new();
};
let mut tables: BTreeSet<String> = graph
.edges
.iter()
.filter(|e| e.from == node.id)
.filter_map(|e| nodes.get(e.to.as_str()))
.filter_map(|n| n.model.as_ref().map(|m| m.table.clone()))
.collect();
tables.insert(repository.table.clone());
tables
.into_iter()
.map(|t| (t, Some(node.id.clone())))
.collect()
}
fn crate_rule(
r: &Resolver<'_>,
graph: Option<&ArchitectureGraph>,
crates: &CrateGraph,
found: &mut Found,
warnings: &mut Vec<String>,
) -> Result<bool, BoundaryError> {
let mut complete = true;
if graph.is_none() {
for (name, b) in &r.config.boundaries {
if !b.owns.is_empty() && b.schema_crates.is_empty() {
complete = false;
warnings.push(format!(
"boundary `{name}`: with no graph, its schema crates are unknown; set \
`schema_crates` or check a graph"
));
}
}
}
let schema = schema_owners(r, graph, crates)?;
for start in crates.deps.keys() {
let Some(boundary) = r.crate_boundary(start, crates)? else {
continue;
};
let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
let mut queue = VecDeque::from([start.as_str()]);
while let Some(current) = queue.pop_front() {
for dep in crates.deps.get(current).into_iter().flatten() {
if dep == start || parent.contains_key(dep.as_str()) {
continue;
}
parent.insert(dep, current);
let dep_boundary = r.crate_boundary(dep, crates)?;
for owner in schema.get(dep.as_str()).into_iter().flatten() {
if *owner == boundary
|| dep_boundary == Some(boundary)
|| r.allows_schema_of(boundary, owner)
{
continue;
}
let mut path = vec![dep.clone()];
let mut at = dep.as_str();
while let Some(prev) = parent.get(at) {
path.push((*prev).to_owned());
at = prev;
}
path.reverse();
found.crates.push(Violation::Crate(CrateViolation {
boundary: boundary.to_owned(),
owner: (*owner).to_owned(),
krate: start.clone(),
schema_crate: dep.clone(),
path,
}));
}
if dep_boundary.is_none() && !r.in_modules_boundary(dep, crates) {
queue.push_back(dep);
}
}
}
}
Ok(complete)
}
fn schema_owners<'a>(
r: &Resolver<'a>,
graph: Option<&ArchitectureGraph>,
crates: &CrateGraph,
) -> Result<BTreeMap<String, BTreeSet<&'a str>>, BoundaryError> {
let mut owners: BTreeMap<String, BTreeSet<&'a str>> = BTreeMap::new();
for (name, boundary) in &r.config.boundaries {
for package in crates.deps.keys() {
if boundary.schema_crates.iter().any(|p| {
let p = crate_key(p);
crates.names(package).iter().any(|n| glob(&p, n))
}) {
owners
.entry(package.clone())
.or_default()
.insert(name.as_str());
}
}
}
let mut by_crate: BTreeMap<String, BTreeSet<&'a str>> = BTreeMap::new();
for node in graph.iter().flat_map(|g| &g.nodes) {
let Some(model) = &node.model else {
continue;
};
let Some(owner) = r.owner(&model.table)? else {
continue;
};
if r.config.boundaries[owner].schema_crates.is_empty() {
by_crate
.entry(crate_key(crate_root(&node.module)))
.or_default()
.insert(owner);
}
}
for package in crates.deps.keys() {
for name in crates.names(package) {
if let Some(set) = by_crate.get(&name) {
owners.entry(package.clone()).or_default().extend(set);
}
}
}
Ok(owners)
}
fn unmatched_patterns(
r: &Resolver<'_>,
graph: Option<&ArchitectureGraph>,
crates: Option<&CrateGraph>,
warnings: &mut Vec<String>,
) {
let mut crate_names: BTreeSet<String> = graph
.iter()
.flat_map(|g| &g.nodes)
.map(|n| crate_key(crate_root(&n.module)))
.collect();
if let Some(c) = crates {
crate_names.extend(c.deps.keys().flat_map(|k| c.names(k)));
}
for (name, b) in &r.config.boundaries {
let mut warn = |list: &str, pattern: &str, what: &str| {
warnings.push(format!(
"boundary `{name}`: `{list}` pattern `{pattern}` matches no {what}"
));
};
if let Some(graph) = graph {
for p in &b.owns {
let p_lower = p.to_ascii_lowercase();
if !r.tables.iter().any(|t| glob(&p_lower, t)) {
warn("owns", p, "table of a #[model] in the graph");
}
}
for p in &b.modules {
if !graph.nodes.iter().any(|n| module_matches(p, &n.module)) {
warn("modules", p, "node in the graph");
}
}
}
if graph.is_some() || crates.is_some() {
for p in &b.crates {
let key = crate_key(p);
if !crate_names.iter().any(|c| glob(&key, c)) {
warn("crates", p, "crate");
}
}
}
if let Some(crates) = crates {
for p in &b.schema_crates {
let key = crate_key(p);
let hit = crates
.deps
.keys()
.any(|c| crates.names(c).iter().any(|n| glob(&key, n)));
if !hit {
warn("schema_crates", p, "crate");
}
}
}
for (list, entries) in [("may_read", &b.may_read), ("may_write", &b.may_write)] {
for t in entries {
if r.exception_owners(t).is_empty() {
warn(list, t, "table that a boundary owns");
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(tag = "rule", rename_all = "snake_case", deny_unknown_fields)]
pub enum BaselineEntry {
Table {
boundary: String,
owner: String,
node: String,
table: String,
access: Access,
},
Crate {
boundary: String,
owner: String,
#[serde(rename = "crate")]
krate: String,
schema_crate: String,
},
}
impl std::fmt::Display for BaselineEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Table {
boundary,
owner,
node,
table,
access,
} => write!(f, "{boundary} -> {owner}.{table} ({access}, {node})"),
Self::Crate {
boundary,
owner,
krate,
schema_crate,
} => write!(
f,
"{boundary} -> {owner} (crate {krate} depends on {schema_crate})"
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Baseline {
pub version: u32,
pub violations: Vec<BaselineEntry>,
}
impl Baseline {
#[must_use]
pub fn from_violations(violations: &[Violation]) -> Self {
let keys: BTreeSet<BaselineEntry> = violations.iter().map(Violation::key).collect();
Self {
version: BASELINE_VERSION,
violations: keys.into_iter().collect(),
}
}
pub fn parse(text: &str) -> Result<Self, String> {
let baseline: Self = serde_json::from_str(text).map_err(|e| e.to_string())?;
if baseline.version != BASELINE_VERSION {
return Err(format!(
"baseline version {} is not supported (expected {BASELINE_VERSION})",
baseline.version
));
}
Ok(baseline)
}
#[must_use]
pub fn to_json(&self) -> String {
let mut json = serde_json::to_string_pretty(self)
.unwrap_or_else(|e| panic!("a baseline is always serializable: {e}"));
json.push('\n');
json
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ratchet {
pub new: Vec<Violation>,
pub known: Vec<Violation>,
pub fixed: Vec<BaselineEntry>,
pub warnings: Vec<String>,
}
impl Ratchet {
#[must_use]
pub const fn passes(&self) -> bool {
self.new.is_empty() && self.fixed.is_empty()
}
#[must_use]
pub fn render(&self) -> String {
let mut out = String::new();
for warning in &self.warnings {
let _ = writeln!(out, "warning: {warning}");
}
if !self.new.is_empty() {
let _ = writeln!(
out,
"\u{2717} {} new boundary violation(s):",
self.new.len()
);
for v in &self.new {
let _ = writeln!(out, " {}", v.render().replace('\n', "\n "));
}
}
if !self.fixed.is_empty() {
let _ = writeln!(
out,
"\u{2717} {} baseline entry(s) no longer occur. Run with --update-baseline \
to remove them:",
self.fixed.len()
);
for entry in &self.fixed {
let _ = writeln!(out, " - {entry}");
}
}
if !self.known.is_empty() {
let _ = writeln!(
out,
"{} known violation(s) in the baseline.",
self.known.len()
);
}
if self.passes() {
let _ = writeln!(out, "\u{2713} No new boundary violations.");
}
out
}
}
#[must_use]
pub fn ratchet(report: Report, baseline: Option<&Baseline>) -> Ratchet {
let accepted: BTreeSet<&BaselineEntry> = baseline.iter().flat_map(|b| &b.violations).collect();
let current: BTreeSet<BaselineEntry> = report.violations.iter().map(Violation::key).collect();
let ran = |entry: &BaselineEntry| match entry {
BaselineEntry::Table { .. } => report.rules.graph,
BaselineEntry::Crate { .. } => report.rules.crates,
};
let fixed = accepted
.iter()
.filter(|e| ran(e) && !current.contains(**e))
.map(|e| (*e).clone())
.collect();
let (known, new) = report
.violations
.into_iter()
.partition(|v| accepted.contains(&v.key()));
Ratchet {
new,
known,
fixed,
warnings: report.warnings,
}
}
struct Resolver<'a> {
config: &'a BoundaryConfig,
tables: BTreeSet<String>,
aliases: BTreeMap<String, Vec<String>>,
}
impl<'a> Resolver<'a> {
fn new(
config: &'a BoundaryConfig,
graph: Option<&ArchitectureGraph>,
crates: Option<&CrateGraph>,
) -> Self {
let tables = graph
.iter()
.flat_map(|g| &g.nodes)
.filter_map(|n| n.model.as_ref().map(|m| m.table.to_ascii_lowercase()))
.collect();
let mut aliases: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (package, lib) in crates.iter().flat_map(|c| &c.libs) {
aliases
.entry(crate_key(lib))
.or_default()
.push(crate_key(package_name(package)));
}
Self {
config,
tables,
aliases,
}
}
fn owner(&self, table: &str) -> Result<Option<&'a str>, BoundaryError> {
let table = table.to_ascii_lowercase();
let mut candidates = Vec::new();
for (name, b) in &self.config.boundaries {
for p in &b.owns {
if glob(&p.to_ascii_lowercase(), &table) {
candidates.push((name.as_str(), specificity(p)));
}
}
}
pick("table", &table, &candidates)
}
fn node_boundary(&self, node: &GraphNode) -> Result<Option<&'a str>, BoundaryError> {
let root = crate_key(crate_root(&node.module));
let mut names = vec![root.clone()];
names.extend(self.aliases.get(&root).into_iter().flatten().cloned());
let mut candidates = Vec::new();
for (name, b) in &self.config.boundaries {
for p in &b.crates {
let p_key = crate_key(p);
if names.iter().any(|n| glob(&p_key, n)) {
candidates.push((name.as_str(), specificity(p)));
}
}
for p in &b.modules {
if module_matches(p, &node.module) {
candidates.push((name.as_str(), specificity(p)));
}
}
}
pick("node", &node.id, &candidates)
}
fn crate_boundary(
&self,
package: &str,
crates: &CrateGraph,
) -> Result<Option<&'a str>, BoundaryError> {
let names = crates.names(package);
let mut candidates = Vec::new();
for (name, b) in &self.config.boundaries {
for p in &b.crates {
let p_key = crate_key(p);
if names.iter().any(|n| glob(&p_key, n)) {
candidates.push((name.as_str(), specificity(p)));
}
}
}
pick("crate", package, &candidates)
}
fn in_modules_boundary(&self, package: &str, crates: &CrateGraph) -> bool {
let names = crates.names(package);
self.config
.boundaries
.values()
.flat_map(|b| &b.modules)
.any(|p| names.iter().any(|n| module_matches(p, n)))
}
fn allows(&self, boundary: &str, table: &str, access: Access) -> bool {
let Some(b) = self.config.boundaries.get(boundary) else {
return false;
};
let table = table.to_ascii_lowercase();
let hit = |list: &[String]| list.iter().any(|p| glob(&p.to_ascii_lowercase(), &table));
hit(&b.may_write) || (access == Access::Read && hit(&b.may_read))
}
fn exception_owners(&self, pattern: &str) -> BTreeSet<&'a str> {
let lower = pattern.to_ascii_lowercase();
self.tables
.iter()
.filter(|t| glob(&lower, t))
.map(String::as_str)
.chain(std::iter::once(lower.as_str()))
.filter_map(|t| self.owner(t).ok().flatten())
.collect()
}
fn allows_schema_of(&self, boundary: &str, owner: &str) -> bool {
let Some(b) = self.config.boundaries.get(boundary) else {
return false;
};
b.may_read
.iter()
.chain(&b.may_write)
.any(|t| self.exception_owners(t).contains(owner))
}
}
fn pick<'a>(
what: &'static str,
name: &str,
candidates: &[(&'a str, usize)],
) -> Result<Option<&'a str>, BoundaryError> {
let Some(top) = candidates.iter().map(|c| c.1).max() else {
return Ok(None);
};
let winners: BTreeSet<&str> = candidates
.iter()
.filter(|c| c.1 == top)
.map(|c| c.0)
.collect();
let mut winners = winners.into_iter();
match (winners.next(), winners.next()) {
(Some(first), Some(second)) => Err(BoundaryError::Ambiguous {
what,
name: name.to_owned(),
first: first.to_owned(),
second: second.to_owned(),
}),
(first, _) => Ok(first),
}
}
fn specificity(pattern: &str) -> usize {
pattern.chars().filter(|c| *c != '*').count()
}
fn module_matches(pattern: &str, module: &str) -> bool {
glob(pattern, module) || glob(&format!("{pattern}::*"), module)
}
fn crate_root(module: &str) -> &str {
module.split_once("::").map_or(module, |(root, _)| root)
}
fn crate_key(name: &str) -> String {
name.replace('-', "_")
}
fn package_name(key: &str) -> &str {
key.split_once('@').map_or(key, |(name, _)| name)
}
fn base_id(id: &str) -> &str {
if id.starts_with(AUTO_API_ID_PREFIX) {
return id;
}
id.split_once('@').map_or(id, |(base, _)| base)
}
fn glob(pattern: &str, text: &str) -> bool {
let mut parts = pattern.split('*');
let first = parts.next().unwrap_or_default();
let Some(mut rest) = text.strip_prefix(first) else {
return false;
};
let middle: Vec<&str> = parts.collect();
let Some((last, middle)) = middle.split_last() else {
return rest.is_empty();
};
for part in middle {
let Some(at) = rest.find(part) else {
return false;
};
rest = &rest[at + part.len()..];
}
rest.ends_with(last)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::manifest::build;
use crate::graph::{ModelGraphDescriptor, RepositoryGraphDescriptor, RouteGraphDescriptor};
const fn model(
name: &'static str,
table: &'static str,
module_path: &'static str,
) -> ModelGraphDescriptor {
ModelGraphDescriptor {
model: name,
model_path: name,
table,
relations: &[],
module_path,
file: "src/models.rs",
line: 1,
crate_version: "0.1.0",
}
}
const fn route(
handler: &'static str,
module_path: &'static str,
method: &'static str,
path: &'static str,
) -> RouteGraphDescriptor {
RouteGraphDescriptor {
handler,
module_path,
method,
path,
static_route: false,
file: "src/routes.rs",
line: 1,
crate_version: "0.1.0",
signature_symbols: &[],
body_symbols: &[],
sql_symbols: &[],
}
}
const MODELS: &[ModelGraphDescriptor] = &[
model("Contact", "contacts", "crm_core::models"),
model("Campaign", "campaigns", "crm_marketing::models"),
model("Touch", "attribution_touches", "crm_marketing::models"),
];
const REPOSITORIES: &[RepositoryGraphDescriptor] = &[RepositoryGraphDescriptor {
repository: "ContactRepository",
implementation: "PgContactRepository",
model: "Contact",
table: "contacts",
api: "",
module_path: "crm_core::repositories",
file: "src/repositories.rs",
line: 1,
crate_version: "0.1.0",
}];
const ROUTES: &[RouteGraphDescriptor] = &[
RouteGraphDescriptor {
body_symbols: &["contacts"],
..route("list", "crm_core::routes", "GET", "/contacts")
},
RouteGraphDescriptor {
body_symbols: &["Campaign", "contacts"],
..route("report", "crm_marketing::routes", "GET", "/report")
},
RouteGraphDescriptor {
body_symbols: &["CONTACTS", "c", "campaigns", "contact_id", "ct", "id"],
sql_symbols: &["CONTACTS", "c", "campaigns", "contact_id", "ct", "id"],
..route("joined", "crm_marketing::routes", "GET", "/joined")
},
RouteGraphDescriptor {
signature_symbols: &["PgContactRepository"],
..route("sync", "crm_marketing::routes", "POST", "/sync")
},
RouteGraphDescriptor {
body_symbols: &["contacts", "campaigns"],
..route("home", "crm_app::routes", "GET", "/")
},
];
fn graph() -> ArchitectureGraph {
build(&[], 0, ROUTES, MODELS, REPOSITORIES, &[])
}
const CONFIG: &str = r#"
[boundaries.core]
owns = ["contacts"]
crates = ["crm-core*"]
[boundaries.marketing]
owns = ["campaigns", "attribution_*"]
crates = ["crm-marketing"]
"#;
fn config(extra: &str) -> BoundaryConfig {
let mut text = CONFIG.to_owned();
text.push_str(extra);
BoundaryConfig::parse(&text).expect("valid config")
}
fn tables(report: &Report) -> Vec<&TableViolation> {
report
.violations
.iter()
.filter_map(|v| match v {
Violation::Table(t) => Some(t),
Violation::Crate(_) => None,
})
.collect()
}
fn crate_violations(report: &Report) -> Vec<&CrateViolation> {
report
.violations
.iter()
.filter_map(|v| match v {
Violation::Crate(c) => Some(c),
Violation::Table(_) => None,
})
.collect()
}
fn table_violation<'a>(report: &'a Report, handler: &str) -> Option<&'a TableViolation> {
tables(report)
.into_iter()
.find(|v| v.node.ends_with(&format!("::{handler}")))
}
#[test]
fn the_config_parses_every_list() {
let config = config(
r#"
[boundaries.billing]
owns = ["invoices"]
modules = ["app::billing"]
may_read = ["contacts"]
may_write = ["campaigns"]
schema_crates = ["billing-schema"]
"#,
);
let billing = &config.boundaries["billing"];
assert_eq!(billing.modules, ["app::billing"]);
assert_eq!(billing.may_read, ["contacts"]);
assert_eq!(billing.may_write, ["campaigns"]);
assert_eq!(billing.schema_crates, ["billing-schema"]);
}
#[test]
fn an_unknown_key_is_rejected() {
let err = BoundaryConfig::parse("[boundaries.core]\nowns = [\"a\"]\nmay_reed = []\n")
.expect_err("a typo must not pass");
assert!(
matches!(err, BoundaryError::Parse(ref m) if m.contains("may_reed")),
"{err}"
);
}
#[test]
fn an_empty_boundary_is_rejected() {
let err = BoundaryConfig::parse("[boundaries.core]\nmay_read = [\"a\"]\n")
.expect_err("a boundary with no members must not pass");
assert_eq!(err, BoundaryError::Empty("core".into()));
}
#[test]
fn a_plugin_joins_the_boundary_that_lists_its_crate() {
let mut config = BoundaryConfig::parse("[boundaries.admin]\ncrates = [\"admin-plugin\"]\n")
.expect("valid");
config.add_plugin("admin-plugin", &["admin_log".to_owned()]);
assert_eq!(config.boundaries.len(), 1);
assert_eq!(config.boundaries["admin"].owns, ["admin_log"]);
}
#[test]
fn a_plugin_table_the_config_owns_stays_with_the_config() {
let mut config =
BoundaryConfig::parse("[boundaries.admin]\nowns = [\"admin_*\"]\n").expect("valid");
config.add_plugin("admin-plugin", &["admin_log".to_owned()]);
let plugin = &config.boundaries["admin-plugin"];
assert!(plugin.owns.is_empty(), "{plugin:?}");
assert!(check(&config, Some(&graph()), None).is_ok());
}
#[test]
fn a_plugin_adds_a_boundary_of_its_own() {
let mut config = config("");
config.add_plugin("autumn-admin-plugin", &["admin_audit_log".to_owned()]);
let plugin = &config.boundaries["autumn-admin-plugin"];
assert_eq!(plugin.owns, ["admin_audit_log"]);
assert_eq!(plugin.crates, ["autumn-admin-plugin"]);
}
#[test]
fn a_route_that_reads_another_boundarys_table_is_a_violation() {
let report = check(&config(""), Some(&graph()), None).expect("check runs");
let v = table_violation(&report, "report").expect("report reads contacts");
assert_eq!(v.boundary, "marketing");
assert_eq!(v.owner, "core");
assert_eq!(v.table, "contacts");
assert_eq!(v.access, Access::Read);
assert_eq!(v.provenance, Provenance::Body);
assert_eq!(v.symbol, "contacts");
assert_eq!(v.via, None);
let text = Violation::Table(v.clone()).render();
for needle in [
"marketing",
"core",
"contacts",
"GET /report",
"body",
"`contacts`",
] {
assert!(text.contains(needle), "{needle} missing from {text}");
}
}
#[test]
fn may_read_clears_a_read_violation() {
let report = check(&config("may_read = [\"contacts\"]\n"), Some(&graph()), None)
.expect("check runs");
assert!(table_violation(&report, "report").is_none());
assert!(table_violation(&report, "joined").is_none());
}
#[test]
fn a_raw_sql_join_is_a_violation() {
let report = check(&config(""), Some(&graph()), None).expect("check runs");
let v = table_violation(&report, "joined").expect("the JOIN reaches contacts");
assert_eq!((v.owner.as_str(), v.table.as_str()), ("core", "contacts"));
assert_eq!(v.provenance, Provenance::Body);
assert_eq!(v.symbol, "CONTACTS");
}
#[test]
fn a_write_through_a_repository_names_the_repository() {
let report = check(&config(""), Some(&graph()), None).expect("check runs");
let v = table_violation(&report, "sync").expect("sync writes contacts");
assert_eq!(v.access, Access::Write);
assert_eq!(v.provenance, Provenance::Signature);
assert_eq!(
v.via.as_deref(),
Some("repository:crm_core::repositories::ContactRepository")
);
assert!(
Violation::Table(v.clone())
.render()
.contains("ContactRepository")
);
}
#[test]
fn a_direct_edge_wins_over_a_repository_edge() {
const BOTH: &[RouteGraphDescriptor] = &[RouteGraphDescriptor {
signature_symbols: &["PgContactRepository"],
body_symbols: &["contacts"],
..route("both", "crm_marketing::routes", "GET", "/both")
}];
let mut graph = build(&[], 0, BOTH, MODELS, REPOSITORIES, &[]);
graph.edges.reverse();
let report = check(&config(""), Some(&graph), None).expect("check runs");
let found = tables(&report);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].via, None);
assert_eq!(found[0].symbol, "contacts");
}
#[test]
fn a_repository_with_an_overridden_table_reaches_that_table() {
const ARCHIVE: &[RepositoryGraphDescriptor] = &[RepositoryGraphDescriptor {
repository: "ArchiveRepository",
implementation: "PgArchiveRepository",
model: "Contact",
table: "contacts_archive",
api: "",
module_path: "crm_core::repositories",
file: "src/repositories.rs",
line: 1,
crate_version: "0.1.0",
}];
const USER: &[RouteGraphDescriptor] = &[RouteGraphDescriptor {
signature_symbols: &["PgArchiveRepository"],
..route("archive", "crm_marketing::routes", "GET", "/archive")
}];
let config = config(
"may_write = [\"contacts\"]\n\
[boundaries.archive]\nowns = [\"contacts_archive\"]\nmodules = [\"crm_archive\"]\n",
);
let graph = build(&[], 0, USER, MODELS, ARCHIVE, &[]);
let report = check(&config, Some(&graph), None).expect("check runs");
let route = table_violation(&report, "archive").expect("the route reaches the archive");
assert_eq!(
(route.owner.as_str(), route.table.as_str()),
("archive", "contacts_archive")
);
assert!(
tables(&report)
.iter()
.any(|v| v.kind == NodeKind::Repository && v.table == "contacts_archive"),
"{:?}",
report.violations
);
}
#[test]
fn an_ambiguous_repository_table_is_an_error() {
const ARCHIVE: &[RepositoryGraphDescriptor] = &[RepositoryGraphDescriptor {
repository: "ArchiveRepository",
implementation: "PgArchiveRepository",
model: "Contact",
table: "archive_rows",
api: "",
module_path: "crm_core::repositories",
file: "src/repositories.rs",
line: 1,
crate_version: "0.1.0",
}];
let config = BoundaryConfig::parse(
"[boundaries.a]\nowns = [\"archive_*\"]\n[boundaries.b]\nowns = [\"*ive_rows\"]\n",
)
.expect("valid");
let graph = build(&[], 0, &[], MODELS, ARCHIVE, &[]);
let err = check(&config, Some(&graph), None).expect_err("ambiguous");
assert!(
matches!(err, BoundaryError::Ambiguous { what: "table", .. }),
"{err}"
);
}
#[test]
fn a_versioned_package_key_matches_by_its_name() {
let deps = crates(&[("crm-marketing", "crm-core@0.2.0")]);
let report = check(&config(""), Some(&graph()), Some(&deps)).expect("runs");
let found = crate_violations(&report);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].schema_crate, "crm-core@0.2.0");
let key = Violation::Crate(found[0].clone()).key();
assert!(!key.to_string().contains('@'), "{key}");
}
#[test]
fn an_ambiguous_node_is_an_error() {
let config = BoundaryConfig::parse(
"[boundaries.a]\nmodules = [\"crm_marketing\"]\n\
[boundaries.b]\nmodules = [\"crm_marketing\"]\n",
)
.expect("valid");
let err = check(&config, Some(&graph()), None).expect_err("ambiguous");
assert!(
matches!(err, BoundaryError::Ambiguous { what: "node", .. }),
"{err}"
);
}
#[test]
fn a_key_holds_the_base_id_and_the_access() {
let report = current();
let Violation::Table(mut v) = report.violations[0].clone() else {
panic!("a table violation");
};
let read = Violation::Table(v.clone()).key();
v.access = Access::Write;
assert_ne!(
Violation::Table(v.clone()).key(),
read,
"a new write is a new violation"
);
v.node.push_str("@0.2.0");
let BaselineEntry::Table { node, .. } = Violation::Table(v).key() else {
panic!("a table key");
};
assert!(!node.contains('@'), "{node}");
}
#[test]
fn may_read_does_not_clear_a_write() {
let report = check(&config("may_read = [\"contacts\"]\n"), Some(&graph()), None)
.expect("check runs");
assert!(table_violation(&report, "sync").is_some());
}
#[test]
fn may_write_clears_a_write() {
let report = check(
&config("may_write = [\"contacts\"]\n"),
Some(&graph()),
None,
)
.expect("check runs");
assert!(tables(&report).is_empty(), "{:?}", report.violations);
}
#[test]
fn own_tables_and_unbounded_nodes_are_not_checked() {
let report = check(&config(""), Some(&graph()), None).expect("check runs");
assert!(table_violation(&report, "list").is_none());
assert!(table_violation(&report, "home").is_none());
assert!(
!tables(&report)
.iter()
.any(|v| v.kind == NodeKind::Repository)
);
assert_eq!(tables(&report).len(), 3, "{:?}", report.violations);
}
#[test]
fn a_repository_in_one_boundary_over_another_boundarys_model_is_a_violation() {
let mut config = config("");
config
.boundaries
.get_mut("marketing")
.expect("marketing")
.modules
.push("crm_core::repositories".into());
let report = check(&config, Some(&graph()), None).expect("check runs");
let v = tables(&report)
.into_iter()
.find(|v| v.kind == NodeKind::Repository)
.expect("the repository is now in marketing");
assert_eq!(v.access, Access::ReadWrite);
assert_eq!(v.provenance, Provenance::Declaration);
}
#[test]
fn the_more_specific_pattern_wins() {
const ADS: &[RouteGraphDescriptor] = &[RouteGraphDescriptor {
body_symbols: &["contacts"],
..route("ads", "crm_core_ad::routes", "GET", "/ads")
}];
let config = BoundaryConfig::parse(
r#"
[boundaries.core]
owns = ["contacts"]
crates = ["crm-core*"]
[boundaries.marketing]
owns = ["campaigns"]
crates = ["crm-core-ad*"]
"#,
)
.expect("valid");
let report =
check(&config, Some(&build(&[], 0, ADS, MODELS, &[], &[])), None).expect("runs");
let v = table_violation(&report, "ads").expect("ads is in marketing");
assert_eq!(v.boundary, "marketing");
}
#[test]
fn an_owns_glob_matches_a_table_without_case() {
const READER: &[RouteGraphDescriptor] = &[RouteGraphDescriptor {
body_symbols: &["Touch"],
..route("touches", "crm_core::routes", "GET", "/touches")
}];
let config = BoundaryConfig::parse(
"[boundaries.marketing]\nowns = [\"ATTRIBUTION_*\"]\nmodules = [\"x\"]\n\
[boundaries.core]\nowns = []\nmodules = [\"crm_core\"]\n",
)
.expect("valid");
let report = check(
&config,
Some(&build(&[], 0, READER, MODELS, &[], &[])),
None,
)
.expect("runs");
let v = table_violation(&report, "touches").expect("core reads attribution_touches");
assert_eq!(
(v.owner.as_str(), v.table.as_str()),
("marketing", "attribution_touches")
);
}
#[test]
fn a_table_two_boundaries_claim_equally_is_an_error() {
let config = BoundaryConfig::parse(
"[boundaries.a]\nowns = [\"contacts\"]\n[boundaries.b]\nowns = [\"contacts\"]\n",
)
.expect("valid");
let err = check(&config, Some(&graph()), None).expect_err("ambiguous");
assert!(
matches!(err, BoundaryError::Ambiguous { what: "table", .. }),
"{err}"
);
}
#[test]
fn a_pattern_that_matches_nothing_is_a_warning() {
let report = check(
&config("[boundaries.ghost]\nowns = [\"nothing_here\"]\nmodules = [\"no::such\"]\n"),
Some(&graph()),
None,
)
.expect("check runs");
let warnings = report.warnings.join("\n");
assert!(warnings.contains("nothing_here"), "{warnings}");
assert!(warnings.contains("no::such"), "{warnings}");
}
fn crates(edges: &[(&str, &str)]) -> CrateGraph {
let mut graph = CrateGraph::default();
for (from, to) in edges {
graph.add(from, to);
}
graph
}
#[test]
fn a_crate_that_depends_on_another_boundarys_schema_crate_is_a_violation() {
let deps = crates(&[("crm-marketing", "crm-core"), ("crm-app", "crm-core")]);
let report = check(&config(""), Some(&graph()), Some(&deps)).expect("runs");
let found = crate_violations(&report);
assert_eq!(found.len(), 1, "{found:?}");
let v = found[0];
assert_eq!(
(v.boundary.as_str(), v.owner.as_str()),
("marketing", "core")
);
assert_eq!(v.krate, "crm-marketing");
assert_eq!(v.schema_crate, "crm-core");
assert_eq!(v.path, ["crm-marketing", "crm-core"]);
let text = Violation::Crate(v.clone()).render();
assert!(text.contains("crm-marketing -> crm-core"), "{text}");
}
#[test]
fn a_dependency_through_an_unbounded_helper_crate_is_a_violation() {
let deps = crates(&[
("crm-marketing", "crm-helpers"),
("crm-helpers", "crm-core"),
]);
let report = check(&config(""), Some(&graph()), Some(&deps)).expect("runs");
let found = crate_violations(&report);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].path, ["crm-marketing", "crm-helpers", "crm-core"]);
}
#[test]
fn schema_crates_name_the_crate_that_holds_the_schema() {
let mut config = config("");
config
.boundaries
.get_mut("core")
.expect("core")
.schema_crates = vec!["crm-core-schema".into()];
let deps = crates(&[
("crm-marketing", "crm-core"),
("crm-marketing", "crm-core-schema"),
]);
let report = check(&config, Some(&graph()), Some(&deps)).expect("runs");
let found = crate_violations(&report);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].schema_crate, "crm-core-schema");
}
#[test]
fn may_read_allows_the_dependency_it_needs() {
let deps = crates(&[("crm-marketing", "crm-core")]);
let report = check(
&config("may_read = [\"contacts\"]\n"),
Some(&graph()),
Some(&deps),
)
.expect("runs");
assert!(crate_violations(&report).is_empty());
}
#[test]
fn may_write_allows_the_dependency_it_needs() {
let deps = crates(&[("crm-marketing", "crm-core")]);
let report = check(
&config("may_write = [\"contacts\"]\n"),
Some(&graph()),
Some(&deps),
)
.expect("runs");
assert!(crate_violations(&report).is_empty());
}
#[test]
fn a_glob_exception_allows_the_dependency_and_does_not_warn() {
let deps = crates(&[("crm-marketing", "crm-core")]);
let report = check(
&config("may_read = [\"contact*\"]\n"),
Some(&graph()),
Some(&deps),
)
.expect("runs");
assert!(
crate_violations(&report).is_empty(),
"{:?}",
report.violations
);
assert!(
!report.warnings.iter().any(|w| w.contains("contact*")),
"{:?}",
report.warnings
);
}
#[test]
fn the_search_stops_at_a_crate_in_a_boundary() {
let mut config = config("");
config
.boundaries
.get_mut("core")
.expect("core")
.schema_crates = vec!["crm-core-schema".into()];
let deps = crates(&[
("crm-marketing", "crm-core-api"),
("crm-core-api", "crm-core-schema"),
]);
let report = check(&config, Some(&graph()), Some(&deps)).expect("runs");
assert!(
crate_violations(&report).is_empty(),
"{:?}",
report.violations
);
}
#[test]
fn the_search_stops_at_a_crate_in_a_modules_boundary() {
let config = BoundaryConfig::parse(
"[boundaries.core]\nowns = [\"contacts\"]\nmodules = [\"crm_core\"]\n\
[boundaries.billing]\nowns = [\"campaigns\"]\ncrates = [\"crm-billing\"]\n\
schema_crates = [\"crm-billing\"]\n\
[boundaries.marketing]\nowns = []\ncrates = [\"crm-marketing\"]\n",
)
.expect("valid");
let deps = crates(&[("crm-marketing", "crm-core"), ("crm-core", "crm-billing")]);
let report = check(&config, Some(&graph()), Some(&deps)).expect("runs");
let found = crate_violations(&report);
assert!(!found.iter().any(|v| v.owner == "billing"), "{found:?}");
assert!(found.iter().any(|v| v.owner == "core"), "{found:?}");
}
#[test]
fn a_schema_crate_matches_its_library_name() {
let mut deps = crates(&[("crm-marketing", "crm-core-pkg")]);
deps.libs.insert("crm-core-pkg".into(), "crm_core".into());
let report = check(&config(""), Some(&graph()), Some(&deps)).expect("runs");
let found = crate_violations(&report);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].schema_crate, "crm-core-pkg");
}
#[test]
fn a_versioned_package_with_a_library_name_keeps_its_nodes() {
const ROUTES: &[RouteGraphDescriptor] = &[RouteGraphDescriptor {
body_symbols: &["contacts"],
..route("versioned_report", "crm_mkt::routes", "GET", "/versioned")
}];
let config = BoundaryConfig::parse(
"[boundaries.core]\nowns = [\"contacts\"]\ncrates = [\"crm-core\"]\n\
[boundaries.marketing]\nowns = [\"campaigns\"]\ncrates = [\"crm-mkt-pkg\"]\n",
)
.expect("valid");
let mut deps = crates(&[("crm-mkt-pkg@1.0.0", "crm-core")]);
deps.libs
.insert("crm-mkt-pkg@1.0.0".into(), "crm_mkt".into());
let graph = build(&[], 0, ROUTES, MODELS, &[], &[]);
let report = check(&config, Some(&graph), Some(&deps)).expect("runs");
let v = table_violation(&report, "versioned_report").expect("the route is in marketing");
assert_eq!(v.boundary, "marketing");
}
#[test]
fn a_node_joins_the_boundary_of_its_package_by_library_name() {
const ROUTES: &[RouteGraphDescriptor] = &[RouteGraphDescriptor {
body_symbols: &["contacts"],
..route("lib_report", "crm_mkt::routes", "GET", "/lib-report")
}];
let config = BoundaryConfig::parse(
"[boundaries.core]\nowns = [\"contacts\"]\ncrates = [\"crm-core\"]\n\
[boundaries.marketing]\nowns = [\"campaigns\"]\ncrates = [\"crm-mkt-pkg\"]\n",
)
.expect("valid");
let mut deps = crates(&[("crm-mkt-pkg", "crm-core")]);
deps.libs.insert("crm-mkt-pkg".into(), "crm_mkt".into());
let graph = build(&[], 0, ROUTES, MODELS, &[], &[]);
let report = check(&config, Some(&graph), Some(&deps)).expect("runs");
let v = table_violation(&report, "lib_report").expect("the route is in marketing");
assert_eq!(v.boundary, "marketing");
}
#[test]
fn without_a_graph_a_derived_schema_crate_is_a_warning() {
let deps = crates(&[("crm-marketing", "crm-core")]);
let report = check(&config(""), None, Some(&deps)).expect("runs");
assert!(
report.warnings.iter().any(|w| w.contains("schema_crates")),
"{:?}",
report.warnings
);
}
fn current() -> Report {
check(&config(""), Some(&graph()), None).expect("check runs")
}
#[test]
fn with_no_baseline_every_violation_is_new() {
let result = ratchet(current(), None);
assert_eq!(result.new.len(), 3);
assert!(!result.passes());
}
#[test]
fn a_baseline_of_today_passes() {
let report = current();
let baseline = Baseline::from_violations(&report.violations);
assert_eq!(baseline.violations.len(), 3);
let result = ratchet(report, Some(&baseline));
assert!(result.passes(), "{}", result.render());
assert_eq!(result.known.len(), 3);
}
#[test]
fn a_new_violation_fails_against_the_baseline() {
let report = current();
let mut baseline = Baseline::from_violations(&report.violations);
let dropped = baseline.violations.remove(0);
let result = ratchet(report, Some(&baseline));
assert!(!result.passes());
assert_eq!(result.new.len(), 1);
assert_eq!(result.new[0].key(), dropped);
assert!(result.render().contains("new"), "{}", result.render());
}
#[test]
fn a_fixed_violation_must_leave_the_baseline() {
let report = current();
let mut baseline = Baseline::from_violations(&report.violations);
let gone = BaselineEntry::Table {
boundary: "marketing".into(),
owner: "core".into(),
node: "route:crm_marketing::routes::removed".into(),
table: "contacts".into(),
access: Access::Read,
};
baseline.violations.push(gone.clone());
let result = ratchet(report, Some(&baseline));
assert!(!result.passes());
assert_eq!(result.fixed, [gone]);
assert!(
result.render().contains("--update-baseline"),
"{}",
result.render()
);
}
#[test]
fn fixing_one_violation_shrinks_the_baseline_by_one() {
let report = current();
let baseline = Baseline::from_violations(&report.violations);
let fixed =
check(&config("may_read = [\"contacts\"]\n"), Some(&graph()), None).expect("runs");
let result = ratchet(fixed.clone(), Some(&baseline));
assert_eq!(result.fixed.len(), 2, "{:?}", result.fixed);
assert_eq!(result.known.len(), 1);
let shrunk = Baseline::from_violations(&fixed.violations);
assert_eq!(shrunk.violations.len(), 1);
assert!(ratchet(fixed, Some(&shrunk)).passes());
}
#[test]
fn a_rule_that_did_not_run_fixes_nothing() {
let report = current();
let baseline = Baseline::from_violations(&report.violations);
let without_graph = check(&config(""), None, None).expect("runs");
let result = ratchet(without_graph, Some(&baseline));
assert!(result.fixed.is_empty(), "{:?}", result.fixed);
assert!(result.passes());
}
#[test]
fn the_baseline_round_trips_and_is_sorted() {
let report = current();
let baseline = Baseline::from_violations(&report.violations);
let mut sorted = baseline.violations.clone();
sorted.sort();
assert_eq!(baseline.violations, sorted);
let parsed = Baseline::parse(&baseline.to_json()).expect("round trip");
assert_eq!(parsed, baseline);
assert!(Baseline::parse("{\"version\":2,\"violations\":[]}").is_err());
}
#[test]
fn a_key_ignores_the_evidence() {
let report = current();
let Violation::Table(mut v) = report.violations[0].clone() else {
panic!("a table violation");
};
let before = Violation::Table(v.clone()).key();
v.provenance = Provenance::Signature;
v.symbol = "Other".into();
assert_eq!(Violation::Table(v).key(), before);
}
#[test]
fn glob_matches_runs() {
assert!(glob("attribution_*", "attribution_touches"));
assert!(glob("*", ""));
assert!(glob("a*c*e", "abcde"));
assert!(glob("contacts", "contacts"));
assert!(!glob("contacts", "contacts2"));
assert!(!glob("a*c", "abd"));
assert!(!glob("*s", "contact"));
}
}