Skip to content

Commit

Permalink
Add LayeredArchitectureContract
Browse files Browse the repository at this point in the history
  • Loading branch information
Peter554 committed Jan 4, 2025
1 parent cf00d67 commit 2f741ff
Show file tree
Hide file tree
Showing 7 changed files with 360 additions and 168 deletions.
18 changes: 14 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ tempdir = "0.3.7"
pathfinding = "4.12.0"
regex = "1.11.1"
lazy_static = "1.5.0"
itertools = "0.14.0"

[dev-dependencies]
parameterized = "2.0.0"
Expand Down
295 changes: 295 additions & 0 deletions src/contracts/layers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
use crate::contracts::{Contract, ContractViolation, ForbiddenImport};
use crate::{ImportsInfo, InternalImportsPathQuery, PackageItemToken, PackageItemTokens};
use anyhow::Result;
use itertools::Itertools;
use maplit::hashset;
use std::collections::HashSet;

#[derive(Debug, Clone)]
pub struct LayeredArchitectureContract {
layers: Vec<Layer>,
}

impl LayeredArchitectureContract {
pub fn new(layers: &[Layer]) -> Self {
LayeredArchitectureContract {
layers: layers.to_vec(),
}
}

pub fn layers(&self) -> &[Layer] {
&self.layers
}
}

impl Contract for LayeredArchitectureContract {
fn find_violations(&self, imports_info: &ImportsInfo) -> Result<Vec<ContractViolation>> {
let mut violations: Vec<ContractViolation> = Vec::new();

let forbidden_imports = get_forbidden_imports(&self.layers);
for forbidden_import in forbidden_imports {
let from: HashSet<PackageItemToken> = forbidden_import.from.into();
let from = from.extend_with_descendants(imports_info.package_info());
let to: HashSet<PackageItemToken> = forbidden_import.to.into();
let to = to.extend_with_descendants(imports_info.package_info());
let except_via = forbidden_import
.except_via
.clone()
.extend_with_descendants(imports_info.package_info());

let path = imports_info.internal_imports().find_path(
&InternalImportsPathQuery::new()
.from(from)
.to(to)
.excluding_paths_via(except_via),
)?;
match path {
Some(path) => {
violations.push(ContractViolation::ForbiddenImport {
forbidden_import,
path,
});
}
None => {}
}
}

Ok(violations)
}
}

#[derive(Debug, Clone)]
pub struct Layer {
siblings: HashSet<PackageItemToken>,
siblings_independent: bool,
}

impl Layer {
pub fn new<T: IntoIterator<Item = PackageItemToken>>(
siblings: T,
siblings_independent: bool,
) -> Self {
Layer {
siblings: siblings.into_iter().collect(),
siblings_independent,
}
}

pub fn siblings(&self) -> &HashSet<PackageItemToken> {
&self.siblings
}

pub fn siblings_independent(&self) -> bool {
self.siblings_independent
}
}

