diff --git a/src/contracts/layers.rs b/src/contracts/layers.rs new file mode 100644 index 0000000..6840a65 --- /dev/null +++ b/src/contracts/layers.rs @@ -0,0 +1,296 @@ +use crate::contracts::{Contract, ContractViolation, ForbiddenImport}; +use crate::{ImportsInfo, InternalImportsPathQuery, PackageItemToken, PackageItemTokens}; +use anyhow::Result; +use maplit::hashset; +use std::collections::HashSet; + +#[derive(Debug, Clone)] +pub struct LayeredArchitectureContract { + layers: Vec, +} + +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> { + let mut violations: Vec = Vec::new(); + + let forbidden_imports = get_forbidden_imports(&self.layers); + for forbidden_import in forbidden_imports { + let from: HashSet = forbidden_import.from.into(); + let from = from.extend_with_descendants(imports_info.package_info()); + let to: HashSet = 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, + siblings_independent: bool, +} + +impl Layer { + pub fn new>( + siblings: T, + siblings_independent: bool, + ) -> Self { + Layer { + siblings: siblings.into_iter().collect(), + siblings_independent, + } + } + + pub fn siblings(&self) -> &HashSet { + &self.siblings + } + + pub fn siblings_independent(&self) -> bool { + self.siblings_independent + } +} + +fn get_forbidden_imports(layers: &[Layer]) -> Vec { + let mut forbidden_imports = Vec::new(); + + for (idx, layer) in layers.iter().enumerate() { + 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! {}, + )); + } + } + } + + 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(), + )); + } + } + } + } + + if layer.siblings_independent { + for layer_sibling1 in layer.siblings.iter() { + for layer_sibling2 in layer.siblings.iter() { + if layer_sibling1 == layer_sibling2 { + continue; + } + forbidden_imports.push(ForbiddenImport::new( + *layer_sibling1, + *layer_sibling2, + 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 = 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(()) + } +} diff --git a/src/contracts/mod.rs b/src/contracts/mod.rs new file mode 100644 index 0000000..20e58c9 --- /dev/null +++ b/src/contracts/mod.rs @@ -0,0 +1,48 @@ +use crate::{ImportsInfo, PackageItemToken}; +use anyhow::Result; +use std::collections::HashSet; + +pub mod layers; + +trait Contract { + fn find_violations(&self, imports_info: &ImportsInfo) -> Result>; +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ContractViolation { + ForbiddenImport { + forbidden_import: ForbiddenImport, + path: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ForbiddenImport { + from: PackageItemToken, + to: PackageItemToken, + except_via: HashSet, +} + +impl ForbiddenImport { + fn new( + from: PackageItemToken, + to: PackageItemToken, + except_via: HashSet, + ) -> Self { + ForbiddenImport { + from, + to, + except_via, + } + } + + pub fn from(&self) -> PackageItemToken { + self.from + } + pub fn to(&self) -> PackageItemToken { + self.to + } + pub fn except_via(&self) -> &HashSet { + &self.except_via + } +} diff --git a/src/imports_info/queries/internal_imports/mod.rs b/src/imports_info/queries/internal_imports.rs similarity index 99% rename from src/imports_info/queries/internal_imports/mod.rs rename to src/imports_info/queries/internal_imports.rs index b8a5c77..42b9591 100644 --- a/src/imports_info/queries/internal_imports/mod.rs +++ b/src/imports_info/queries/internal_imports.rs @@ -1,5 +1,3 @@ -mod layers; - use std::collections::{HashMap, HashSet}; use anyhow::Result; diff --git a/src/imports_info/queries/internal_imports/layers.rs b/src/imports_info/queries/internal_imports/layers.rs deleted file mode 100644 index df58ff3..0000000 --- a/src/imports_info/queries/internal_imports/layers.rs +++ /dev/null @@ -1,162 +0,0 @@ -#![allow(dead_code)] // TODO: Remove me - -use crate::PackageItemToken; -use maplit::hashset; -use std::collections::HashSet; - -#[derive(Debug, Clone)] -struct Layer { - siblings: HashSet, - siblings_independent: bool, -} - -impl Layer { - fn new>( - siblings: T, - siblings_independent: bool, - ) -> Self { - Layer { - siblings: siblings.into_iter().collect(), - siblings_independent, - } - } -} - -#[derive(Debug, Clone, PartialEq)] -struct ForbiddenImport { - from: PackageItemToken, - to: PackageItemToken, - except_via: HashSet, -} - -impl ForbiddenImport { - fn new( - from: PackageItemToken, - to: PackageItemToken, - except_via: HashSet, - ) -> Self { - ForbiddenImport { - from, - to, - except_via, - } - } -} - -fn get_forbidden_imports(layers: &[Layer]) -> Vec { - let mut forbidden_imports = Vec::new(); - - for (idx, layer) in layers.iter().enumerate() { - 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! {}, - )); - } - } - } - - 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(), - )); - } - } - } - } - - if layer.siblings_independent { - for layer_sibling1 in layer.siblings.iter() { - for layer_sibling2 in layer.siblings.iter() { - if layer_sibling1 == layer_sibling2 { - continue; - } - forbidden_imports.push(ForbiddenImport::new( - *layer_sibling1, - *layer_sibling2, - hashset! {}, - )); - } - } - } - } - - forbidden_imports -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::PackageToken; - use anyhow::Result; - use pretty_assertions::assert_eq; - use slotmap::SlotMap; - - #[test] - fn test_get_forbidden_imports() -> Result<()> { - let mut sm: SlotMap = 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(()) - } -} diff --git a/src/lib.rs b/src/lib.rs index f145b64..d65e0e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,9 @@ mod pypath; // TODO: Use #[cfg(test)] here, but still need // a way to access the testutils from doctests. // Related [GH issue](https://github.com/rust-lang/rust/issues/67295). +pub mod contracts; mod testutils; + #[doc(hidden)] pub use testutils::TestPackage;