Skip to main content

bylaw_core/
builtin.rs

1//! Built-in architecture rules expressed through the public rule engine.
2
3use 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}