fn get_forbidden_imports(layers: &[Layer]) -> Vec<ForbiddenImport> {
let mut forbidden_imports = Vec::new();

for (idx, layer) in layers.iter().enumerate() {
// Lower layers should not import higher layers.
for higher_layer in layers[idx + 1..].iter() {
for layer_sibling in layer.siblings.iter() {
for higher_layer_sibling in higher_layer.siblings.iter() {
forbidden_imports.push(ForbiddenImport::new(
*layer_sibling,
*higher_layer_sibling,
hashset! {},
));
}
}
}

// Higher layers should not import lower layers, except via the layer immediately below.
if idx >= 2 {
let directly_lower_layer = &layers[idx - 1];
for lower_layer in layers[..idx - 1].iter() {
for layer_sibling in layer.siblings.iter() {
for lower_layer_sibling in lower_layer.siblings.iter() {
forbidden_imports.push(ForbiddenImport::new(
*layer_sibling,
*lower_layer_sibling,
directly_lower_layer.siblings.clone(),
));
}
}
}
}

// Independent siblings should not import each other.
if layer.siblings_independent {
for permutation in layer.siblings.iter().permutations(2) {
forbidden_imports.push(ForbiddenImport::new(
*permutation[0],
*permutation[1],
hashset! {},
));
}
}
}

forbidden_imports
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{testpackage, PackageInfo, PackageToken, TestPackage};
use anyhow::Result;
use pretty_assertions::assert_eq;
use slotmap::SlotMap;

#[test]
fn test_get_forbidden_imports() -> Result<()> {
let mut sm: SlotMap<PackageToken, String> = SlotMap::with_key();
let data: PackageItemToken = sm.insert("data".into()).into();
let domain1: PackageItemToken = sm.insert("domain1".into()).into();
let domain2: PackageItemToken = sm.insert("domain2".into()).into();
let application1: PackageItemToken = sm.insert("application1".into()).into();
let application2: PackageItemToken = sm.insert("application2".into()).into();
let interfaces: PackageItemToken = sm.insert("interfaces".into()).into();

let layers = vec![
Layer::new([data], true),
Layer::new([domain1, domain2], true),
Layer::new([application1, application2], false),
Layer::new([interfaces], true),
];

let forbidden_imports = get_forbidden_imports(&layers);

let expected = vec![
// data may not import domain, application or interfaces
ForbiddenImport::new(data, domain1, hashset! {}),
ForbiddenImport::new(data, domain2, hashset! {}),
ForbiddenImport::new(data, application1, hashset! {}),
ForbiddenImport::new(data, application2, hashset! {}),
ForbiddenImport::new(data, interfaces, hashset! {}),
// domain may not import application or interfaces
// (domain may import data)
ForbiddenImport::new(domain1, application1, hashset! {}),
ForbiddenImport::new(domain1, application2, hashset! {}),
ForbiddenImport::new(domain1, interfaces, hashset! {}),
ForbiddenImport::new(domain2, application1, hashset! {}),
ForbiddenImport::new(domain2, application2, hashset! {}),
ForbiddenImport::new(domain2, interfaces, hashset! {}),
// domain1 and domain2 are independent siblings
ForbiddenImport::new(domain1, domain2, hashset! {}),
ForbiddenImport::new(domain2, domain1, hashset! {}),
// application may not import interfaces
// application may not import data, except via domain
// (application may import domain)
ForbiddenImport::new(application1, interfaces, hashset! {}),
ForbiddenImport::new(application1, data, hashset! {domain1, domain2}),
ForbiddenImport::new(application2, interfaces, hashset! {}),
ForbiddenImport::new(application2, data, hashset! {domain1, domain2}),
// interfaces may not import data or domain, except via application
// (application may import application)
ForbiddenImport::new(interfaces, data, hashset! {application1, application2}),
ForbiddenImport::new(interfaces, domain1, hashset! {application1, application2}),
ForbiddenImport::new(interfaces, domain2, hashset! {application1, application2}),
];

assert_eq!(forbidden_imports.len(), expected.len(),);
for forbidden_import in forbidden_imports.iter() {
assert!(expected.contains(forbidden_import));
}

Ok(())
}

#[test]
fn test_layered_architecture_contract_ok() -> Result<()> {
let testpackage = testpackage! {
"__init__.py" => "",
"data.py" => "",
"domain.py" => "
import testpackage.data
",
"application.py" => "
import testpackage.domain
",
"interfaces.py" => "
import testpackage.application
"
};

let package_info = PackageInfo::build(testpackage.path())?;
let imports_info = ImportsInfo::build(package_info)?;

let data = imports_info.package_info()._item("testpackage.data");
let domain = imports_info.package_info()._item("testpackage.domain");
let application = imports_info.package_info()._item("testpackage.application");
let interfaces = imports_info.package_info()._item("testpackage.interfaces");

let contract = LayeredArchitectureContract::new(&[
Layer::new([data], true),
Layer::new([domain], true),
Layer::new([application], true),
Layer::new([interfaces], true),
]);

let violations = contract.find_violations(&imports_info)?;
assert!(violations.is_empty());

Ok(())
}

#[test]
fn test_layered_architecture_contract_violated() -> Result<()> {
let testpackage = testpackage! {
"__init__.py" => "",
"data.py" => "",
"domain.py" => "
import testpackage.data
",
"application.py" => "
import testpackage.domain
import testpackage.interfaces
",
"interfaces.py" => "
import testpackage.application
import testpackage.data
"
};

let package_info = PackageInfo::build(testpackage.path())?;
let imports_info = ImportsInfo::build(package_info)?;

let data = imports_info.package_info()._item("testpackage.data");
let domain = imports_info.package_info()._item("testpackage.domain");
let application = imports_info.package_info()._item("testpackage.application");
let interfaces = imports_info.package_info()._item("testpackage.interfaces");

let contract = LayeredArchitectureContract::new(&[
Layer::new([data], true),
Layer::new([domain], true),
Layer::new([application], true),
Layer::new([interfaces], true),
]);

let violations = contract.find_violations(&imports_info)?;

let expected_violations = vec![
ContractViolation::ForbiddenImport {
forbidden_import: ForbiddenImport::new(application, interfaces, hashset! {}),
path: vec![application, interfaces],
},
ContractViolation::ForbiddenImport {
forbidden_import: ForbiddenImport::new(interfaces, data, hashset! {application}),
path: vec![interfaces, data],
},
ContractViolation::ForbiddenImport {
forbidden_import: ForbiddenImport::new(application, data, hashset! {domain}),
path: vec![application, interfaces, data],
},
];
assert_eq!(violations.len(), expected_violations.len());
for violation in violations.iter() {
assert!(expected_violations.contains(violation));
}

Ok(())
}
}
Loading

0 comments on commit 2f741ff

Please sign in to comment.