1use crate::{
4 ArchitectureGraph, Candidate, Component, ComponentId, ConditionEvent, DependencyEvidence,
5 DependencyScope, DescribedCondition, DescribedSelector, PathPatternError, Rule, RuleMetadata,
6 SelectorSpec,
7};
8use petgraph::algo::kosaraju_scc;
9use petgraph::graph::DiGraph;
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet, HashSet};
12use thiserror::Error;
13
14#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum DependencyScopes {
17 Actual,
18 Declared,
19 #[default]
20 Both,
21}
22
23impl DependencyScopes {
24 pub fn includes(self, scope: DependencyScope) -> bool {
25 matches!(
26 (self, scope),
27 (Self::Both, _)
28 | (Self::Actual, DependencyScope::Actual)
29 | (Self::Declared, DependencyScope::Declared)
30 )
31 }
32}
33
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35pub struct NamedSelectorSpec {
36 pub name: String,
37 pub selector: SelectorSpec,
38}
39
40#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
41pub struct LayerDependencySpec {
42 pub from: String,
43 pub may_depend_on: Vec<String>,
44}
45
46#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
47#[serde(tag = "kind", rename_all = "kebab-case")]
48pub enum CycleGrouping {
49 Components,
50 Modules,
51 #[default]
52 Crates,
53 Slices {
54 slices: Vec<NamedSelectorSpec>,
55 },
56}
57
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59#[serde(tag = "kind", rename_all = "kebab-case")]
60pub enum BuiltInRuleSpec {
61 ForbidDependencies {
62 metadata: RuleMetadata,
63 from: SelectorSpec,
64 to: SelectorSpec,
65 #[serde(default)]
66 scopes: DependencyScopes,
67 },
68 OnlyDependencies {
69 metadata: RuleMetadata,
70 from: SelectorSpec,
71 allowed: SelectorSpec,
72 #[serde(default)]
73 scopes: DependencyScopes,
74 #[serde(default = "default_true")]
75 allow_toolchain: bool,
76 #[serde(default = "default_true")]
77 allow_self: bool,
78 },
79 Layers {
80 metadata: RuleMetadata,
81 layers: Vec<NamedSelectorSpec>,
82 dependencies: Vec<LayerDependencySpec>,
83 #[serde(default)]
84 scopes: DependencyScopes,
85 },
86 NoCycles {
87 metadata: RuleMetadata,
88 within: SelectorSpec,
89 #[serde(default)]
90 grouping: CycleGrouping,
91 #[serde(default)]
92 scopes: DependencyScopes,
93 },
94}
95
96fn default_true() -> bool {
97 true
98}
99
100impl BuiltInRuleSpec {
101 pub fn metadata(&self) -> &RuleMetadata {
102 match self {
103 Self::ForbidDependencies { metadata, .. }
104 | Self::OnlyDependencies { metadata, .. }
105 | Self::Layers { metadata, .. }
106 | Self::NoCycles { metadata, .. } => metadata,
107 }
108 }
109
110 pub fn metadata_mut(&mut self) -> &mut RuleMetadata {
111 match self {
112 Self::ForbidDependencies { metadata, .. }
113 | Self::OnlyDependencies { metadata, .. }
114 | Self::Layers { metadata, .. }
115 | Self::NoCycles { metadata, .. } => metadata,
116 }
117 }
118
119 pub fn compile(&self) -> Result<Rule, RuleBuildError> {
120 if self.metadata().id.trim().is_empty() {
121 return Err(RuleBuildError::EmptyRuleId);
122 }
123 match self {
124 Self::ForbidDependencies {
125 metadata,
126 from,
127 to,
128 scopes,
129 } => {
130 let origin_selector = from.compile()?;
131 let target_selector = to.compile()?;
132 Ok(Rule::new(
133 metadata.clone(),
134 origin_selector,
135 forbid_dependencies_condition(target_selector, *scopes),
136 ))
137 }
138 Self::OnlyDependencies {
139 metadata,
140 from,
141 allowed,
142 scopes,
143 allow_toolchain,
144 allow_self,
145 } => {
146 let origin_selector = from.compile()?;
147 let allowed_selector = allowed.compile()?;
148 Ok(Rule::new(
149 metadata.clone(),
150 origin_selector,
151 only_dependencies_condition(
152 allowed_selector,
153 *scopes,
154 *allow_toolchain,
155 *allow_self,
156 ),
157 ))
158 }
159 Self::Layers {
160 metadata,
161 layers,
162 dependencies,
163 scopes,
164 } => {
165 let layers = compile_named_selectors(layers)?;
166 validate_layer_dependencies(&layers, dependencies)?;
167 let dependencies = dependencies
168 .iter()
169 .map(|dependency| {
170 (
171 dependency.from.clone(),
172 dependency.may_depend_on.iter().cloned().collect(),
173 )
174 })
175 .collect::<BTreeMap<String, BTreeSet<String>>>();
176 Ok(Rule::new(
177 metadata.clone(),
178 DescribedSelector::all(),
179 layers_condition(layers, dependencies, *scopes),
180 ))
181 }
182 Self::NoCycles {
183 metadata,
184 within,
185 grouping,
186 scopes,
187 } => {
188 let within = within.compile()?;
189 let grouping = compile_grouping(grouping)?;
190 Ok(Rule::new(
191 metadata.clone(),
192 DescribedSelector::all(),
193 no_cycles_condition(within, grouping, *scopes),
194 ))
195 }
196 }
197 }
198}
199
200#[derive(Debug, Error)]
201pub enum RuleBuildError {
202 #[error(transparent)]
203 InvalidPattern(#[from] PathPatternError),
204 #[error("rule IDs cannot be empty")]
205 EmptyRuleId,
206 #[error("selector name `{0}` is duplicated")]
207 DuplicateSelector(String),
208 #[error("selector names cannot be empty")]
209 EmptySelectorName,
210 #[error("layer dependency references unknown origin layer `{0}`")]
211 UnknownOriginLayer(String),
212 #[error("layer `{layer}` may depend on unknown layer `{target}`")]
213 UnknownTargetLayer { layer: String, target: String },
214}
215
216fn compile_named_selectors(
217 specs: &[NamedSelectorSpec],
218) -> Result<Vec<(String, DescribedSelector)>, RuleBuildError> {
219 let mut names = HashSet::new();
220 let mut compiled = Vec::with_capacity(specs.len());
221 for spec in specs {
222 if spec.name.trim().is_empty() {
223 return Err(RuleBuildError::EmptySelectorName);
224 }
225 if !names.insert(spec.name.clone()) {
226 return Err(RuleBuildError::DuplicateSelector(spec.name.clone()));
227 }
228 compiled.push((spec.name.clone(), spec.selector.compile()?));
229 }
230 Ok(compiled)
231}
232
233fn validate_layer_dependencies(
234 layers: &[(String, DescribedSelector)],
235 dependencies: &[LayerDependencySpec],
236) -> Result<(), RuleBuildError> {
237 let names = layers
238 .iter()
239 .map(|(name, _)| name.as_str())
240 .collect::<HashSet<_>>();
241 for dependency in dependencies {
242 if !names.contains(dependency.from.as_str()) {
243 return Err(RuleBuildError::UnknownOriginLayer(dependency.from.clone()));
244 }
245 for target in &dependency.may_depend_on {
246 if !names.contains(target.as_str()) {
247 return Err(RuleBuildError::UnknownTargetLayer {
248 layer: dependency.from.clone(),
249 target: target.clone(),
250 });
251 }
252 }
253 }
254 Ok(())
255}
256
257fn forbid_dependencies_condition(
258 target_selector: DescribedSelector,
259 scopes: DependencyScopes,
260) -> DescribedCondition {
261 let description = format!(
262 "not depend on {} through {scopes:?} dependencies",
263 target_selector.description()
264 );
265 DescribedCondition::new(description, move |graph, selected| {
266 let selected = selected.iter().collect::<HashSet<_>>();
267 graph
268 .edges()
269 .filter(|edge| selected.contains(&edge.origin) && scopes.includes(edge.scope))
270 .filter_map(|edge| {
271 let target = graph.component(&edge.target)?;
272 target_selector
273 .matches(Candidate::new(graph, target))
274 .then(|| {
275 let origin_name = graph.component(&edge.origin).map_or_else(
276 || edge.origin.to_string(),
277 |node| node.canonical_name().to_owned(),
278 );
279 ConditionEvent::new(format!(
280 "{origin_name} depends on {}",
281 target.canonical_name()
282 ))
283 .with_edge(
284 edge.origin.clone(),
285 edge.target.clone(),
286 edge.evidence.clone(),
287 )
288 })
289 })
290 .collect()
291 })
292}
293
294fn only_dependencies_condition(
295 allowed_selector: DescribedSelector,
296 scopes: DependencyScopes,
297 allow_toolchain: bool,
298 allow_self: bool,
299) -> DescribedCondition {
300 let description = format!(
301 "only depend on {} through {scopes:?} dependencies",
302 allowed_selector.description()
303 );
304 DescribedCondition::new(description, move |graph, selected| {
305 let selected = selected.iter().collect::<HashSet<_>>();
306 graph
307 .edges()
308 .filter(|edge| selected.contains(&edge.origin) && scopes.includes(edge.scope))
309 .filter_map(|edge| {
310 let origin = graph.component(&edge.origin)?;
311 let target = graph.component(&edge.target)?;
312 if edge.origin == edge.target
313 || (allow_self && same_workspace_package(origin, target))
314 {
315 return None;
316 }
317 if allow_toolchain && target.is_toolchain_crate() {
318 return None;
319 }
320 (!allowed_selector.matches(Candidate::new(graph, target))).then(|| {
321 let origin_name = graph.component(&edge.origin).map_or_else(
322 || edge.origin.to_string(),
323 |node| node.canonical_name().to_owned(),
324 );
325 ConditionEvent::new(format!(
326 "{origin_name} depends on disallowed component {}",
327 target.canonical_name()
328 ))
329 .with_edge(
330 edge.origin.clone(),
331 edge.target.clone(),
332 edge.evidence.clone(),
333 )
334 })
335 })
336 .collect()
337 })
338}
339
340fn same_workspace_package(origin: &Component, target: &Component) -> bool {
341 matches!(origin, Component::Crate(_) | Component::Module(_))
342 && matches!(target, Component::Crate(_) | Component::Module(_))
343 && origin.package_name() == target.package_name()
344}
345
346fn layers_condition(
347 layers: Vec<(String, DescribedSelector)>,
348 dependencies: BTreeMap<String, BTreeSet<String>>,
349 scopes: DependencyScopes,
350) -> DescribedCondition {
351 DescribedCondition::new("respect configured layer dependencies", move |graph, _| {
352 let mut events = Vec::new();
353 for edge in graph.edges().filter(|edge| scopes.includes(edge.scope)) {
354 let (Some(origin), Some(target)) =
355 (graph.component(&edge.origin), graph.component(&edge.target))
356 else {
357 continue;
358 };
359 let origin_layers = matching_names(graph, origin, &layers);
360 let target_layers = matching_names(graph, target, &layers);
361
362 for origin_layer in &origin_layers {
363 for target_layer in &target_layers {
364 if origin_layer == target_layer {
365 continue;
366 }
367 let allowed = dependencies
368 .get(origin_layer)
369 .is_some_and(|targets| targets.contains(target_layer));
370 if !allowed {
371 events.push(
372 ConditionEvent::new(format!(
373 "layer `{origin_layer}` may not depend on layer `{target_layer}`: {} depends on {}",
374 origin.canonical_name(),
375 target.canonical_name()
376 ))
377 .with_edge(
378 edge.origin.clone(),
379 edge.target.clone(),
380 edge.evidence.clone(),
381 ),
382 );
383 }
384 }
385 }
386 }
387 events
388 })
389}
390
391fn matching_names(
392 graph: &ArchitectureGraph,
393 component: &Component,
394 selectors: &[(String, DescribedSelector)],
395) -> Vec<String> {
396 selectors
397 .iter()
398 .filter(|(_, selector)| selector.matches(Candidate::new(graph, component)))
399 .map(|(name, _)| name.clone())
400 .collect()
401}
402
403#[derive(Clone)]
404enum CompiledGrouping {
405 Components,
406 Modules,
407 Crates,
408 Slices(Vec<(String, DescribedSelector)>),
409}
410
411fn compile_grouping(grouping: &CycleGrouping) -> Result<CompiledGrouping, RuleBuildError> {
412 match grouping {
413 CycleGrouping::Components => Ok(CompiledGrouping::Components),
414 CycleGrouping::Modules => Ok(CompiledGrouping::Modules),
415 CycleGrouping::Crates => Ok(CompiledGrouping::Crates),
416 CycleGrouping::Slices { slices } => {
417 Ok(CompiledGrouping::Slices(compile_named_selectors(slices)?))
418 }
419 }
420}
421
422fn no_cycles_condition(
423 within: DescribedSelector,
424 grouping: CompiledGrouping,
425 scopes: DependencyScopes,
426) -> DescribedCondition {
427 DescribedCondition::new("be free of dependency cycles", move |graph, _| {
428 let grouped = GroupedGraph::build(graph, &within, &grouping, scopes);
429 grouped.cycle_events()
430 })
431}
432
433#[derive(Default)]
434struct GroupedGraph {
435 labels: BTreeMap<String, String>,
436 representatives: BTreeMap<String, ComponentId>,
437 adjacency: BTreeMap<String, BTreeSet<String>>,
438 evidence: BTreeMap<(String, String), Vec<DependencyEvidence>>,
439}
440
441impl GroupedGraph {
442 fn build(
443 graph: &ArchitectureGraph,
444 within: &DescribedSelector,
445 grouping: &CompiledGrouping,
446 scopes: DependencyScopes,
447 ) -> Self {
448 let selected = graph
449 .components()
450 .filter(|component| within.matches(Candidate::new(graph, component)))
451 .map(Component::id)
452 .collect::<HashSet<_>>();
453 let selected_crates = selected
454 .iter()
455 .filter_map(|id| graph.component(id)?.containing_crate())
456 .collect::<HashSet<_>>();
457
458 let mut grouped = Self::default();
459 for edge in graph.edges().filter(|edge| scopes.includes(edge.scope)) {
460 let origin_groups =
461 groups_for(graph, &edge.origin, grouping, &selected, &selected_crates);
462 let target_groups =
463 groups_for(graph, &edge.target, grouping, &selected, &selected_crates);
464 for origin in &origin_groups {
465 for target in &target_groups {
466 if origin.key == target.key {
467 continue;
468 }
469 grouped
470 .labels
471 .entry(origin.key.clone())
472 .or_insert_with(|| origin.label.clone());
473 grouped
474 .labels
475 .entry(target.key.clone())
476 .or_insert_with(|| target.label.clone());
477 grouped
478 .representatives
479 .entry(origin.key.clone())
480 .or_insert_with(|| edge.origin.clone());
481 grouped
482 .representatives
483 .entry(target.key.clone())
484 .or_insert_with(|| edge.target.clone());
485 grouped
486 .adjacency
487 .entry(origin.key.clone())
488 .or_default()
489 .insert(target.key.clone());
490 let evidence = grouped
491 .evidence
492 .entry((origin.key.clone(), target.key.clone()))
493 .or_default();
494 for item in &edge.evidence {
495 if !evidence.contains(item) {
496 evidence.push(item.clone());
497 }
498 }
499 }
500 }
501 }
502 grouped
503 }
504
505 fn cycle_events(&self) -> Vec<ConditionEvent> {
506 let mut graph = DiGraph::<String, ()>::new();
507 let mut nodes = BTreeMap::new();
508 for key in self.labels.keys() {
509 nodes.insert(key.clone(), graph.add_node(key.clone()));
510 }
511 for (origin, targets) in &self.adjacency {
512 for target in targets {
513 if let (Some(origin), Some(target)) = (nodes.get(origin), nodes.get(target)) {
514 graph.add_edge(*origin, *target, ());
515 }
516 }
517 }
518
519 let mut strongly_connected = kosaraju_scc(&graph)
520 .into_iter()
521 .filter(|component| component.len() > 1)
522 .map(|component| {
523 let mut keys = component
524 .into_iter()
525 .filter_map(|node| graph.node_weight(node).cloned())
526 .collect::<Vec<_>>();
527 keys.sort();
528 keys
529 })
530 .collect::<Vec<_>>();
531 strongly_connected.sort();
532
533 strongly_connected
534 .into_iter()
535 .filter_map(|component| self.find_cycle(&component))
536 .map(|cycle| {
537 let labels = cycle
538 .iter()
539 .map(|key| self.labels.get(key).unwrap_or(key))
540 .cloned()
541 .collect::<Vec<_>>();
542 let component_cycle = cycle
543 .iter()
544 .filter_map(|key| self.representatives.get(key).cloned())
545 .collect::<Vec<_>>();
546 let evidence = cycle
547 .windows(2)
548 .flat_map(|pair| {
549 self.evidence
550 .get(&(pair[0].clone(), pair[1].clone()))
551 .cloned()
552 .unwrap_or_default()
553 })
554 .collect::<Vec<_>>();
555 let mut event =
556 ConditionEvent::new(format!("dependency cycle: {}", labels.join(" -> ")))
557 .with_cycle(component_cycle);
558 if let Some((origin, target)) = cycle.first().zip(cycle.get(1))
559 && let (Some(origin), Some(target)) = (
560 self.representatives.get(origin),
561 self.representatives.get(target),
562 )
563 {
564 event = event.with_edge(origin.clone(), target.clone(), evidence);
565 }
566 event
567 })
568 .collect()
569 }
570
571 fn find_cycle(&self, component: &[String]) -> Option<Vec<String>> {
572 let members = component.iter().cloned().collect::<BTreeSet<_>>();
573 for start in component {
574 let mut path = vec![start.clone()];
575 let mut active = BTreeSet::from([start.clone()]);
576 if self.find_cycle_from(start, start, &members, &mut active, &mut path) {
577 return Some(path);
578 }
579 }
580 None
581 }
582
583 fn find_cycle_from(
584 &self,
585 start: &str,
586 current: &str,
587 members: &BTreeSet<String>,
588 active: &mut BTreeSet<String>,
589 path: &mut Vec<String>,
590 ) -> bool {
591 let Some(neighbors) = self.adjacency.get(current) else {
592 return false;
593 };
594 for neighbor in neighbors {
595 if !members.contains(neighbor) {
596 continue;
597 }
598 if neighbor == start {
599 path.push(start.to_owned());
600 return true;
601 }
602 if active.insert(neighbor.clone()) {
603 path.push(neighbor.clone());
604 if self.find_cycle_from(start, neighbor, members, active, path) {
605 return true;
606 }
607 path.pop();
608 active.remove(neighbor);
609 }
610 }
611 false
612 }
613}
614
615#[derive(Clone)]
616struct Group {
617 key: String,
618 label: String,
619}
620
621fn groups_for(
622 graph: &ArchitectureGraph,
623 id: &ComponentId,
624 grouping: &CompiledGrouping,
625 selected: &HashSet<ComponentId>,
626 selected_crates: &HashSet<crate::CrateId>,
627) -> Vec<Group> {
628 let Some(component) = graph.component(id) else {
629 return Vec::new();
630 };
631 match grouping {
632 CompiledGrouping::Components => selected
633 .contains(id)
634 .then(|| Group {
635 key: id.to_string(),
636 label: component.canonical_name().to_owned(),
637 })
638 .into_iter()
639 .collect(),
640 CompiledGrouping::Modules => {
641 if selected.contains(id) && matches!(component, Component::Module(_)) {
642 vec![Group {
643 key: id.to_string(),
644 label: component.canonical_name().to_owned(),
645 }]
646 } else {
647 Vec::new()
648 }
649 }
650 CompiledGrouping::Crates => component
651 .containing_crate()
652 .filter(|crate_id| selected_crates.contains(crate_id))
653 .map(|crate_id| {
654 let component_id = ComponentId::Crate(crate_id.clone());
655 let label = graph.component(&component_id).map_or_else(
656 || crate_id.to_string(),
657 |node| node.canonical_name().to_owned(),
658 );
659 Group {
660 key: crate_id.to_string(),
661 label,
662 }
663 })
664 .into_iter()
665 .collect(),
666 CompiledGrouping::Slices(slices) => {
667 if !selected.contains(id) {
668 return Vec::new();
669 }
670 slices
671 .iter()
672 .filter(|(_, selector)| selector.matches(Candidate::new(graph, component)))
673 .map(|(name, _)| Group {
674 key: name.clone(),
675 label: name.clone(),
676 })
677 .collect()
678 }
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use crate::{
686 AnalysisContext, ArchitectureRule, Component, CrateId, CrateNode, DependencyEvidence,
687 DependencyKind, GraphBuilder, ModuleId, ModuleNode, Package, PackageId, TargetKind,
688 };
689 use camino::Utf8PathBuf;
690
691 fn graph_with_module_edges(edges: &[(&str, &str)]) -> ArchitectureGraph {
692 graph_with_scoped_module_edges(
693 edges
694 .iter()
695 .map(|(origin, target)| (*origin, *target, DependencyScope::Actual))
696 .collect::<Vec<_>>()
697 .as_slice(),
698 )
699 }
700
701 fn graph_with_scoped_module_edges(
702 edges: &[(&str, &str, DependencyScope)],
703 ) -> ArchitectureGraph {
704 let package_id = PackageId::new("shop 0.1.0");
705 let crate_id = CrateId::new("shop#lib");
706 let mut builder = GraphBuilder::new(AnalysisContext::default());
707 builder
708 .add_package(Package {
709 id: package_id.clone(),
710 name: "shop".to_owned(),
711 version: Some("0.1.0".to_owned()),
712 manifest_path: Utf8PathBuf::from("Cargo.toml"),
713 })
714 .unwrap();
715 builder
716 .add_component(Component::Crate(CrateNode {
717 id: crate_id.clone(),
718 package_id: package_id.clone(),
719 package_name: "shop".to_owned(),
720 crate_name: "shop".to_owned(),
721 target_name: "shop".to_owned(),
722 target_kind: TargetKind::Library,
723 source_root: Utf8PathBuf::from("src/lib.rs"),
724 }))
725 .unwrap();
726
727 for name in ["domain", "persistence", "api"] {
728 builder
729 .add_component(Component::Module(ModuleNode {
730 id: ModuleId::new(format!("shop#lib::{name}")),
731 crate_id: crate_id.clone(),
732 package_id: package_id.clone(),
733 package_name: "shop".to_owned(),
734 crate_name: "shop".to_owned(),
735 path: format!("shop::{name}"),
736 parent: None,
737 source_file: Utf8PathBuf::from(format!("src/{name}.rs")),
738 }))
739 .unwrap();
740 }
741 for (origin, target, scope) in edges {
742 builder
743 .add_dependency(
744 ComponentId::Module(ModuleId::new(format!("shop#lib::{origin}"))),
745 ComponentId::Module(ModuleId::new(format!("shop#lib::{target}"))),
746 *scope,
747 DependencyEvidence::new(DependencyKind::Use),
748 )
749 .unwrap();
750 }
751 builder.finish()
752 }
753
754 #[test]
755 fn forbidden_dependency_reports_edge() {
756 let graph = graph_with_module_edges(&[("domain", "persistence")]);
757 let rule = BuiltInRuleSpec::ForbidDependencies {
758 metadata: RuleMetadata::new("domain-is-internal"),
759 from: SelectorSpec::Modules {
760 patterns: vec!["shop::domain".to_owned()],
761 },
762 to: SelectorSpec::Modules {
763 patterns: vec!["shop::persistence".to_owned()],
764 },
765 scopes: DependencyScopes::Actual,
766 }
767 .compile()
768 .unwrap();
769
770 let result = rule.evaluate(&graph);
771 assert_eq!(result.violations.len(), 1);
772 assert!(result.violations[0].message.contains("persistence"));
773 }
774
775 #[test]
776 fn dependency_scope_is_respected() {
777 let graph =
778 graph_with_scoped_module_edges(&[("domain", "persistence", DependencyScope::Declared)]);
779 let rule = BuiltInRuleSpec::ForbidDependencies {
780 metadata: RuleMetadata::new("actual-only"),
781 from: SelectorSpec::Modules {
782 patterns: vec!["shop::domain".to_owned()],
783 },
784 to: SelectorSpec::Modules {
785 patterns: vec!["shop::persistence".to_owned()],
786 },
787 scopes: DependencyScopes::Actual,
788 }
789 .compile()
790 .unwrap();
791
792 assert!(rule.evaluate(&graph).violations.is_empty());
793 }
794
795 #[test]
796 fn only_dependencies_reports_disallowed_target() {
797 let graph = graph_with_module_edges(&[("domain", "persistence")]);
798 let rule = BuiltInRuleSpec::OnlyDependencies {
799 metadata: RuleMetadata::new("domain-dependencies"),
800 from: SelectorSpec::Modules {
801 patterns: vec!["shop::domain".to_owned()],
802 },
803 allowed: SelectorSpec::Modules {
804 patterns: vec!["shop::domain::**".to_owned()],
805 },
806 scopes: DependencyScopes::Actual,
807 allow_toolchain: true,
808 allow_self: false,
809 }
810 .compile()
811 .unwrap();
812
813 assert_eq!(rule.evaluate(&graph).violations.len(), 1);
814 }
815
816 #[test]
817 fn only_dependencies_can_allow_same_package_edges() {
818 let graph = graph_with_module_edges(&[("domain", "persistence")]);
819 let rule = BuiltInRuleSpec::OnlyDependencies {
820 metadata: RuleMetadata::new("domain-dependencies"),
821 from: SelectorSpec::Modules {
822 patterns: vec!["shop::domain".to_owned()],
823 },
824 allowed: SelectorSpec::Modules {
825 patterns: Vec::new(),
826 },
827 scopes: DependencyScopes::Actual,
828 allow_toolchain: true,
829 allow_self: true,
830 }
831 .compile()
832 .unwrap();
833
834 assert!(rule.evaluate(&graph).violations.is_empty());
835 }
836
837 #[test]
838 fn layered_rule_enforces_direction() {
839 let graph = graph_with_module_edges(&[("domain", "persistence")]);
840 let rule = BuiltInRuleSpec::Layers {
841 metadata: RuleMetadata::new("layers"),
842 layers: vec![
843 NamedSelectorSpec {
844 name: "domain".to_owned(),
845 selector: SelectorSpec::Modules {
846 patterns: vec!["shop::domain".to_owned()],
847 },
848 },
849 NamedSelectorSpec {
850 name: "persistence".to_owned(),
851 selector: SelectorSpec::Modules {
852 patterns: vec!["shop::persistence".to_owned()],
853 },
854 },
855 ],
856 dependencies: vec![LayerDependencySpec {
857 from: "persistence".to_owned(),
858 may_depend_on: vec!["domain".to_owned()],
859 }],
860 scopes: DependencyScopes::Actual,
861 }
862 .compile()
863 .unwrap();
864
865 let result = rule.evaluate(&graph);
866 assert_eq!(result.violations.len(), 1);
867 assert!(result.violations[0].message.contains("domain"));
868 }
869
870 #[test]
871 fn cycle_rule_reports_deterministic_path() {
872 let graph = graph_with_module_edges(&[
873 ("domain", "persistence"),
874 ("persistence", "api"),
875 ("api", "domain"),
876 ]);
877 let rule = BuiltInRuleSpec::NoCycles {
878 metadata: RuleMetadata::new("modules-are-acyclic"),
879 within: SelectorSpec::Modules {
880 patterns: vec!["shop::**".to_owned()],
881 },
882 grouping: CycleGrouping::Modules,
883 scopes: DependencyScopes::Actual,
884 }
885 .compile()
886 .unwrap();
887
888 let result = rule.evaluate(&graph);
889 assert_eq!(result.violations.len(), 1);
890 assert_eq!(result.violations[0].cycle.len(), 4);
891 }
892
893 #[test]
894 fn named_slices_can_be_checked_for_cycles() {
895 let graph = graph_with_module_edges(&[
896 ("domain", "persistence"),
897 ("persistence", "api"),
898 ("api", "domain"),
899 ]);
900 let slices = ["domain", "persistence", "api"]
901 .into_iter()
902 .map(|name| NamedSelectorSpec {
903 name: name.to_owned(),
904 selector: SelectorSpec::Modules {
905 patterns: vec![format!("shop::{name}")],
906 },
907 })
908 .collect();
909 let rule = BuiltInRuleSpec::NoCycles {
910 metadata: RuleMetadata::new("slices-are-acyclic"),
911 within: SelectorSpec::Modules {
912 patterns: vec!["shop::**".to_owned()],
913 },
914 grouping: CycleGrouping::Slices { slices },
915 scopes: DependencyScopes::Actual,
916 }
917 .compile()
918 .unwrap();
919
920 assert_eq!(rule.evaluate(&graph).violations.len(), 1);
921 }
922}