1use std::fmt;
4use std::path::Path;
5
6use globset::Glob;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[expect(
11 clippy::trivially_copy_pass_by_ref,
12 reason = "serde skip_serializing_if predicates receive field references"
13)]
14fn is_false(value: &bool) -> bool {
15 !*value
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ZoneReferenceKind {
21 From,
23 Allow,
25 AllowTypeOnly,
27 CallsFrom,
29}
30
31impl ZoneReferenceKind {
32 fn config_field(self) -> &'static str {
33 match self {
34 Self::From | Self::CallsFrom => "from",
35 Self::Allow => "allow",
36 Self::AllowTypeOnly => "allowTypeOnly",
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct UnknownZoneRef {
44 pub rule_index: usize,
46 pub kind: ZoneReferenceKind,
48 pub zone_name: String,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct RedundantRootPrefix {
55 pub zone_name: String,
57 pub pattern: String,
59 pub root: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct InvalidForbiddenCallee {
66 pub rule_index: usize,
68 pub pattern: String,
70 pub reason: String,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum ZoneValidationError {
77 UnknownZoneReference(UnknownZoneRef),
79 RedundantRootPrefix(RedundantRootPrefix),
81 InvalidForbiddenCallee(InvalidForbiddenCallee),
83}
84
85impl fmt::Display for ZoneValidationError {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 match self {
88 Self::UnknownZoneReference(err) if err.kind == ZoneReferenceKind::CallsFrom => {
89 write!(
90 f,
91 "boundaries.calls.forbidden[{}].from: references undefined zone '{}'",
92 err.rule_index, err.zone_name,
93 )
94 }
95 Self::UnknownZoneReference(err) => write!(
96 f,
97 "boundaries.rules[{}].{}: references undefined zone '{}'",
98 err.rule_index,
99 err.kind.config_field(),
100 err.zone_name,
101 ),
102 Self::InvalidForbiddenCallee(err) => write!(
103 f,
104 "boundaries.calls.forbidden[{}].callee: pattern '{}' {}",
105 err.rule_index, err.pattern, err.reason,
106 ),
107 Self::RedundantRootPrefix(err) => write!(
108 f,
109 "FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX: zone '{}': pattern '{}' starts with the zone root '{}'. Patterns are now resolved relative to root; remove the redundant prefix from the pattern.",
110 err.zone_name, err.pattern, err.root,
111 ),
112 }
113 }
114}
115
116impl std::error::Error for ZoneValidationError {}
117
118#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
120#[serde(rename_all = "kebab-case")]
121pub enum BoundaryPreset {
122 Layered,
124 Hexagonal,
126 FeatureSliced,
128 Bulletproof,
130}
131
132impl BoundaryPreset {
133 #[must_use]
139 pub const fn all() -> &'static [BoundaryPreset] {
140 &[
141 Self::Layered,
142 Self::Hexagonal,
143 Self::FeatureSliced,
144 Self::Bulletproof,
145 ]
146 }
147
148 #[must_use]
150 pub const fn name(&self) -> &'static str {
151 match self {
152 Self::Layered => "layered",
153 Self::Hexagonal => "hexagonal",
154 Self::FeatureSliced => "feature-sliced",
155 Self::Bulletproof => "bulletproof",
156 }
157 }
158
159 #[must_use]
161 pub const fn description(&self) -> &'static str {
162 match self {
163 Self::Layered => {
164 "Classic layers: presentation depends on application depends on domain; \
165 infrastructure depends on domain and application."
166 }
167 Self::Hexagonal => {
168 "Ports and adapters: adapters depend on ports, ports depend on the domain; \
169 the domain depends on nothing outward."
170 }
171 Self::FeatureSliced => {
172 "Feature-Sliced Design: app, pages, widgets, features, entities, shared; \
173 each layer may only import from the layers below it."
174 }
175 Self::Bulletproof => {
176 "Bulletproof React: app and features depend on shared and server; \
177 features stay isolated from each other."
178 }
179 }
180 }
181
182 #[must_use]
184 pub fn default_config(&self, source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
185 match self {
186 Self::Layered => Self::layered_config(source_root),
187 Self::Hexagonal => Self::hexagonal_config(source_root),
188 Self::FeatureSliced => Self::feature_sliced_config(source_root),
189 Self::Bulletproof => Self::bulletproof_config(source_root),
190 }
191 }
192
193 fn zone(name: &str, source_root: &str) -> BoundaryZone {
194 BoundaryZone {
195 name: name.to_owned(),
196 patterns: vec![format!("{source_root}/{name}/**")],
197 auto_discover: vec![],
198 root: None,
199 }
200 }
201
202 fn rule(from: &str, allow: &[&str]) -> BoundaryRule {
203 BoundaryRule {
204 from: from.to_owned(),
205 allow: allow.iter().map(|s| (*s).to_owned()).collect(),
206 allow_type_only: Vec::new(),
207 }
208 }
209
210 fn layered_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
211 let zones = vec![
212 Self::zone("presentation", source_root),
213 Self::zone("application", source_root),
214 Self::zone("domain", source_root),
215 Self::zone("infrastructure", source_root),
216 ];
217 let rules = vec![
218 Self::rule("presentation", &["application"]),
219 Self::rule("application", &["domain"]),
220 Self::rule("domain", &[]),
221 Self::rule("infrastructure", &["domain", "application"]),
222 ];
223 (zones, rules)
224 }
225
226 fn hexagonal_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
227 let zones = vec![
228 Self::zone("adapters", source_root),
229 Self::zone("ports", source_root),
230 Self::zone("domain", source_root),
231 ];
232 let rules = vec![
233 Self::rule("adapters", &["ports"]),
234 Self::rule("ports", &["domain"]),
235 Self::rule("domain", &[]),
236 ];
237 (zones, rules)
238 }
239
240 fn feature_sliced_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
241 let layer_names = ["app", "pages", "widgets", "features", "entities", "shared"];
242 let zones = layer_names
243 .iter()
244 .map(|name| Self::zone(name, source_root))
245 .collect();
246 let rules = layer_names
247 .iter()
248 .enumerate()
249 .map(|(i, name)| {
250 let below: Vec<&str> = layer_names[i + 1..].to_vec();
251 Self::rule(name, &below)
252 })
253 .collect();
254 (zones, rules)
255 }
256
257 fn bulletproof_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
258 let zones = vec![
259 Self::zone("app", source_root),
260 BoundaryZone {
261 name: "features".to_owned(),
262 patterns: vec![format!("{source_root}/features/**")],
263 auto_discover: vec![format!("{source_root}/features")],
264 root: None,
265 },
266 BoundaryZone {
267 name: "shared".to_owned(),
268 patterns: [
269 "components",
270 "hooks",
271 "lib",
272 "utils",
273 "utilities",
274 "providers",
275 "shared",
276 "types",
277 "styles",
278 "i18n",
279 ]
280 .iter()
281 .map(|dir| format!("{source_root}/{dir}/**"))
282 .collect(),
283 auto_discover: vec![],
284 root: None,
285 },
286 Self::zone("server", source_root),
287 ];
288 let rules = vec![
289 Self::rule("app", &["features", "shared", "server"]),
290 Self::rule("features", &["shared", "server"]),
291 Self::rule("server", &["shared"]),
292 Self::rule("shared", &[]),
293 ];
294 (zones, rules)
295 }
296}
297
298#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
300#[serde(rename_all = "camelCase")]
301pub struct BoundaryConfig {
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub preset: Option<BoundaryPreset>,
305 #[serde(default)]
307 pub zones: Vec<BoundaryZone>,
308 #[serde(default)]
310 pub rules: Vec<BoundaryRule>,
311 #[serde(default, skip_serializing_if = "BoundaryCoverageConfig::is_default")]
313 pub coverage: BoundaryCoverageConfig,
314 #[serde(default, skip_serializing_if = "BoundaryCallsConfig::is_default")]
316 pub calls: BoundaryCallsConfig,
317}
318
319#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
321#[serde(rename_all = "camelCase")]
322pub struct BoundaryCoverageConfig {
323 #[serde(default, skip_serializing_if = "is_false")]
325 pub require_all_files: bool,
326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub allow_unmatched: Vec<String>,
329}
330
331impl BoundaryCoverageConfig {
332 fn is_default(value: &Self) -> bool {
333 !value.require_all_files && value.allow_unmatched.is_empty()
334 }
335}
336
337#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
340#[serde(rename_all = "camelCase")]
341pub struct BoundaryCallsConfig {
342 #[serde(default, skip_serializing_if = "Vec::is_empty")]
344 pub forbidden: Vec<ForbiddenCallRule>,
345}
346
347impl BoundaryCallsConfig {
348 fn is_default(value: &Self) -> bool {
349 value.forbidden.is_empty()
350 }
351
352 #[must_use]
354 pub fn is_empty(&self) -> bool {
355 self.forbidden.is_empty()
356 }
357}
358
359#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
362#[serde(rename_all = "camelCase")]
363pub struct ForbiddenCallRule {
364 pub from: String,
366 pub callee: ForbiddenCallee,
371}
372
373#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
375#[serde(untagged)]
376pub enum ForbiddenCallee {
377 Single(String),
379 Many(Vec<String>),
381}
382
383impl ForbiddenCallee {
384 pub fn iter(&self) -> impl Iterator<Item = &str> {
386 match self {
387 Self::Single(pattern) => std::slice::from_ref(pattern),
388 Self::Many(patterns) => patterns.as_slice(),
389 }
390 .iter()
391 .map(String::as_str)
392 }
393}
394
395#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
397#[serde(rename_all = "camelCase")]
398pub struct BoundaryZone {
399 pub name: String,
401 #[serde(default, skip_serializing_if = "Vec::is_empty")]
403 pub patterns: Vec<String>,
404 #[serde(default, skip_serializing_if = "Vec::is_empty")]
406 pub auto_discover: Vec<String>,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub root: Option<String>,
410}
411
412#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
414#[serde(rename_all = "camelCase")]
415pub struct BoundaryRule {
416 pub from: String,
418 #[serde(default)]
420 pub allow: Vec<String>,
421 #[serde(default, skip_serializing_if = "Vec::is_empty")]
423 pub allow_type_only: Vec<String>,
424}
425
426#[derive(Debug, Clone, Default)]
428pub struct ResolvedBoundaryConfig {
429 pub zones: Vec<ResolvedZone>,
431 pub rules: Vec<ResolvedBoundaryRule>,
433 pub logical_groups: Vec<LogicalGroup>,
435 pub coverage: ResolvedBoundaryCoverageConfig,
437 pub calls_forbidden_by_zone: rustc_hash::FxHashMap<String, Vec<String>>,
441}
442
443#[derive(Debug, Clone, Default)]
445pub struct ResolvedBoundaryCoverageConfig {
446 pub require_all_files: bool,
448 pub allow_unmatched: Vec<globset::GlobMatcher>,
450}
451
452#[derive(Debug, Clone, Serialize, JsonSchema)]
454#[serde(rename_all = "snake_case")]
455pub struct LogicalGroup {
456 pub name: String,
458 pub children: Vec<String>,
460 pub auto_discover: Vec<String>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub authored_rule: Option<AuthoredRule>,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub fallback_zone: Option<String>,
468 pub source_zone_index: usize,
470 pub status: LogicalGroupStatus,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub merged_from: Option<Vec<usize>>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub original_zone_root: Option<String>,
478 #[serde(default, skip_serializing_if = "Vec::is_empty")]
480 pub child_source_indices: Vec<usize>,
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
485#[serde(rename_all = "snake_case")]
486pub enum LogicalGroupStatus {
487 Ok,
489 Empty,
491 InvalidPath,
493}
494
495#[derive(Debug, Clone, Serialize, JsonSchema)]
497pub struct AuthoredRule {
498 pub allow: Vec<String>,
500 #[serde(default, skip_serializing_if = "Vec::is_empty")]
502 pub allow_type_only: Vec<String>,
503}
504
505#[derive(Debug, Clone)]
507pub struct ResolvedZone {
508 pub name: String,
510 pub patterns: Vec<String>,
512 pub matchers: Vec<globset::GlobMatcher>,
514 pub root: Option<String>,
516}
517
518#[derive(Debug, Clone)]
520pub struct ResolvedBoundaryRule {
521 pub from_zone: String,
523 pub allowed_zones: Vec<String>,
525 pub allow_type_only_zones: Vec<String>,
527}
528
529impl BoundaryConfig {
530 #[must_use]
532 pub fn is_empty(&self) -> bool {
533 self.preset.is_none()
534 && self.zones.is_empty()
535 && !self.coverage.require_all_files
536 && self.calls.is_empty()
537 }
538
539 pub fn expand(&mut self, source_root: &str) {
541 let Some(preset) = self.preset.take() else {
542 return;
543 };
544
545 let (preset_zones, preset_rules) = preset.default_config(source_root);
546
547 let user_zone_names: rustc_hash::FxHashSet<&str> =
548 self.zones.iter().map(|z| z.name.as_str()).collect();
549
550 let mut merged_zones: Vec<BoundaryZone> = preset_zones
551 .into_iter()
552 .filter(|pz| {
553 if user_zone_names.contains(pz.name.as_str()) {
554 tracing::info!(
555 "boundary preset: user zone '{}' replaces preset zone",
556 pz.name
557 );
558 false
559 } else {
560 true
561 }
562 })
563 .collect();
564 merged_zones.append(&mut self.zones);
565 self.zones = merged_zones;
566
567 let user_rule_sources: rustc_hash::FxHashSet<&str> =
568 self.rules.iter().map(|r| r.from.as_str()).collect();
569
570 let mut merged_rules: Vec<BoundaryRule> = preset_rules
571 .into_iter()
572 .filter(|pr| {
573 if user_rule_sources.contains(pr.from.as_str()) {
574 tracing::info!(
575 "boundary preset: user rule for '{}' replaces preset rule",
576 pr.from
577 );
578 false
579 } else {
580 true
581 }
582 })
583 .collect();
584 merged_rules.append(&mut self.rules);
585 self.rules = merged_rules;
586 }
587
588 pub fn expand_auto_discover(&mut self, project_root: &Path) -> Vec<LogicalGroup> {
590 if self.zones.iter().all(|zone| zone.auto_discover.is_empty()) {
591 return Vec::new();
592 }
593
594 let original_zones = std::mem::take(&mut self.zones);
595 let mut expanded_zones = Vec::new();
596 let mut group_expansions: rustc_hash::FxHashMap<String, Vec<String>> =
597 rustc_hash::FxHashMap::default();
598 let mut group_drafts: Vec<LogicalGroupDraft> = Vec::new();
599
600 for (source_zone_index, zone) in original_zones.into_iter().enumerate() {
601 if zone.auto_discover.is_empty() {
602 expanded_zones.push(zone);
603 continue;
604 }
605
606 let expansion = expand_auto_discover_zone(
607 project_root,
608 zone,
609 source_zone_index,
610 &mut expanded_zones,
611 );
612 if !expansion.expanded_names.is_empty() {
613 group_expansions
614 .entry(expansion.group_name.clone())
615 .or_default()
616 .extend(expansion.expanded_names);
617 }
618 merge_logical_group_draft(&mut group_drafts, expansion.draft);
619 }
620
621 self.zones = expanded_zones;
622
623 let original_rules = std::mem::take(&mut self.rules);
624 let logical_groups = build_logical_groups_from_drafts(group_drafts, &original_rules);
625
626 if group_expansions.is_empty() {
627 self.rules = original_rules;
628 return logical_groups;
629 }
630
631 self.rules = expand_rules_for_groups(original_rules, &group_expansions);
632 logical_groups
633 }
634}
635
636struct AutoDiscoverExpansion {
637 group_name: String,
638 expanded_names: Vec<String>,
639 draft: LogicalGroupDraft,
640}
641
642fn expand_auto_discover_zone(
643 project_root: &Path,
644 mut zone: BoundaryZone,
645 source_zone_index: usize,
646 expanded_zones: &mut Vec<BoundaryZone>,
647) -> AutoDiscoverExpansion {
648 let group_name = zone.name.clone();
649 let raw_auto_discover = zone.auto_discover.clone();
650 let original_zone_root = zone.root.clone();
651 let DiscoveryOutcome {
652 zones: discovered_zones,
653 source_indices: discovered_source_indices,
654 had_invalid_path,
655 } = discover_child_zones(project_root, &zone);
656 let status = discovery_status(discovered_zones.len(), had_invalid_path);
657 let mut expanded_names: Vec<String> = discovered_zones
658 .iter()
659 .map(|child| child.name.clone())
660 .collect();
661 let child_names_only = expanded_names.clone();
662
663 for child_zone in discovered_zones {
664 merge_zone_by_name(expanded_zones, child_zone);
665 }
666
667 let fallback_zone = merge_fallback_auto_discover_zone(&mut zone, &group_name, expanded_zones);
668 if fallback_zone.is_some() {
669 expanded_names.push(group_name.clone());
670 }
671
672 AutoDiscoverExpansion {
673 group_name: group_name.clone(),
674 expanded_names,
675 draft: LogicalGroupDraft {
676 name: group_name,
677 children: child_names_only,
678 auto_discover: raw_auto_discover,
679 fallback_zone,
680 source_zone_index,
681 status,
682 merged_from: None,
683 original_zone_root,
684 child_source_indices: discovered_source_indices,
685 },
686 }
687}
688
689const fn discovery_status(discovered_count: usize, had_invalid_path: bool) -> LogicalGroupStatus {
690 if discovered_count > 0 {
691 LogicalGroupStatus::Ok
692 } else if had_invalid_path {
693 LogicalGroupStatus::InvalidPath
694 } else {
695 LogicalGroupStatus::Empty
696 }
697}
698
699fn merge_fallback_auto_discover_zone(
700 zone: &mut BoundaryZone,
701 group_name: &str,
702 expanded_zones: &mut Vec<BoundaryZone>,
703) -> Option<String> {
704 if zone.patterns.is_empty() {
705 return None;
706 }
707 zone.auto_discover.clear();
708 merge_zone_by_name(expanded_zones, zone.clone());
709 Some(group_name.to_owned())
710}
711
712fn merge_logical_group_draft(group_drafts: &mut Vec<LogicalGroupDraft>, draft: LogicalGroupDraft) {
713 let Some(existing) = group_drafts.iter_mut().find(|d| d.name == draft.name) else {
714 group_drafts.push(draft);
715 return;
716 };
717
718 tracing::warn!(
719 "boundary zone '{}' is declared multiple times with autoDiscover; merging discovered children",
720 draft.name
721 );
722 let auto_discover_offset = existing.auto_discover.len();
723 merge_logical_group_children(existing, &draft, auto_discover_offset);
724 existing.auto_discover.extend(draft.auto_discover);
725 if existing.fallback_zone.is_none() {
726 existing.fallback_zone = draft.fallback_zone;
727 }
728 existing.status = merge_status(existing.status, draft.status);
729 let chain = existing
730 .merged_from
731 .get_or_insert_with(|| vec![existing.source_zone_index]);
732 chain.push(draft.source_zone_index);
733}
734
735fn merge_logical_group_children(
736 existing: &mut LogicalGroupDraft,
737 draft: &LogicalGroupDraft,
738 auto_discover_offset: usize,
739) {
740 let existing_children: rustc_hash::FxHashSet<String> =
741 existing.children.iter().cloned().collect();
742 for (idx, name) in draft.children.iter().enumerate() {
743 if existing_children.contains(name) {
744 continue;
745 }
746 existing.children.push(name.clone());
747 existing
748 .child_source_indices
749 .push(draft.child_source_indices[idx] + auto_discover_offset);
750 }
751}
752
753fn build_logical_groups_from_drafts(
754 group_drafts: Vec<LogicalGroupDraft>,
755 original_rules: &[BoundaryRule],
756) -> Vec<LogicalGroup> {
757 let authored_rules = authored_rules_for_logical_groups(&group_drafts, original_rules);
758
759 group_drafts
760 .into_iter()
761 .map(|draft| {
762 let child_source_indices = if draft.auto_discover.len() > 1 {
763 draft.child_source_indices
764 } else {
765 Vec::new()
766 };
767 LogicalGroup {
768 authored_rule: authored_rules.get(draft.name.as_str()).cloned(),
769 name: draft.name,
770 children: draft.children,
771 auto_discover: draft.auto_discover,
772 fallback_zone: draft.fallback_zone,
773 source_zone_index: draft.source_zone_index,
774 status: draft.status,
775 merged_from: draft.merged_from,
776 original_zone_root: draft.original_zone_root,
777 child_source_indices,
778 }
779 })
780 .collect()
781}
782
783fn authored_rules_for_logical_groups<'a>(
784 group_drafts: &[LogicalGroupDraft],
785 original_rules: &'a [BoundaryRule],
786) -> rustc_hash::FxHashMap<&'a str, AuthoredRule> {
787 let draft_names: rustc_hash::FxHashSet<&str> =
788 group_drafts.iter().map(|d| d.name.as_str()).collect();
789 original_rules
790 .iter()
791 .filter(|rule| draft_names.contains(rule.from.as_str()))
792 .map(|rule| {
793 (
794 rule.from.as_str(),
795 AuthoredRule {
796 allow: rule.allow.clone(),
797 allow_type_only: rule.allow_type_only.clone(),
798 },
799 )
800 })
801 .collect()
802}
803
804fn merge_zone_by_name(expanded_zones: &mut Vec<BoundaryZone>, zone: BoundaryZone) {
806 if let Some(existing) = expanded_zones.iter_mut().find(|z| z.name == zone.name) {
807 for pattern in zone.patterns {
808 if !existing.patterns.contains(&pattern) {
809 existing.patterns.push(pattern);
810 }
811 }
812 } else {
813 expanded_zones.push(zone);
814 }
815}
816
817fn expand_rules_for_groups(
819 original_rules: Vec<BoundaryRule>,
820 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
821) -> Vec<BoundaryRule> {
822 let mut generated_rules = Vec::new();
823 let mut explicit_rules = Vec::new();
824 for rule in original_rules {
825 let allow = expand_rule_allow(&rule.allow, group_expansions);
826 let allow_type_only = expand_rule_allow(&rule.allow_type_only, group_expansions);
827
828 if let Some(from_zones) = group_expansions.get(&rule.from) {
829 for from in from_zones {
830 let (allow, allow_type_only) = if from == &rule.from {
831 (
832 expand_parent_fallback_allow(&allow, from_zones, &rule.from),
833 allow_type_only.clone(),
834 )
835 } else {
836 (
837 expand_generated_child_allow(&rule.allow, group_expansions, &rule.from),
838 expand_generated_child_allow(
839 &rule.allow_type_only,
840 group_expansions,
841 &rule.from,
842 ),
843 )
844 };
845 let expanded_rule = BoundaryRule {
846 from: from.clone(),
847 allow,
848 allow_type_only,
849 };
850 if from == &rule.from {
851 explicit_rules.push(expanded_rule);
852 } else {
853 generated_rules.push(expanded_rule);
854 }
855 }
856 } else {
857 explicit_rules.push(BoundaryRule {
858 from: rule.from,
859 allow,
860 allow_type_only,
861 });
862 }
863 }
864
865 let mut expanded_rules = dedupe_rules_keep_last(generated_rules);
866 expanded_rules.extend(dedupe_rules_keep_last(explicit_rules));
867 dedupe_rules_keep_last(expanded_rules)
868}
869
870impl BoundaryConfig {
871 #[must_use]
873 pub fn preset_name(&self) -> Option<&str> {
874 self.preset.as_ref().map(|p| match p {
875 BoundaryPreset::Layered => "layered",
876 BoundaryPreset::Hexagonal => "hexagonal",
877 BoundaryPreset::FeatureSliced => "feature-sliced",
878 BoundaryPreset::Bulletproof => "bulletproof",
879 })
880 }
881
882 #[must_use]
884 pub fn validate_root_prefixes(&self) -> Vec<RedundantRootPrefix> {
885 let mut errors = Vec::new();
886 for zone in &self.zones {
887 let Some(raw_root) = zone.root.as_deref() else {
888 continue;
889 };
890 let normalized = normalize_zone_root(raw_root);
891 if normalized.is_empty() {
892 continue;
893 }
894 for pattern in &zone.patterns {
895 let normalized_pattern = pattern.replace('\\', "/");
896 let stripped = normalized_pattern
897 .strip_prefix("./")
898 .unwrap_or(&normalized_pattern);
899 if stripped.starts_with(&normalized) {
900 errors.push(RedundantRootPrefix {
901 zone_name: zone.name.clone(),
902 pattern: pattern.clone(),
903 root: normalized.clone(),
904 });
905 }
906 }
907 }
908 errors
909 }
910
911 #[must_use]
913 pub fn validate_zone_references(&self) -> Vec<UnknownZoneRef> {
914 let zone_names: rustc_hash::FxHashSet<&str> =
915 self.zones.iter().map(|z| z.name.as_str()).collect();
916
917 let mut errors = Vec::new();
918 for (i, rule) in self.rules.iter().enumerate() {
919 if !zone_names.contains(rule.from.as_str()) {
920 errors.push(UnknownZoneRef {
921 rule_index: i,
922 kind: ZoneReferenceKind::From,
923 zone_name: rule.from.clone(),
924 });
925 }
926 for allowed in &rule.allow {
927 if !zone_names.contains(allowed.as_str()) {
928 errors.push(UnknownZoneRef {
929 rule_index: i,
930 kind: ZoneReferenceKind::Allow,
931 zone_name: allowed.clone(),
932 });
933 }
934 }
935 for allowed_type_only in &rule.allow_type_only {
936 if !zone_names.contains(allowed_type_only.as_str()) {
937 errors.push(UnknownZoneRef {
938 rule_index: i,
939 kind: ZoneReferenceKind::AllowTypeOnly,
940 zone_name: allowed_type_only.clone(),
941 });
942 }
943 }
944 }
945 for (i, rule) in self.calls.forbidden.iter().enumerate() {
946 if !zone_names.contains(rule.from.as_str()) {
947 errors.push(UnknownZoneRef {
948 rule_index: i,
949 kind: ZoneReferenceKind::CallsFrom,
950 zone_name: rule.from.clone(),
951 });
952 }
953 }
954 errors
955 }
956
957 #[must_use]
963 pub fn validate_call_rules(&self) -> Vec<InvalidForbiddenCallee> {
964 let mut errors = Vec::new();
965 for (i, rule) in self.calls.forbidden.iter().enumerate() {
966 if rule.callee.iter().next().is_none() {
967 errors.push(InvalidForbiddenCallee {
968 rule_index: i,
969 pattern: String::new(),
970 reason: "must list at least one callee pattern".to_owned(),
971 });
972 continue;
973 }
974 for pattern in rule.callee.iter() {
975 let trimmed = pattern.trim();
976 if trimmed.is_empty() {
977 errors.push(InvalidForbiddenCallee {
978 rule_index: i,
979 pattern: pattern.to_owned(),
980 reason: "must not be empty".to_owned(),
981 });
982 } else if trimmed == "*" {
983 errors.push(InvalidForbiddenCallee {
984 rule_index: i,
985 pattern: pattern.to_owned(),
986 reason: "matches nothing: a bare `*` has no callee segments. Name a \
987 specific callee such as `console.*` or `child_process.exec`"
988 .to_owned(),
989 });
990 } else if trimmed.split('.').any(|segment| segment.trim().is_empty()) {
991 errors.push(InvalidForbiddenCallee {
992 rule_index: i,
993 pattern: pattern.to_owned(),
994 reason: "contains an empty path segment".to_owned(),
995 });
996 } else if let Some(reason) = wildcard_placement_error(trimmed) {
997 errors.push(InvalidForbiddenCallee {
998 rule_index: i,
999 pattern: pattern.to_owned(),
1000 reason,
1001 });
1002 }
1003 }
1004 }
1005 errors
1006 }
1007
1008 #[must_use]
1010 pub fn resolve(&self) -> ResolvedBoundaryConfig {
1011 let rules = self
1012 .rules
1013 .iter()
1014 .map(|rule| ResolvedBoundaryRule {
1015 from_zone: rule.from.clone(),
1016 allowed_zones: rule.allow.clone(),
1017 allow_type_only_zones: rule.allow_type_only.clone(),
1018 })
1019 .collect();
1020
1021 ResolvedBoundaryConfig {
1022 zones: self.resolve_zones(),
1023 rules,
1024 logical_groups: Vec::new(),
1025 coverage: self.resolve_coverage(),
1026 calls_forbidden_by_zone: self.resolve_calls_forbidden_by_zone(),
1027 }
1028 }
1029
1030 #[expect(
1032 clippy::expect_used,
1033 reason = "boundary glob patterns are validated before config resolution"
1034 )]
1035 fn resolve_zones(&self) -> Vec<ResolvedZone> {
1036 self.zones
1037 .iter()
1038 .map(|zone| {
1039 let matchers = zone
1040 .patterns
1041 .iter()
1042 .map(|pattern| {
1043 Glob::new(pattern)
1044 .expect("boundaries.zones[].patterns was validated at config load time")
1045 .compile_matcher()
1046 })
1047 .collect();
1048 let root = zone.root.as_deref().map(normalize_zone_root);
1049 ResolvedZone {
1050 name: zone.name.clone(),
1051 patterns: zone.patterns.clone(),
1052 matchers,
1053 root,
1054 }
1055 })
1056 .collect()
1057 }
1058
1059 #[expect(
1061 clippy::expect_used,
1062 reason = "boundary glob patterns are validated before config resolution"
1063 )]
1064 fn resolve_coverage(&self) -> ResolvedBoundaryCoverageConfig {
1065 ResolvedBoundaryCoverageConfig {
1066 require_all_files: self.coverage.require_all_files,
1067 allow_unmatched: self
1068 .coverage
1069 .allow_unmatched
1070 .iter()
1071 .map(|pattern| {
1072 Glob::new(pattern)
1073 .expect(
1074 "boundaries.coverage.allowUnmatched was validated at config load time",
1075 )
1076 .compile_matcher()
1077 })
1078 .collect(),
1079 }
1080 }
1081
1082 fn resolve_calls_forbidden_by_zone(&self) -> rustc_hash::FxHashMap<String, Vec<String>> {
1084 let mut calls_forbidden_by_zone: rustc_hash::FxHashMap<String, Vec<String>> =
1085 rustc_hash::FxHashMap::default();
1086 for rule in &self.calls.forbidden {
1087 let patterns = calls_forbidden_by_zone
1088 .entry(rule.from.clone())
1089 .or_default();
1090 for pattern in rule.callee.iter() {
1091 patterns.push(pattern.trim().to_owned());
1092 }
1093 }
1094 calls_forbidden_by_zone
1095 }
1096}
1097
1098#[expect(
1103 clippy::redundant_pub_crate,
1104 reason = "the parent module is glob re-exported from lib.rs, so `pub` would leak this helper into the public API; pub(crate) is the minimal widening for the rule-pack validator"
1105)]
1106pub(crate) fn wildcard_placement_error(pattern: &str) -> Option<String> {
1107 let segments: Vec<&str> = pattern.split('.').collect();
1108 let last = segments.len() - 1;
1109 if segments
1110 .iter()
1111 .any(|segment| segment.contains('*') && *segment != "*")
1112 {
1113 return Some(
1114 "uses `*` inside a segment; callee patterns are not globs, so `*` must be a \
1115 whole segment (`*.member` or `object.*`)"
1116 .to_owned(),
1117 );
1118 }
1119 let star_positions: Vec<usize> = segments
1120 .iter()
1121 .enumerate()
1122 .filter(|(_, segment)| **segment == "*")
1123 .map(|(i, _)| i)
1124 .collect();
1125 if star_positions.len() > 1 || star_positions.iter().any(|&i| i != 0 && i != last) {
1126 return Some(
1127 "may use `*` only as the leading object segment (`*.member`) or the trailing \
1128 member segment (`object.*`), not both and not mid-path"
1129 .to_owned(),
1130 );
1131 }
1132 None
1133}
1134
1135fn normalize_zone_root(raw: &str) -> String {
1137 let with_slashes = raw.replace('\\', "/");
1138 let trimmed = with_slashes.trim_start_matches("./");
1139 let no_dot = if trimmed == "." { "" } else { trimmed };
1140 if no_dot.is_empty() {
1141 String::new()
1142 } else if no_dot.ends_with('/') {
1143 no_dot.to_owned()
1144 } else {
1145 format!("{no_dot}/")
1146 }
1147}
1148
1149fn normalize_auto_discover_dir(raw: &str) -> Option<String> {
1150 let with_slashes = raw.replace('\\', "/");
1151 let trimmed = with_slashes.trim_start_matches("./").trim_end_matches('/');
1152 if trimmed.starts_with('/') || trimmed.split('/').any(|part| part == "..") {
1153 None
1154 } else if trimmed == "." {
1155 Some(String::new())
1156 } else {
1157 Some(trimmed.to_owned())
1158 }
1159}
1160
1161fn join_relative_path(prefix: &str, suffix: &str) -> String {
1162 match (prefix.is_empty(), suffix.is_empty()) {
1163 (true, true) => String::new(),
1164 (true, false) => suffix.to_owned(),
1165 (false, true) => prefix.trim_end_matches('/').to_owned(),
1166 (false, false) => format!("{}/{}", prefix.trim_end_matches('/'), suffix),
1167 }
1168}
1169
1170struct DiscoveryOutcome {
1172 zones: Vec<BoundaryZone>,
1173 source_indices: Vec<usize>,
1174 had_invalid_path: bool,
1175}
1176
1177struct LogicalGroupDraft {
1179 name: String,
1180 children: Vec<String>,
1181 auto_discover: Vec<String>,
1182 fallback_zone: Option<String>,
1183 source_zone_index: usize,
1184 status: LogicalGroupStatus,
1185 merged_from: Option<Vec<usize>>,
1187 original_zone_root: Option<String>,
1189 child_source_indices: Vec<usize>,
1191}
1192
1193const fn merge_status(existing: LogicalGroupStatus, new: LogicalGroupStatus) -> LogicalGroupStatus {
1195 match (existing, new) {
1196 (LogicalGroupStatus::Ok, _) | (_, LogicalGroupStatus::Ok) => LogicalGroupStatus::Ok,
1197 (LogicalGroupStatus::InvalidPath, _) | (_, LogicalGroupStatus::InvalidPath) => {
1198 LogicalGroupStatus::InvalidPath
1199 }
1200 (LogicalGroupStatus::Empty, LogicalGroupStatus::Empty) => LogicalGroupStatus::Empty,
1201 }
1202}
1203
1204#[derive(Default)]
1206struct ChildZoneAccumulator {
1207 zones_by_name: rustc_hash::FxHashMap<String, BoundaryZone>,
1208 first_source_index: rustc_hash::FxHashMap<String, usize>,
1209}
1210
1211impl ChildZoneAccumulator {
1212 fn register_child(
1215 &mut self,
1216 zone: &BoundaryZone,
1217 discover_dir: &str,
1218 child_name: &str,
1219 source_index: usize,
1220 ) {
1221 let zone_name = format!("{}/{}", zone.name, child_name);
1222 let child_pattern = format!("{}/**", join_relative_path(discover_dir, child_name));
1223 let entry = self
1224 .zones_by_name
1225 .entry(zone_name.clone())
1226 .or_insert_with(|| BoundaryZone {
1227 name: zone_name.clone(),
1228 patterns: vec![],
1229 auto_discover: vec![],
1230 root: zone.root.clone(),
1231 });
1232 if !entry
1233 .patterns
1234 .iter()
1235 .any(|pattern| pattern == &child_pattern)
1236 {
1237 entry.patterns.push(child_pattern);
1238 }
1239 self.first_source_index
1240 .entry(zone_name)
1241 .or_insert(source_index);
1242 }
1243}
1244
1245fn discover_child_zones_in_dir(
1249 project_root: &Path,
1250 zone: &BoundaryZone,
1251 normalized_root: &str,
1252 raw_dir: &str,
1253 source_index: usize,
1254 accumulator: &mut ChildZoneAccumulator,
1255) -> bool {
1256 let Some(discover_dir) = normalize_auto_discover_dir(raw_dir) else {
1257 tracing::warn!(
1258 "invalid boundary autoDiscover path '{}' in zone '{}': paths must be project-relative and must not contain '..'",
1259 raw_dir,
1260 zone.name
1261 );
1262 return false;
1263 };
1264
1265 let fs_relative = join_relative_path(normalized_root, &discover_dir);
1266 let absolute_dir = if fs_relative.is_empty() {
1267 project_root.to_path_buf()
1268 } else {
1269 project_root.join(&fs_relative)
1270 };
1271 let Ok(entries) = std::fs::read_dir(&absolute_dir) else {
1272 tracing::warn!(
1273 "boundary zone '{}' autoDiscover path '{}' did not resolve to a readable directory",
1274 zone.name,
1275 raw_dir
1276 );
1277 return false;
1278 };
1279
1280 let mut children: Vec<_> = entries
1281 .filter_map(Result::ok)
1282 .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_dir()))
1283 .collect();
1284 children.sort_by_key(std::fs::DirEntry::file_name);
1285
1286 for child in children {
1287 let child_name = child.file_name().to_string_lossy().to_string();
1288 if child_name.is_empty() {
1289 continue;
1290 }
1291 accumulator.register_child(zone, &discover_dir, &child_name, source_index);
1292 }
1293 true
1294}
1295
1296fn discover_child_zones(project_root: &Path, zone: &BoundaryZone) -> DiscoveryOutcome {
1297 let mut accumulator = ChildZoneAccumulator::default();
1298 let normalized_root = zone
1299 .root
1300 .as_deref()
1301 .map(normalize_zone_root)
1302 .unwrap_or_default();
1303 let mut had_invalid_path = false;
1304
1305 for (source_index, raw_dir) in zone.auto_discover.iter().enumerate() {
1306 if !discover_child_zones_in_dir(
1307 project_root,
1308 zone,
1309 &normalized_root,
1310 raw_dir,
1311 source_index,
1312 &mut accumulator,
1313 ) {
1314 had_invalid_path = true;
1315 }
1316 }
1317
1318 let mut zones: Vec<_> = accumulator.zones_by_name.into_values().collect();
1319 zones.sort_by(|a, b| a.name.cmp(&b.name));
1320 let source_indices: Vec<usize> = zones
1321 .iter()
1322 .map(|z| {
1323 accumulator
1324 .first_source_index
1325 .get(z.name.as_str())
1326 .copied()
1327 .unwrap_or(0)
1328 })
1329 .collect();
1330 DiscoveryOutcome {
1331 zones,
1332 source_indices,
1333 had_invalid_path,
1334 }
1335}
1336
1337fn expand_rule_allow(
1338 allow: &[String],
1339 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
1340) -> Vec<String> {
1341 let mut expanded = Vec::new();
1342 for zone in allow {
1343 if let Some(expansion) = group_expansions.get(zone) {
1344 expanded.extend(expansion.iter().cloned());
1345 } else {
1346 expanded.push(zone.clone());
1347 }
1348 }
1349 dedupe_preserving_order(expanded)
1350}
1351
1352fn expand_parent_fallback_allow(
1353 allow: &[String],
1354 from_zones: &[String],
1355 parent_name: &str,
1356) -> Vec<String> {
1357 let mut expanded = allow.to_vec();
1358 expanded.extend(
1359 from_zones
1360 .iter()
1361 .filter(|from_zone| from_zone.as_str() != parent_name)
1362 .cloned(),
1363 );
1364 dedupe_preserving_order(expanded)
1365}
1366
1367fn expand_generated_child_allow(
1368 allow: &[String],
1369 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
1370 source_group: &str,
1371) -> Vec<String> {
1372 let mut expanded = Vec::new();
1373 for zone in allow {
1374 if zone == source_group {
1375 if group_expansions
1376 .get(source_group)
1377 .is_some_and(|from_zones| from_zones.iter().any(|from_zone| from_zone == zone))
1378 {
1379 expanded.push(zone.clone());
1380 }
1381 } else if let Some(expansion) = group_expansions.get(zone) {
1382 expanded.extend(expansion.iter().cloned());
1383 } else {
1384 expanded.push(zone.clone());
1385 }
1386 }
1387 dedupe_preserving_order(expanded)
1388}
1389
1390fn dedupe_preserving_order(values: Vec<String>) -> Vec<String> {
1391 let mut seen = rustc_hash::FxHashSet::default();
1392 values
1393 .into_iter()
1394 .filter(|value| seen.insert(value.clone()))
1395 .collect()
1396}
1397
1398fn dedupe_rules_keep_last(rules: Vec<BoundaryRule>) -> Vec<BoundaryRule> {
1399 let mut seen = rustc_hash::FxHashSet::default();
1400 let mut deduped: Vec<_> = rules
1401 .into_iter()
1402 .rev()
1403 .filter(|rule| seen.insert(rule.from.clone()))
1404 .collect();
1405 deduped.reverse();
1406 deduped
1407}
1408
1409impl ResolvedBoundaryConfig {
1410 #[must_use]
1412 pub fn is_empty(&self) -> bool {
1413 self.zones.is_empty()
1414 && self.logical_groups.is_empty()
1415 && !self.coverage.require_all_files
1416 && self.calls_forbidden_by_zone.is_empty()
1417 }
1418
1419 #[must_use]
1421 pub fn classify_zone(&self, relative_path: &str) -> Option<&str> {
1422 for zone in &self.zones {
1423 let candidate: &str = match zone.root.as_deref() {
1424 Some(root) if !root.is_empty() => {
1425 let Some(stripped) = relative_path.strip_prefix(root) else {
1426 continue;
1427 };
1428 stripped
1429 }
1430 _ => relative_path,
1431 };
1432 if zone.matchers.iter().any(|m| m.is_match(candidate)) {
1433 return Some(&zone.name);
1434 }
1435 }
1436 None
1437 }
1438
1439 #[must_use]
1441 pub fn allows_unmatched(&self, relative_path: &str) -> bool {
1442 self.coverage
1443 .allow_unmatched
1444 .iter()
1445 .any(|matcher| matcher.is_match(relative_path))
1446 }
1447
1448 #[must_use]
1450 pub fn is_import_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
1451 if from_zone == to_zone {
1452 return true;
1453 }
1454
1455 let rule = self.rules.iter().find(|r| r.from_zone == from_zone);
1456
1457 match rule {
1458 None => true,
1459 Some(r) => r.allowed_zones.iter().any(|z| z == to_zone),
1460 }
1461 }
1462
1463 #[must_use]
1465 pub fn is_type_only_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
1466 let Some(rule) = self.rules.iter().find(|r| r.from_zone == from_zone) else {
1467 return false;
1468 };
1469 rule.allow_type_only_zones.iter().any(|z| z == to_zone)
1470 }
1471}
1472
1473#[cfg(test)]
1474mod tests {
1475 use super::*;
1476
1477 #[test]
1478 fn empty_config() {
1479 let config = BoundaryConfig::default();
1480 assert!(config.is_empty());
1481 assert!(config.validate_zone_references().is_empty());
1482 }
1483
1484 #[test]
1485 fn deserialize_json() {
1486 let json = r#"{
1487 "zones": [
1488 { "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
1489 { "name": "db", "patterns": ["src/db/**"] },
1490 { "name": "shared", "patterns": ["src/shared/**"] }
1491 ],
1492 "rules": [
1493 { "from": "ui", "allow": ["shared"] },
1494 { "from": "db", "allow": ["shared"] }
1495 ]
1496 }"#;
1497 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1498 assert_eq!(config.zones.len(), 3);
1499 assert_eq!(config.rules.len(), 2);
1500 assert_eq!(config.zones[0].name, "ui");
1501 assert_eq!(
1502 config.zones[0].patterns,
1503 vec!["src/components/**", "src/pages/**"]
1504 );
1505 assert_eq!(config.rules[0].from, "ui");
1506 assert_eq!(config.rules[0].allow, vec!["shared"]);
1507 }
1508
1509 #[test]
1510 fn deserialize_boundary_coverage() {
1511 let json = r#"{
1512 "coverage": {
1513 "requireAllFiles": true,
1514 "allowUnmatched": ["src/generated/**"]
1515 }
1516 }"#;
1517 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1518
1519 assert!(config.coverage.require_all_files);
1520 assert_eq!(config.coverage.allow_unmatched, vec!["src/generated/**"]);
1521 assert!(!config.is_empty());
1522 }
1523
1524 #[test]
1525 fn deserialize_toml() {
1526 let toml_str = r#"
1527[[zones]]
1528name = "ui"
1529patterns = ["src/components/**"]
1530
1531[[zones]]
1532name = "db"
1533patterns = ["src/db/**"]
1534
1535[[rules]]
1536from = "ui"
1537allow = ["db"]
1538"#;
1539 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
1540 assert_eq!(config.zones.len(), 2);
1541 assert_eq!(config.rules.len(), 1);
1542 }
1543
1544 #[test]
1545 fn deserialize_boundary_calls_single_and_array() {
1546 let json = r#"{
1547 "zones": [{ "name": "domain", "patterns": ["src/domain/**"] }],
1548 "calls": {
1549 "forbidden": [
1550 { "from": "domain", "callee": "child_process.*" },
1551 { "from": "domain", "callee": ["console.*", "process.exit"] }
1552 ]
1553 }
1554 }"#;
1555 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1556
1557 assert_eq!(config.calls.forbidden.len(), 2);
1558 assert_eq!(
1559 config.calls.forbidden[0].callee.iter().collect::<Vec<_>>(),
1560 vec!["child_process.*"]
1561 );
1562 assert_eq!(
1563 config.calls.forbidden[1].callee.iter().collect::<Vec<_>>(),
1564 vec!["console.*", "process.exit"]
1565 );
1566 assert!(!config.is_empty());
1567 assert!(config.validate_zone_references().is_empty());
1568 assert!(config.validate_call_rules().is_empty());
1569 }
1570
1571 #[test]
1572 fn deserialize_boundary_calls_toml() {
1573 let toml_str = r#"
1574[[zones]]
1575name = "domain"
1576patterns = ["src/domain/**"]
1577
1578[[calls.forbidden]]
1579from = "domain"
1580callee = "child_process.*"
1581
1582[[calls.forbidden]]
1583from = "domain"
1584callee = ["console.*"]
1585"#;
1586 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
1587 assert_eq!(config.calls.forbidden.len(), 2);
1588 assert_eq!(
1589 config.calls.forbidden[0].callee.iter().collect::<Vec<_>>(),
1590 vec!["child_process.*"]
1591 );
1592 assert_eq!(
1593 config.calls.forbidden[1].callee.iter().collect::<Vec<_>>(),
1594 vec!["console.*"]
1595 );
1596 }
1597
1598 #[test]
1599 fn validate_zone_references_calls_from_unknown() {
1600 let json = r#"{
1601 "zones": [{ "name": "domain", "patterns": ["src/domain/**"] }],
1602 "calls": { "forbidden": [{ "from": "nonexistent", "callee": "console.*" }] }
1603 }"#;
1604 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1605 let errors = config.validate_zone_references();
1606 assert_eq!(errors.len(), 1);
1607 assert_eq!(errors[0].kind, ZoneReferenceKind::CallsFrom);
1608 assert_eq!(errors[0].zone_name, "nonexistent");
1609 let rendered = ZoneValidationError::UnknownZoneReference(errors[0].clone()).to_string();
1610 assert!(
1611 rendered.contains("boundaries.calls.forbidden[0].from"),
1612 "unexpected rendering: {rendered}"
1613 );
1614 }
1615
1616 #[test]
1617 fn validate_call_rules_rejects_inert_patterns() {
1618 let json = r#"{
1619 "zones": [{ "name": "domain", "patterns": ["src/domain/**"] }],
1620 "calls": {
1621 "forbidden": [
1622 { "from": "domain", "callee": "*" },
1623 { "from": "domain", "callee": " " },
1624 { "from": "domain", "callee": "foo..bar" },
1625 { "from": "domain", "callee": [] }
1626 ]
1627 }
1628 }"#;
1629 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1630 let errors = config.validate_call_rules();
1631 assert_eq!(errors.len(), 4);
1632 assert!(errors[0].reason.contains("matches nothing"));
1633 assert!(errors[1].reason.contains("must not be empty"));
1634 assert!(errors[2].reason.contains("empty path segment"));
1635 assert!(errors[3].reason.contains("at least one callee pattern"));
1636 }
1637
1638 #[test]
1639 fn validate_call_rules_rejects_misplaced_wildcards() {
1640 let json = r#"{
1641 "zones": [{ "name": "domain", "patterns": ["src/domain/**"] }],
1642 "calls": {
1643 "forbidden": [
1644 { "from": "domain", "callee": "a.*.b" },
1645 { "from": "domain", "callee": "*.query.*" },
1646 { "from": "domain", "callee": "con*ole.log" },
1647 { "from": "domain", "callee": ["console.*", "*.innerHTML", "child_process.exec"] }
1648 ]
1649 }
1650 }"#;
1651 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1652 let errors = config.validate_call_rules();
1653 assert_eq!(errors.len(), 3);
1654 assert!(errors[0].reason.contains("not both and not mid-path"));
1655 assert!(errors[1].reason.contains("not both and not mid-path"));
1656 assert!(errors[2].reason.contains("not globs"));
1657 }
1658
1659 #[test]
1660 fn resolve_groups_calls_by_zone() {
1661 let json = r#"{
1662 "zones": [
1663 { "name": "domain", "patterns": ["src/domain/**"] },
1664 { "name": "ui", "patterns": ["src/ui/**"] }
1665 ],
1666 "calls": {
1667 "forbidden": [
1668 { "from": "domain", "callee": "child_process.*" },
1669 { "from": "domain", "callee": ["console.*"] },
1670 { "from": "ui", "callee": "process.exit" }
1671 ]
1672 }
1673 }"#;
1674 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1675 let resolved = config.resolve();
1676 assert_eq!(
1677 resolved.calls_forbidden_by_zone.get("domain"),
1678 Some(&vec![
1679 "child_process.*".to_string(),
1680 "console.*".to_string()
1681 ])
1682 );
1683 assert_eq!(
1684 resolved.calls_forbidden_by_zone.get("ui"),
1685 Some(&vec!["process.exit".to_string()])
1686 );
1687 assert!(!resolved.is_empty());
1688 }
1689
1690 #[test]
1691 fn auto_discover_expands_child_zones_and_parent_rules() {
1692 let temp = tempfile::tempdir().unwrap();
1693 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1694 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1695
1696 let mut config = BoundaryConfig {
1697 coverage: BoundaryCoverageConfig::default(),
1698 calls: BoundaryCallsConfig::default(),
1699 preset: None,
1700 zones: vec![
1701 BoundaryZone {
1702 name: "app".to_string(),
1703 patterns: vec!["src/app/**".to_string()],
1704 auto_discover: vec![],
1705 root: None,
1706 },
1707 BoundaryZone {
1708 name: "features".to_string(),
1709 patterns: vec![],
1710 auto_discover: vec!["src/features".to_string()],
1711 root: None,
1712 },
1713 ],
1714 rules: vec![
1715 BoundaryRule {
1716 from: "app".to_string(),
1717 allow: vec!["features".to_string()],
1718 allow_type_only: vec![],
1719 },
1720 BoundaryRule {
1721 from: "features".to_string(),
1722 allow: vec![],
1723 allow_type_only: vec![],
1724 },
1725 ],
1726 };
1727
1728 config.expand_auto_discover(temp.path());
1729
1730 let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
1731 assert_eq!(zone_names, vec!["app", "features/auth", "features/billing"]);
1732 assert_eq!(
1733 config.zones[1].patterns,
1734 vec!["src/features/auth/**".to_string()]
1735 );
1736 assert_eq!(
1737 config.zones[2].patterns,
1738 vec!["src/features/billing/**".to_string()]
1739 );
1740 let app_rule = config
1741 .rules
1742 .iter()
1743 .find(|rule| rule.from == "app")
1744 .expect("app rule should be preserved");
1745 assert_eq!(
1746 app_rule.allow,
1747 vec!["features/auth".to_string(), "features/billing".to_string()]
1748 );
1749 assert!(
1750 config
1751 .rules
1752 .iter()
1753 .any(|rule| rule.from == "features/auth" && rule.allow.is_empty())
1754 );
1755 assert!(
1756 config
1757 .rules
1758 .iter()
1759 .any(|rule| rule.from == "features/billing" && rule.allow.is_empty())
1760 );
1761 assert!(config.validate_zone_references().is_empty());
1762 }
1763
1764 #[test]
1765 fn auto_discover_parent_fallback_allows_children_without_relaxing_child_rules() {
1766 let temp = tempfile::tempdir().unwrap();
1767 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1768 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1769
1770 let mut config = BoundaryConfig {
1771 coverage: BoundaryCoverageConfig::default(),
1772 calls: BoundaryCallsConfig::default(),
1773 preset: None,
1774 zones: vec![
1775 BoundaryZone {
1776 name: "app".to_string(),
1777 patterns: vec!["src/app/**".to_string()],
1778 auto_discover: vec![],
1779 root: None,
1780 },
1781 BoundaryZone {
1782 name: "features".to_string(),
1783 patterns: vec!["src/features/**".to_string()],
1784 auto_discover: vec!["src/features".to_string()],
1785 root: None,
1786 },
1787 BoundaryZone {
1788 name: "shared".to_string(),
1789 patterns: vec!["src/shared/**".to_string()],
1790 auto_discover: vec![],
1791 root: None,
1792 },
1793 ],
1794 rules: vec![
1795 BoundaryRule {
1796 from: "app".to_string(),
1797 allow: vec!["features".to_string(), "shared".to_string()],
1798 allow_type_only: vec![],
1799 },
1800 BoundaryRule {
1801 from: "features".to_string(),
1802 allow: vec!["shared".to_string()],
1803 allow_type_only: vec![],
1804 },
1805 ],
1806 };
1807
1808 config.expand_auto_discover(temp.path());
1809
1810 let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
1811 assert_eq!(
1812 zone_names,
1813 vec![
1814 "app",
1815 "features/auth",
1816 "features/billing",
1817 "features",
1818 "shared"
1819 ]
1820 );
1821
1822 let app_rule = config
1823 .rules
1824 .iter()
1825 .find(|rule| rule.from == "app")
1826 .expect("app rule should be preserved");
1827 assert_eq!(
1828 app_rule.allow,
1829 vec![
1830 "features/auth".to_string(),
1831 "features/billing".to_string(),
1832 "features".to_string(),
1833 "shared".to_string()
1834 ]
1835 );
1836
1837 let parent_rule = config
1838 .rules
1839 .iter()
1840 .find(|rule| rule.from == "features")
1841 .expect("parent fallback rule should be preserved");
1842 assert_eq!(
1843 parent_rule.allow,
1844 vec![
1845 "shared".to_string(),
1846 "features/auth".to_string(),
1847 "features/billing".to_string()
1848 ]
1849 );
1850
1851 let auth_rule = config
1852 .rules
1853 .iter()
1854 .find(|rule| rule.from == "features/auth")
1855 .expect("auth child rule should be generated");
1856 assert_eq!(auth_rule.allow, vec!["shared".to_string()]);
1857
1858 let billing_rule = config
1859 .rules
1860 .iter()
1861 .find(|rule| rule.from == "features/billing")
1862 .expect("billing child rule should be generated");
1863 assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1864 assert!(config.validate_zone_references().is_empty());
1865 }
1866
1867 #[test]
1868 fn auto_discover_explicit_child_rule_wins_over_generated_parent_rule() {
1869 let temp = tempfile::tempdir().unwrap();
1870 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1871 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1872
1873 for explicit_child_first in [true, false] {
1874 let explicit_child_rule = BoundaryRule {
1875 from: "features/auth".to_string(),
1876 allow: vec!["shared".to_string(), "features/billing".to_string()],
1877 allow_type_only: vec![],
1878 };
1879 let parent_rule = BoundaryRule {
1880 from: "features".to_string(),
1881 allow: vec!["shared".to_string()],
1882 allow_type_only: vec![],
1883 };
1884 let rules = if explicit_child_first {
1885 vec![explicit_child_rule, parent_rule]
1886 } else {
1887 vec![parent_rule, explicit_child_rule]
1888 };
1889
1890 let mut config = BoundaryConfig {
1891 coverage: BoundaryCoverageConfig::default(),
1892 calls: BoundaryCallsConfig::default(),
1893 preset: None,
1894 zones: vec![
1895 BoundaryZone {
1896 name: "features".to_string(),
1897 patterns: vec![],
1898 auto_discover: vec!["src/features".to_string()],
1899 root: None,
1900 },
1901 BoundaryZone {
1902 name: "shared".to_string(),
1903 patterns: vec!["src/shared/**".to_string()],
1904 auto_discover: vec![],
1905 root: None,
1906 },
1907 ],
1908 rules,
1909 };
1910
1911 config.expand_auto_discover(temp.path());
1912
1913 let auth_rule = config
1914 .rules
1915 .iter()
1916 .find(|rule| rule.from == "features/auth")
1917 .expect("explicit child rule should remain");
1918 assert_eq!(
1919 auth_rule.allow,
1920 vec!["shared".to_string(), "features/billing".to_string()],
1921 "explicit child rule should win regardless of rule order"
1922 );
1923
1924 let billing_rule = config
1925 .rules
1926 .iter()
1927 .find(|rule| rule.from == "features/billing")
1928 .expect("parent rule should still generate sibling child rule");
1929 assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1930 assert!(config.validate_zone_references().is_empty());
1931 }
1932 }
1933
1934 #[test]
1935 fn logical_groups_returned_for_simple_auto_discover_zone() {
1936 let temp = tempfile::tempdir().unwrap();
1937 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1938 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1939
1940 let mut config = BoundaryConfig {
1941 coverage: BoundaryCoverageConfig::default(),
1942 calls: BoundaryCallsConfig::default(),
1943 preset: None,
1944 zones: vec![
1945 BoundaryZone {
1946 name: "app".to_string(),
1947 patterns: vec!["src/app/**".to_string()],
1948 auto_discover: vec![],
1949 root: None,
1950 },
1951 BoundaryZone {
1952 name: "features".to_string(),
1953 patterns: vec![],
1954 auto_discover: vec!["src/features".to_string()],
1955 root: None,
1956 },
1957 ],
1958 rules: vec![BoundaryRule {
1959 from: "features".to_string(),
1960 allow: vec!["app".to_string()],
1961 allow_type_only: vec![],
1962 }],
1963 };
1964
1965 let groups = config.expand_auto_discover(temp.path());
1966 assert_eq!(groups.len(), 1);
1967 let g = &groups[0];
1968 assert_eq!(g.name, "features");
1969 assert_eq!(g.children, vec!["features/auth", "features/billing"]);
1970 assert_eq!(g.auto_discover, vec!["src/features"]);
1971 assert_eq!(g.source_zone_index, 1);
1972 assert_eq!(g.status, LogicalGroupStatus::Ok);
1973 assert!(g.fallback_zone.is_none());
1974 let rule = g
1975 .authored_rule
1976 .as_ref()
1977 .expect("authored rule preserved verbatim");
1978 assert_eq!(rule.allow, vec!["app"]);
1979 assert!(rule.allow_type_only.is_empty());
1980 }
1981
1982 #[test]
1983 fn logical_groups_preserve_verbatim_auto_discover_strings() {
1984 let temp = tempfile::tempdir().unwrap();
1985 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1986
1987 let mut config = BoundaryConfig {
1988 coverage: BoundaryCoverageConfig::default(),
1989 calls: BoundaryCallsConfig::default(),
1990 preset: None,
1991 zones: vec![BoundaryZone {
1992 name: "features".to_string(),
1993 patterns: vec![],
1994 auto_discover: vec!["./src/features/".to_string()],
1995 root: None,
1996 }],
1997 rules: vec![],
1998 };
1999
2000 let groups = config.expand_auto_discover(temp.path());
2001 assert_eq!(groups.len(), 1);
2002 assert_eq!(groups[0].auto_discover, vec!["./src/features/"]);
2003 assert_eq!(groups[0].children, vec!["features/auth"]);
2004 }
2005
2006 #[test]
2007 fn logical_groups_bulletproof_keeps_fallback_zone_cross_reference() {
2008 let temp = tempfile::tempdir().unwrap();
2009 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2010
2011 let mut config = BoundaryConfig {
2012 coverage: BoundaryCoverageConfig::default(),
2013 calls: BoundaryCallsConfig::default(),
2014 preset: None,
2015 zones: vec![BoundaryZone {
2016 name: "features".to_string(),
2017 patterns: vec!["src/features/**".to_string()],
2018 auto_discover: vec!["src/features".to_string()],
2019 root: None,
2020 }],
2021 rules: vec![],
2022 };
2023
2024 let groups = config.expand_auto_discover(temp.path());
2025 assert_eq!(groups.len(), 1);
2026 assert_eq!(groups[0].fallback_zone.as_deref(), Some("features"));
2027 assert!(config.zones.iter().any(|z| z.name == "features"));
2028 }
2029
2030 #[test]
2031 fn logical_groups_status_empty_when_no_child_dirs() {
2032 let temp = tempfile::tempdir().unwrap();
2033 std::fs::create_dir_all(temp.path().join("src/features")).unwrap();
2034 let mut config = BoundaryConfig {
2035 coverage: BoundaryCoverageConfig::default(),
2036 calls: BoundaryCallsConfig::default(),
2037 preset: None,
2038 zones: vec![BoundaryZone {
2039 name: "features".to_string(),
2040 patterns: vec![],
2041 auto_discover: vec!["src/features".to_string()],
2042 root: None,
2043 }],
2044 rules: vec![],
2045 };
2046
2047 let groups = config.expand_auto_discover(temp.path());
2048 assert_eq!(groups.len(), 1);
2049 assert_eq!(groups[0].status, LogicalGroupStatus::Empty);
2050 assert!(groups[0].children.is_empty());
2051 }
2052
2053 #[test]
2054 fn logical_groups_status_invalid_path_when_dir_missing() {
2055 let temp = tempfile::tempdir().unwrap();
2056 let mut config = BoundaryConfig {
2057 coverage: BoundaryCoverageConfig::default(),
2058 calls: BoundaryCallsConfig::default(),
2059 preset: None,
2060 zones: vec![BoundaryZone {
2061 name: "features".to_string(),
2062 patterns: vec![],
2063 auto_discover: vec!["src/features".to_string()],
2064 root: None,
2065 }],
2066 rules: vec![],
2067 };
2068
2069 let groups = config.expand_auto_discover(temp.path());
2070 assert_eq!(groups.len(), 1);
2071 assert_eq!(groups[0].status, LogicalGroupStatus::InvalidPath);
2072 assert!(groups[0].children.is_empty());
2073 }
2074
2075 #[test]
2076 fn logical_groups_status_ok_wins_over_invalid_when_mixed() {
2077 let temp = tempfile::tempdir().unwrap();
2078 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2079 let mut config = BoundaryConfig {
2080 coverage: BoundaryCoverageConfig::default(),
2081 calls: BoundaryCallsConfig::default(),
2082 preset: None,
2083 zones: vec![BoundaryZone {
2084 name: "features".to_string(),
2085 patterns: vec![],
2086 auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
2087 root: None,
2088 }],
2089 rules: vec![],
2090 };
2091
2092 let groups = config.expand_auto_discover(temp.path());
2093 assert_eq!(groups.len(), 1);
2094 assert_eq!(groups[0].status, LogicalGroupStatus::Ok);
2095 assert_eq!(groups[0].children, vec!["features/auth"]);
2096 }
2097
2098 #[test]
2099 fn logical_groups_preserve_declaration_order() {
2100 let temp = tempfile::tempdir().unwrap();
2101 std::fs::create_dir_all(temp.path().join("src/zeta/a")).unwrap();
2102 std::fs::create_dir_all(temp.path().join("src/alpha/a")).unwrap();
2103 std::fs::create_dir_all(temp.path().join("src/mid/a")).unwrap();
2104
2105 let mut config = BoundaryConfig {
2106 coverage: BoundaryCoverageConfig::default(),
2107 calls: BoundaryCallsConfig::default(),
2108 preset: None,
2109 zones: vec![
2110 BoundaryZone {
2111 name: "zeta".to_string(),
2112 patterns: vec![],
2113 auto_discover: vec!["src/zeta".to_string()],
2114 root: None,
2115 },
2116 BoundaryZone {
2117 name: "alpha".to_string(),
2118 patterns: vec![],
2119 auto_discover: vec!["src/alpha".to_string()],
2120 root: None,
2121 },
2122 BoundaryZone {
2123 name: "mid".to_string(),
2124 patterns: vec![],
2125 auto_discover: vec!["src/mid".to_string()],
2126 root: None,
2127 },
2128 ],
2129 rules: vec![],
2130 };
2131
2132 let groups = config.expand_auto_discover(temp.path());
2133 let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
2134 assert_eq!(names, vec!["zeta", "alpha", "mid"]);
2135 }
2136
2137 #[test]
2138 fn logical_groups_merged_from_records_duplicate_indices() {
2139 let temp = tempfile::tempdir().unwrap();
2140 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2141 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
2142
2143 let mut config = BoundaryConfig {
2144 coverage: BoundaryCoverageConfig::default(),
2145 calls: BoundaryCallsConfig::default(),
2146 preset: None,
2147 zones: vec![
2148 BoundaryZone {
2149 name: "features".to_string(),
2150 patterns: vec![],
2151 auto_discover: vec!["src/features".to_string()],
2152 root: None,
2153 },
2154 BoundaryZone {
2155 name: "other".to_string(),
2156 patterns: vec!["src/other/**".to_string()],
2157 auto_discover: vec![],
2158 root: None,
2159 },
2160 BoundaryZone {
2161 name: "features".to_string(),
2162 patterns: vec![],
2163 auto_discover: vec!["src/extra".to_string()],
2164 root: None,
2165 },
2166 ],
2167 rules: vec![],
2168 };
2169 let groups = config.expand_auto_discover(temp.path());
2170 assert_eq!(groups.len(), 1);
2171 assert_eq!(groups[0].merged_from.as_deref(), Some(&[0_usize, 2][..]));
2172 assert_eq!(groups[0].source_zone_index, 0);
2173 }
2174
2175 #[test]
2176 fn logical_groups_merged_from_none_on_single_declaration() {
2177 let temp = tempfile::tempdir().unwrap();
2178 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2179
2180 let mut config = BoundaryConfig {
2181 coverage: BoundaryCoverageConfig::default(),
2182 calls: BoundaryCallsConfig::default(),
2183 preset: None,
2184 zones: vec![BoundaryZone {
2185 name: "features".to_string(),
2186 patterns: vec![],
2187 auto_discover: vec!["src/features".to_string()],
2188 root: None,
2189 }],
2190 rules: vec![],
2191 };
2192 let groups = config.expand_auto_discover(temp.path());
2193 assert!(groups[0].merged_from.is_none());
2194 }
2195
2196 #[test]
2197 fn logical_groups_echo_original_zone_root() {
2198 let temp = tempfile::tempdir().unwrap();
2199 std::fs::create_dir_all(temp.path().join("packages/app/src/features/auth")).unwrap();
2200
2201 let mut config = BoundaryConfig {
2202 coverage: BoundaryCoverageConfig::default(),
2203 calls: BoundaryCallsConfig::default(),
2204 preset: None,
2205 zones: vec![BoundaryZone {
2206 name: "features".to_string(),
2207 patterns: vec![],
2208 auto_discover: vec!["src/features".to_string()],
2209 root: Some("packages/app/".to_string()),
2210 }],
2211 rules: vec![],
2212 };
2213 let groups = config.expand_auto_discover(temp.path());
2214 assert_eq!(
2215 groups[0].original_zone_root.as_deref(),
2216 Some("packages/app/")
2217 );
2218 }
2219
2220 #[test]
2221 fn logical_groups_original_zone_root_none_when_unset() {
2222 let temp = tempfile::tempdir().unwrap();
2223 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2224
2225 let mut config = BoundaryConfig {
2226 coverage: BoundaryCoverageConfig::default(),
2227 calls: BoundaryCallsConfig::default(),
2228 preset: None,
2229 zones: vec![BoundaryZone {
2230 name: "features".to_string(),
2231 patterns: vec![],
2232 auto_discover: vec!["src/features".to_string()],
2233 root: None,
2234 }],
2235 rules: vec![],
2236 };
2237 let groups = config.expand_auto_discover(temp.path());
2238 assert!(groups[0].original_zone_root.is_none());
2239 }
2240
2241 #[test]
2242 fn logical_groups_child_source_indices_populated_for_multi_path() {
2243 let temp = tempfile::tempdir().unwrap();
2244 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2245 std::fs::create_dir_all(temp.path().join("src/modules/billing")).unwrap();
2246
2247 let mut config = BoundaryConfig {
2248 coverage: BoundaryCoverageConfig::default(),
2249 calls: BoundaryCallsConfig::default(),
2250 preset: None,
2251 zones: vec![BoundaryZone {
2252 name: "features".to_string(),
2253 patterns: vec![],
2254 auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
2255 root: None,
2256 }],
2257 rules: vec![],
2258 };
2259 let groups = config.expand_auto_discover(temp.path());
2260 assert_eq!(
2261 groups[0].children,
2262 vec!["features/auth", "features/billing"]
2263 );
2264 assert_eq!(groups[0].child_source_indices, vec![0, 1]);
2265 }
2266
2267 #[test]
2268 fn logical_groups_child_source_indices_empty_for_single_path() {
2269 let temp = tempfile::tempdir().unwrap();
2270 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2271 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
2272
2273 let mut config = BoundaryConfig {
2274 coverage: BoundaryCoverageConfig::default(),
2275 calls: BoundaryCallsConfig::default(),
2276 preset: None,
2277 zones: vec![BoundaryZone {
2278 name: "features".to_string(),
2279 patterns: vec![],
2280 auto_discover: vec!["src/features".to_string()],
2281 root: None,
2282 }],
2283 rules: vec![],
2284 };
2285 let groups = config.expand_auto_discover(temp.path());
2286 assert!(groups[0].child_source_indices.is_empty());
2287 }
2288
2289 #[test]
2290 fn logical_groups_child_source_indices_after_duplicate_merge_shifted() {
2291 let temp = tempfile::tempdir().unwrap();
2292 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2293 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
2294
2295 let mut config = BoundaryConfig {
2296 coverage: BoundaryCoverageConfig::default(),
2297 calls: BoundaryCallsConfig::default(),
2298 preset: None,
2299 zones: vec![
2300 BoundaryZone {
2301 name: "features".to_string(),
2302 patterns: vec![],
2303 auto_discover: vec!["src/features".to_string()],
2304 root: None,
2305 },
2306 BoundaryZone {
2307 name: "features".to_string(),
2308 patterns: vec![],
2309 auto_discover: vec!["src/extra".to_string()],
2310 root: None,
2311 },
2312 ],
2313 rules: vec![],
2314 };
2315 let groups = config.expand_auto_discover(temp.path());
2316 assert_eq!(groups.len(), 1);
2317 assert_eq!(groups[0].auto_discover, vec!["src/features", "src/extra"]);
2318 let auth_idx = groups[0]
2319 .children
2320 .iter()
2321 .position(|c| c == "features/auth")
2322 .unwrap();
2323 let billing_idx = groups[0]
2324 .children
2325 .iter()
2326 .position(|c| c == "features/billing")
2327 .unwrap();
2328 assert_eq!(groups[0].child_source_indices[auth_idx], 0);
2329 assert_eq!(groups[0].child_source_indices[billing_idx], 1);
2330 }
2331
2332 #[test]
2333 fn logical_groups_merge_duplicate_parent_zone_declarations() {
2334 let temp = tempfile::tempdir().unwrap();
2335 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2336 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
2337
2338 let mut config = BoundaryConfig {
2339 coverage: BoundaryCoverageConfig::default(),
2340 calls: BoundaryCallsConfig::default(),
2341 preset: None,
2342 zones: vec![
2343 BoundaryZone {
2344 name: "features".to_string(),
2345 patterns: vec![],
2346 auto_discover: vec!["src/features".to_string()],
2347 root: None,
2348 },
2349 BoundaryZone {
2350 name: "features".to_string(),
2351 patterns: vec![],
2352 auto_discover: vec!["src/extra".to_string()],
2353 root: None,
2354 },
2355 ],
2356 rules: vec![],
2357 };
2358
2359 let groups = config.expand_auto_discover(temp.path());
2360 assert_eq!(groups.len(), 1);
2361 assert_eq!(groups[0].name, "features");
2362 assert_eq!(groups[0].auto_discover, vec!["src/features", "src/extra"]);
2363 assert!(groups[0].children.iter().any(|c| c == "features/auth"));
2364 assert!(groups[0].children.iter().any(|c| c == "features/billing"));
2365 assert_eq!(groups[0].source_zone_index, 0);
2366 }
2367
2368 #[test]
2369 fn logical_groups_duplicate_identical_declarations_no_double_count() {
2370 let temp = tempfile::tempdir().unwrap();
2371 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2372 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
2373
2374 let mut config = BoundaryConfig {
2375 coverage: BoundaryCoverageConfig::default(),
2376 calls: BoundaryCallsConfig::default(),
2377 preset: None,
2378 zones: vec![
2379 BoundaryZone {
2380 name: "features".to_string(),
2381 patterns: vec![],
2382 auto_discover: vec!["src/features".to_string()],
2383 root: None,
2384 },
2385 BoundaryZone {
2386 name: "features".to_string(),
2387 patterns: vec![],
2388 auto_discover: vec!["src/features".to_string()],
2389 root: None,
2390 },
2391 ],
2392 rules: vec![],
2393 };
2394
2395 let groups = config.expand_auto_discover(temp.path());
2396 assert_eq!(groups.len(), 1);
2397 let zone_names: Vec<&str> = config.zones.iter().map(|z| z.name.as_str()).collect();
2398 assert_eq!(zone_names, vec!["features/auth", "features/billing"]);
2399 assert_eq!(
2400 groups[0].children,
2401 vec!["features/auth", "features/billing"]
2402 );
2403 assert_eq!(
2404 groups[0].auto_discover,
2405 vec!["src/features", "src/features"]
2406 );
2407 assert_eq!(groups[0].merged_from.as_deref(), Some(&[0_usize, 1][..]));
2408 }
2409
2410 #[test]
2411 fn logical_groups_empty_when_no_auto_discover_present() {
2412 let temp = tempfile::tempdir().unwrap();
2413 let mut config = BoundaryConfig {
2414 coverage: BoundaryCoverageConfig::default(),
2415 calls: BoundaryCallsConfig::default(),
2416 preset: None,
2417 zones: vec![BoundaryZone {
2418 name: "ui".to_string(),
2419 patterns: vec!["src/components/**".to_string()],
2420 auto_discover: vec![],
2421 root: None,
2422 }],
2423 rules: vec![],
2424 };
2425 let groups = config.expand_auto_discover(temp.path());
2426 assert!(groups.is_empty());
2427 }
2428
2429 #[test]
2430 fn logical_groups_propagate_through_resolve() {
2431 let temp = tempfile::tempdir().unwrap();
2432 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2433
2434 let mut config = BoundaryConfig {
2435 coverage: BoundaryCoverageConfig::default(),
2436 calls: BoundaryCallsConfig::default(),
2437 preset: None,
2438 zones: vec![BoundaryZone {
2439 name: "features".to_string(),
2440 patterns: vec![],
2441 auto_discover: vec!["src/features".to_string()],
2442 root: None,
2443 }],
2444 rules: vec![],
2445 };
2446 let groups = config.expand_auto_discover(temp.path());
2447 let mut resolved = config.resolve();
2448 resolved.logical_groups = groups;
2449 assert_eq!(resolved.logical_groups.len(), 1);
2450 assert_eq!(resolved.logical_groups[0].name, "features");
2451 assert_eq!(resolved.logical_groups[0].children, vec!["features/auth"]);
2452 }
2453
2454 #[test]
2455 fn validate_zone_references_valid() {
2456 let config = BoundaryConfig {
2457 coverage: BoundaryCoverageConfig::default(),
2458 calls: BoundaryCallsConfig::default(),
2459 preset: None,
2460 zones: vec![
2461 BoundaryZone {
2462 name: "ui".to_string(),
2463 patterns: vec![],
2464 auto_discover: vec![],
2465 root: None,
2466 },
2467 BoundaryZone {
2468 name: "db".to_string(),
2469 patterns: vec![],
2470 auto_discover: vec![],
2471 root: None,
2472 },
2473 ],
2474 rules: vec![BoundaryRule {
2475 from: "ui".to_string(),
2476 allow: vec!["db".to_string()],
2477 allow_type_only: vec![],
2478 }],
2479 };
2480 assert!(config.validate_zone_references().is_empty());
2481 }
2482
2483 #[test]
2484 fn validate_zone_references_invalid_from() {
2485 let config = BoundaryConfig {
2486 coverage: BoundaryCoverageConfig::default(),
2487 calls: BoundaryCallsConfig::default(),
2488 preset: None,
2489 zones: vec![BoundaryZone {
2490 name: "ui".to_string(),
2491 patterns: vec![],
2492 auto_discover: vec![],
2493 root: None,
2494 }],
2495 rules: vec![BoundaryRule {
2496 from: "nonexistent".to_string(),
2497 allow: vec!["ui".to_string()],
2498 allow_type_only: vec![],
2499 }],
2500 };
2501 let errors = config.validate_zone_references();
2502 assert_eq!(errors.len(), 1);
2503 assert_eq!(errors[0].zone_name, "nonexistent");
2504 assert_eq!(errors[0].kind, ZoneReferenceKind::From);
2505 assert_eq!(errors[0].rule_index, 0);
2506 }
2507
2508 #[test]
2509 fn validate_zone_references_invalid_allow() {
2510 let config = BoundaryConfig {
2511 coverage: BoundaryCoverageConfig::default(),
2512 calls: BoundaryCallsConfig::default(),
2513 preset: None,
2514 zones: vec![BoundaryZone {
2515 name: "ui".to_string(),
2516 patterns: vec![],
2517 auto_discover: vec![],
2518 root: None,
2519 }],
2520 rules: vec![BoundaryRule {
2521 from: "ui".to_string(),
2522 allow: vec!["nonexistent".to_string()],
2523 allow_type_only: vec![],
2524 }],
2525 };
2526 let errors = config.validate_zone_references();
2527 assert_eq!(errors.len(), 1);
2528 assert_eq!(errors[0].zone_name, "nonexistent");
2529 assert_eq!(errors[0].kind, ZoneReferenceKind::Allow);
2530 }
2531
2532 #[test]
2533 fn validate_zone_references_invalid_allow_type_only() {
2534 let config = BoundaryConfig {
2535 coverage: BoundaryCoverageConfig::default(),
2536 calls: BoundaryCallsConfig::default(),
2537 preset: None,
2538 zones: vec![BoundaryZone {
2539 name: "ui".to_string(),
2540 patterns: vec![],
2541 auto_discover: vec![],
2542 root: None,
2543 }],
2544 rules: vec![BoundaryRule {
2545 from: "ui".to_string(),
2546 allow: vec![],
2547 allow_type_only: vec!["nonexistent_type_zone".to_string()],
2548 }],
2549 };
2550 let errors = config.validate_zone_references();
2551 assert_eq!(errors.len(), 1, "got: {errors:?}");
2552 assert_eq!(errors[0].zone_name, "nonexistent_type_zone");
2553 assert_eq!(errors[0].kind, ZoneReferenceKind::AllowTypeOnly);
2554 }
2555
2556 #[test]
2557 fn resolve_and_classify() {
2558 let config = BoundaryConfig {
2559 coverage: BoundaryCoverageConfig::default(),
2560 calls: BoundaryCallsConfig::default(),
2561 preset: None,
2562 zones: vec![
2563 BoundaryZone {
2564 name: "ui".to_string(),
2565 patterns: vec!["src/components/**".to_string()],
2566 auto_discover: vec![],
2567 root: None,
2568 },
2569 BoundaryZone {
2570 name: "db".to_string(),
2571 patterns: vec!["src/db/**".to_string()],
2572 auto_discover: vec![],
2573 root: None,
2574 },
2575 ],
2576 rules: vec![],
2577 };
2578 let resolved = config.resolve();
2579 assert_eq!(
2580 resolved.classify_zone("src/components/Button.tsx"),
2581 Some("ui")
2582 );
2583 assert_eq!(resolved.classify_zone("src/db/queries.ts"), Some("db"));
2584 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
2585 }
2586
2587 #[test]
2588 fn first_match_wins() {
2589 let config = BoundaryConfig {
2590 coverage: BoundaryCoverageConfig::default(),
2591 calls: BoundaryCallsConfig::default(),
2592 preset: None,
2593 zones: vec![
2594 BoundaryZone {
2595 name: "specific".to_string(),
2596 patterns: vec!["src/shared/db-utils/**".to_string()],
2597 auto_discover: vec![],
2598 root: None,
2599 },
2600 BoundaryZone {
2601 name: "shared".to_string(),
2602 patterns: vec!["src/shared/**".to_string()],
2603 auto_discover: vec![],
2604 root: None,
2605 },
2606 ],
2607 rules: vec![],
2608 };
2609 let resolved = config.resolve();
2610 assert_eq!(
2611 resolved.classify_zone("src/shared/db-utils/pool.ts"),
2612 Some("specific")
2613 );
2614 assert_eq!(
2615 resolved.classify_zone("src/shared/helpers.ts"),
2616 Some("shared")
2617 );
2618 }
2619
2620 #[test]
2621 fn self_import_always_allowed() {
2622 let config = BoundaryConfig {
2623 coverage: BoundaryCoverageConfig::default(),
2624 calls: BoundaryCallsConfig::default(),
2625 preset: None,
2626 zones: vec![BoundaryZone {
2627 name: "ui".to_string(),
2628 patterns: vec![],
2629 auto_discover: vec![],
2630 root: None,
2631 }],
2632 rules: vec![BoundaryRule {
2633 from: "ui".to_string(),
2634 allow: vec![],
2635 allow_type_only: vec![],
2636 }],
2637 };
2638 let resolved = config.resolve();
2639 assert!(resolved.is_import_allowed("ui", "ui"));
2640 }
2641
2642 #[test]
2643 fn unrestricted_zone_allows_all() {
2644 let config = BoundaryConfig {
2645 coverage: BoundaryCoverageConfig::default(),
2646 calls: BoundaryCallsConfig::default(),
2647 preset: None,
2648 zones: vec![
2649 BoundaryZone {
2650 name: "shared".to_string(),
2651 patterns: vec![],
2652 auto_discover: vec![],
2653 root: None,
2654 },
2655 BoundaryZone {
2656 name: "db".to_string(),
2657 patterns: vec![],
2658 auto_discover: vec![],
2659 root: None,
2660 },
2661 ],
2662 rules: vec![],
2663 };
2664 let resolved = config.resolve();
2665 assert!(resolved.is_import_allowed("shared", "db"));
2666 }
2667
2668 #[test]
2669 fn restricted_zone_blocks_unlisted() {
2670 let config = BoundaryConfig {
2671 coverage: BoundaryCoverageConfig::default(),
2672 calls: BoundaryCallsConfig::default(),
2673 preset: None,
2674 zones: vec![
2675 BoundaryZone {
2676 name: "ui".to_string(),
2677 patterns: vec![],
2678 auto_discover: vec![],
2679 root: None,
2680 },
2681 BoundaryZone {
2682 name: "db".to_string(),
2683 patterns: vec![],
2684 auto_discover: vec![],
2685 root: None,
2686 },
2687 BoundaryZone {
2688 name: "shared".to_string(),
2689 patterns: vec![],
2690 auto_discover: vec![],
2691 root: None,
2692 },
2693 ],
2694 rules: vec![BoundaryRule {
2695 from: "ui".to_string(),
2696 allow: vec!["shared".to_string()],
2697 allow_type_only: vec![],
2698 }],
2699 };
2700 let resolved = config.resolve();
2701 assert!(resolved.is_import_allowed("ui", "shared"));
2702 assert!(!resolved.is_import_allowed("ui", "db"));
2703 }
2704
2705 #[test]
2706 fn empty_allow_blocks_all_except_self() {
2707 let config = BoundaryConfig {
2708 coverage: BoundaryCoverageConfig::default(),
2709 calls: BoundaryCallsConfig::default(),
2710 preset: None,
2711 zones: vec![
2712 BoundaryZone {
2713 name: "isolated".to_string(),
2714 patterns: vec![],
2715 auto_discover: vec![],
2716 root: None,
2717 },
2718 BoundaryZone {
2719 name: "other".to_string(),
2720 patterns: vec![],
2721 auto_discover: vec![],
2722 root: None,
2723 },
2724 ],
2725 rules: vec![BoundaryRule {
2726 from: "isolated".to_string(),
2727 allow: vec![],
2728 allow_type_only: vec![],
2729 }],
2730 };
2731 let resolved = config.resolve();
2732 assert!(resolved.is_import_allowed("isolated", "isolated"));
2733 assert!(!resolved.is_import_allowed("isolated", "other"));
2734 }
2735
2736 #[test]
2737 fn zone_root_filters_classification_to_subtree() {
2738 let config = BoundaryConfig {
2739 coverage: BoundaryCoverageConfig::default(),
2740 calls: BoundaryCallsConfig::default(),
2741 preset: None,
2742 zones: vec![
2743 BoundaryZone {
2744 name: "ui".to_string(),
2745 patterns: vec!["src/**".to_string()],
2746 auto_discover: vec![],
2747 root: Some("packages/app/".to_string()),
2748 },
2749 BoundaryZone {
2750 name: "domain".to_string(),
2751 patterns: vec!["src/**".to_string()],
2752 auto_discover: vec![],
2753 root: Some("packages/core/".to_string()),
2754 },
2755 ],
2756 rules: vec![],
2757 };
2758 let resolved = config.resolve();
2759 assert_eq!(
2760 resolved.classify_zone("packages/app/src/login.tsx"),
2761 Some("ui")
2762 );
2763 assert_eq!(
2764 resolved.classify_zone("packages/core/src/order.ts"),
2765 Some("domain")
2766 );
2767 assert_eq!(resolved.classify_zone("src/login.tsx"), None);
2768 assert_eq!(resolved.classify_zone("packages/utils/src/x.ts"), None);
2769 }
2770
2771 #[test]
2773 fn zone_root_is_case_sensitive() {
2774 let config = BoundaryConfig {
2775 coverage: BoundaryCoverageConfig::default(),
2776 calls: BoundaryCallsConfig::default(),
2777 preset: None,
2778 zones: vec![BoundaryZone {
2779 name: "ui".to_string(),
2780 patterns: vec!["src/**".to_string()],
2781 auto_discover: vec![],
2782 root: Some("packages/app/".to_string()),
2783 }],
2784 rules: vec![],
2785 };
2786 let resolved = config.resolve();
2787 assert_eq!(
2788 resolved.classify_zone("packages/app/src/login.tsx"),
2789 Some("ui"),
2790 "exact-case path classifies"
2791 );
2792 assert_eq!(
2793 resolved.classify_zone("packages/App/src/login.tsx"),
2794 None,
2795 "case-different path does not classify (root is case-sensitive)"
2796 );
2797 assert_eq!(
2798 resolved.classify_zone("Packages/app/src/login.tsx"),
2799 None,
2800 "case-different prefix does not classify"
2801 );
2802 }
2803
2804 #[test]
2805 fn zone_root_normalizes_trailing_slash_and_dot_prefix() {
2806 let config = BoundaryConfig {
2807 coverage: BoundaryCoverageConfig::default(),
2808 calls: BoundaryCallsConfig::default(),
2809 preset: None,
2810 zones: vec![
2811 BoundaryZone {
2812 name: "no-slash".to_string(),
2813 patterns: vec!["src/**".to_string()],
2814 auto_discover: vec![],
2815 root: Some("packages/app".to_string()),
2816 },
2817 BoundaryZone {
2818 name: "dot-prefixed".to_string(),
2819 patterns: vec!["src/**".to_string()],
2820 auto_discover: vec![],
2821 root: Some("./packages/lib/".to_string()),
2822 },
2823 ],
2824 rules: vec![],
2825 };
2826 let resolved = config.resolve();
2827 assert_eq!(resolved.zones[0].root.as_deref(), Some("packages/app/"));
2828 assert_eq!(resolved.zones[1].root.as_deref(), Some("packages/lib/"));
2829 assert_eq!(
2830 resolved.classify_zone("packages/app/src/x.ts"),
2831 Some("no-slash")
2832 );
2833 assert_eq!(
2834 resolved.classify_zone("packages/lib/src/x.ts"),
2835 Some("dot-prefixed")
2836 );
2837 }
2838
2839 #[test]
2840 fn validate_root_prefixes_flags_redundant_pattern() {
2841 let config = BoundaryConfig {
2842 coverage: BoundaryCoverageConfig::default(),
2843 calls: BoundaryCallsConfig::default(),
2844 preset: None,
2845 zones: vec![BoundaryZone {
2846 name: "ui".to_string(),
2847 patterns: vec!["packages/app/src/**".to_string()],
2848 auto_discover: vec![],
2849 root: Some("packages/app/".to_string()),
2850 }],
2851 rules: vec![],
2852 };
2853 let errors = config.validate_root_prefixes();
2854 assert_eq!(errors.len(), 1, "expected one redundant-prefix error");
2855 assert_eq!(errors[0].zone_name, "ui");
2856 assert_eq!(errors[0].pattern, "packages/app/src/**");
2857 assert_eq!(errors[0].root, "packages/app/");
2858 let rendered = ZoneValidationError::RedundantRootPrefix(errors[0].clone()).to_string();
2859 assert!(
2860 rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"),
2861 "Display should carry legacy tag: {rendered}"
2862 );
2863 assert!(
2864 rendered.contains("zone 'ui'"),
2865 "Display rendering: {rendered}"
2866 );
2867 assert!(
2868 rendered.contains("packages/app/src/**"),
2869 "Display rendering: {rendered}"
2870 );
2871 }
2872
2873 #[test]
2874 fn validate_root_prefixes_handles_unnormalized_root() {
2875 let config = BoundaryConfig {
2876 coverage: BoundaryCoverageConfig::default(),
2877 calls: BoundaryCallsConfig::default(),
2878 preset: None,
2879 zones: vec![BoundaryZone {
2880 name: "ui".to_string(),
2881 patterns: vec!["./packages/app/src/**".to_string()],
2882 auto_discover: vec![],
2883 root: Some("packages/app".to_string()),
2884 }],
2885 rules: vec![],
2886 };
2887 let errors = config.validate_root_prefixes();
2888 assert_eq!(errors.len(), 1);
2889 }
2890
2891 #[test]
2892 fn validate_root_prefixes_empty_when_no_overlap() {
2893 let config = BoundaryConfig {
2894 coverage: BoundaryCoverageConfig::default(),
2895 calls: BoundaryCallsConfig::default(),
2896 preset: None,
2897 zones: vec![BoundaryZone {
2898 name: "ui".to_string(),
2899 patterns: vec!["src/**".to_string()],
2900 auto_discover: vec![],
2901 root: Some("packages/app/".to_string()),
2902 }],
2903 rules: vec![],
2904 };
2905 assert!(config.validate_root_prefixes().is_empty());
2906 }
2907
2908 #[test]
2909 fn validate_root_prefixes_skips_zones_without_root() {
2910 let json = r#"{
2911 "zones": [{ "name": "ui", "patterns": ["src/**"] }],
2912 "rules": []
2913 }"#;
2914 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2915 assert!(config.validate_root_prefixes().is_empty());
2916 }
2917
2918 #[test]
2920 fn validate_root_prefixes_skips_empty_root() {
2921 for raw_root in ["", ".", "./"] {
2922 let config = BoundaryConfig {
2923 coverage: BoundaryCoverageConfig::default(),
2924 calls: BoundaryCallsConfig::default(),
2925 preset: None,
2926 zones: vec![BoundaryZone {
2927 name: "ui".to_string(),
2928 patterns: vec!["src/**".to_string(), "lib/**".to_string()],
2929 auto_discover: vec![],
2930 root: Some(raw_root.to_string()),
2931 }],
2932 rules: vec![],
2933 };
2934 let errors = config.validate_root_prefixes();
2935 assert!(
2936 errors.is_empty(),
2937 "empty-normalized root {raw_root:?} produced spurious errors: {errors:?}"
2938 );
2939 }
2940 }
2941
2942 #[test]
2943 fn deserialize_zone_with_root() {
2944 let json = r#"{
2945 "zones": [
2946 { "name": "ui", "patterns": ["src/**"], "root": "packages/app/" }
2947 ],
2948 "rules": []
2949 }"#;
2950 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2951 assert_eq!(config.zones[0].root.as_deref(), Some("packages/app/"));
2952 }
2953
2954 #[test]
2955 fn deserialize_preset_json() {
2956 let json = r#"{ "preset": "layered" }"#;
2957 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2958 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
2959 assert!(config.zones.is_empty());
2960 }
2961
2962 #[test]
2963 fn deserialize_preset_hexagonal_json() {
2964 let json = r#"{ "preset": "hexagonal" }"#;
2965 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2966 assert_eq!(config.preset, Some(BoundaryPreset::Hexagonal));
2967 }
2968
2969 #[test]
2970 fn deserialize_preset_feature_sliced_json() {
2971 let json = r#"{ "preset": "feature-sliced" }"#;
2972 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2973 assert_eq!(config.preset, Some(BoundaryPreset::FeatureSliced));
2974 }
2975
2976 #[test]
2977 fn deserialize_preset_toml() {
2978 let toml_str = r#"preset = "layered""#;
2979 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
2980 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
2981 }
2982
2983 #[test]
2984 fn deserialize_invalid_preset_rejected() {
2985 let json = r#"{ "preset": "invalid_preset" }"#;
2986 let result: Result<BoundaryConfig, _> = serde_json::from_str(json);
2987 assert!(result.is_err());
2988 }
2989
2990 #[test]
2991 fn preset_absent_by_default() {
2992 let config = BoundaryConfig::default();
2993 assert!(config.preset.is_none());
2994 assert!(config.is_empty());
2995 }
2996
2997 #[test]
2998 fn preset_makes_config_non_empty() {
2999 let config = BoundaryConfig {
3000 coverage: BoundaryCoverageConfig::default(),
3001 calls: BoundaryCallsConfig::default(),
3002 preset: Some(BoundaryPreset::Layered),
3003 zones: vec![],
3004 rules: vec![],
3005 };
3006 assert!(!config.is_empty());
3007 }
3008
3009 #[test]
3010 fn expand_layered_produces_four_zones() {
3011 let mut config = BoundaryConfig {
3012 coverage: BoundaryCoverageConfig::default(),
3013 calls: BoundaryCallsConfig::default(),
3014 preset: Some(BoundaryPreset::Layered),
3015 zones: vec![],
3016 rules: vec![],
3017 };
3018 config.expand("src");
3019 assert_eq!(config.zones.len(), 4);
3020 assert_eq!(config.rules.len(), 4);
3021 assert!(config.preset.is_none(), "preset cleared after expand");
3022 assert_eq!(config.zones[0].name, "presentation");
3023 assert_eq!(config.zones[0].patterns, vec!["src/presentation/**"]);
3024 }
3025
3026 #[test]
3027 fn expand_layered_rules_correct() {
3028 let mut config = BoundaryConfig {
3029 coverage: BoundaryCoverageConfig::default(),
3030 calls: BoundaryCallsConfig::default(),
3031 preset: Some(BoundaryPreset::Layered),
3032 zones: vec![],
3033 rules: vec![],
3034 };
3035 config.expand("src");
3036 let pres_rule = config
3037 .rules
3038 .iter()
3039 .find(|r| r.from == "presentation")
3040 .unwrap();
3041 assert_eq!(pres_rule.allow, vec!["application"]);
3042 let app_rule = config
3043 .rules
3044 .iter()
3045 .find(|r| r.from == "application")
3046 .unwrap();
3047 assert_eq!(app_rule.allow, vec!["domain"]);
3048 let dom_rule = config.rules.iter().find(|r| r.from == "domain").unwrap();
3049 assert!(dom_rule.allow.is_empty());
3050 let infra_rule = config
3051 .rules
3052 .iter()
3053 .find(|r| r.from == "infrastructure")
3054 .unwrap();
3055 assert_eq!(infra_rule.allow, vec!["domain", "application"]);
3056 }
3057
3058 #[test]
3059 fn expand_hexagonal_produces_three_zones() {
3060 let mut config = BoundaryConfig {
3061 coverage: BoundaryCoverageConfig::default(),
3062 calls: BoundaryCallsConfig::default(),
3063 preset: Some(BoundaryPreset::Hexagonal),
3064 zones: vec![],
3065 rules: vec![],
3066 };
3067 config.expand("src");
3068 assert_eq!(config.zones.len(), 3);
3069 assert_eq!(config.rules.len(), 3);
3070 assert_eq!(config.zones[0].name, "adapters");
3071 assert_eq!(config.zones[1].name, "ports");
3072 assert_eq!(config.zones[2].name, "domain");
3073 }
3074
3075 #[test]
3076 fn expand_feature_sliced_produces_six_zones() {
3077 let mut config = BoundaryConfig {
3078 coverage: BoundaryCoverageConfig::default(),
3079 calls: BoundaryCallsConfig::default(),
3080 preset: Some(BoundaryPreset::FeatureSliced),
3081 zones: vec![],
3082 rules: vec![],
3083 };
3084 config.expand("src");
3085 assert_eq!(config.zones.len(), 6);
3086 assert_eq!(config.rules.len(), 6);
3087 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
3088 assert_eq!(
3089 app_rule.allow,
3090 vec!["pages", "widgets", "features", "entities", "shared"]
3091 );
3092 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
3093 assert!(shared_rule.allow.is_empty());
3094 let ent_rule = config.rules.iter().find(|r| r.from == "entities").unwrap();
3095 assert_eq!(ent_rule.allow, vec!["shared"]);
3096 }
3097
3098 #[test]
3099 fn expand_bulletproof_produces_four_zones() {
3100 let mut config = BoundaryConfig {
3101 coverage: BoundaryCoverageConfig::default(),
3102 calls: BoundaryCallsConfig::default(),
3103 preset: Some(BoundaryPreset::Bulletproof),
3104 zones: vec![],
3105 rules: vec![],
3106 };
3107 config.expand("src");
3108 assert_eq!(config.zones.len(), 4);
3109 assert_eq!(config.rules.len(), 4);
3110 assert_eq!(config.zones[0].name, "app");
3111 assert_eq!(config.zones[1].name, "features");
3112 assert_eq!(config.zones[2].name, "shared");
3113 assert_eq!(config.zones[3].name, "server");
3114 assert!(config.zones[2].patterns.len() > 1);
3115 assert!(
3116 config.zones[2]
3117 .patterns
3118 .contains(&"src/components/**".to_string())
3119 );
3120 assert!(
3121 config.zones[2]
3122 .patterns
3123 .contains(&"src/hooks/**".to_string())
3124 );
3125 assert!(config.zones[2].patterns.contains(&"src/lib/**".to_string()));
3126 assert!(
3127 config.zones[2]
3128 .patterns
3129 .contains(&"src/providers/**".to_string())
3130 );
3131 }
3132
3133 #[test]
3134 fn expand_bulletproof_rules_correct() {
3135 let mut config = BoundaryConfig {
3136 coverage: BoundaryCoverageConfig::default(),
3137 calls: BoundaryCallsConfig::default(),
3138 preset: Some(BoundaryPreset::Bulletproof),
3139 zones: vec![],
3140 rules: vec![],
3141 };
3142 config.expand("src");
3143 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
3144 assert_eq!(app_rule.allow, vec!["features", "shared", "server"]);
3145 let feat_rule = config.rules.iter().find(|r| r.from == "features").unwrap();
3146 assert_eq!(feat_rule.allow, vec!["shared", "server"]);
3147 let srv_rule = config.rules.iter().find(|r| r.from == "server").unwrap();
3148 assert_eq!(srv_rule.allow, vec!["shared"]);
3149 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
3150 assert!(shared_rule.allow.is_empty());
3151 }
3152
3153 #[test]
3154 fn expand_bulletproof_then_resolve_classifies() {
3155 let mut config = BoundaryConfig {
3156 coverage: BoundaryCoverageConfig::default(),
3157 calls: BoundaryCallsConfig::default(),
3158 preset: Some(BoundaryPreset::Bulletproof),
3159 zones: vec![],
3160 rules: vec![],
3161 };
3162 config.expand("src");
3163 let resolved = config.resolve();
3164 assert_eq!(
3165 resolved.classify_zone("src/app/dashboard/page.tsx"),
3166 Some("app")
3167 );
3168 assert_eq!(
3169 resolved.classify_zone("src/features/auth/hooks/useAuth.ts"),
3170 Some("features"),
3171 "without expand_auto_discover, src/features/... falls back to the parent zone"
3172 );
3173 assert_eq!(
3174 resolved.classify_zone("src/components/Button/Button.tsx"),
3175 Some("shared")
3176 );
3177 assert_eq!(
3178 resolved.classify_zone("src/hooks/useFormatters.ts"),
3179 Some("shared")
3180 );
3181 assert_eq!(
3182 resolved.classify_zone("src/server/db/schema/users.ts"),
3183 Some("server")
3184 );
3185 assert!(resolved.is_import_allowed("features", "shared"));
3186 assert!(resolved.is_import_allowed("features", "server"));
3187 assert!(!resolved.is_import_allowed("features", "app"));
3188 assert!(!resolved.is_import_allowed("shared", "features"));
3189 assert!(!resolved.is_import_allowed("server", "features"));
3190 }
3191
3192 #[test]
3194 fn bulletproof_features_barrel_can_import_children() {
3195 let temp = tempfile::tempdir().unwrap();
3196 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
3197 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
3198
3199 let mut config = BoundaryConfig {
3200 coverage: BoundaryCoverageConfig::default(),
3201 calls: BoundaryCallsConfig::default(),
3202 preset: Some(BoundaryPreset::Bulletproof),
3203 zones: vec![],
3204 rules: vec![],
3205 };
3206 config.expand("src");
3207 config.expand_auto_discover(temp.path());
3208 let resolved = config.resolve();
3209
3210 assert_eq!(
3211 resolved.classify_zone("src/features/index.ts"),
3212 Some("features"),
3213 "src/features/index.ts barrel should classify as the parent features zone"
3214 );
3215 assert_eq!(
3216 resolved.classify_zone("src/features/auth/login.ts"),
3217 Some("features/auth")
3218 );
3219 assert_eq!(
3220 resolved.classify_zone("src/features/billing/invoice.ts"),
3221 Some("features/billing")
3222 );
3223 assert!(resolved.is_import_allowed("features", "features/auth"));
3224 assert!(resolved.is_import_allowed("features", "features/billing"));
3225 assert!(!resolved.is_import_allowed("features/auth", "features/billing"));
3226 }
3227
3228 #[test]
3229 fn expand_uses_custom_source_root() {
3230 let mut config = BoundaryConfig {
3231 coverage: BoundaryCoverageConfig::default(),
3232 calls: BoundaryCallsConfig::default(),
3233 preset: Some(BoundaryPreset::Hexagonal),
3234 zones: vec![],
3235 rules: vec![],
3236 };
3237 config.expand("lib");
3238 assert_eq!(config.zones[0].patterns, vec!["lib/adapters/**"]);
3239 assert_eq!(config.zones[2].patterns, vec!["lib/domain/**"]);
3240 }
3241
3242 #[test]
3243 fn user_zone_replaces_preset_zone() {
3244 let mut config = BoundaryConfig {
3245 coverage: BoundaryCoverageConfig::default(),
3246 calls: BoundaryCallsConfig::default(),
3247 preset: Some(BoundaryPreset::Hexagonal),
3248 zones: vec![BoundaryZone {
3249 name: "domain".to_string(),
3250 patterns: vec!["src/core/**".to_string()],
3251 auto_discover: vec![],
3252 root: None,
3253 }],
3254 rules: vec![],
3255 };
3256 config.expand("src");
3257 assert_eq!(config.zones.len(), 3);
3258 let domain = config.zones.iter().find(|z| z.name == "domain").unwrap();
3259 assert_eq!(domain.patterns, vec!["src/core/**"]);
3260 }
3261
3262 #[test]
3263 fn user_zone_adds_to_preset() {
3264 let mut config = BoundaryConfig {
3265 coverage: BoundaryCoverageConfig::default(),
3266 calls: BoundaryCallsConfig::default(),
3267 preset: Some(BoundaryPreset::Hexagonal),
3268 zones: vec![BoundaryZone {
3269 name: "shared".to_string(),
3270 patterns: vec!["src/shared/**".to_string()],
3271 auto_discover: vec![],
3272 root: None,
3273 }],
3274 rules: vec![],
3275 };
3276 config.expand("src");
3277 assert_eq!(config.zones.len(), 4);
3278 assert!(config.zones.iter().any(|z| z.name == "shared"));
3279 }
3280
3281 #[test]
3282 fn user_rule_replaces_preset_rule() {
3283 let mut config = BoundaryConfig {
3284 coverage: BoundaryCoverageConfig::default(),
3285 calls: BoundaryCallsConfig::default(),
3286 preset: Some(BoundaryPreset::Hexagonal),
3287 zones: vec![],
3288 rules: vec![BoundaryRule {
3289 from: "adapters".to_string(),
3290 allow: vec!["ports".to_string(), "domain".to_string()],
3291 allow_type_only: vec![],
3292 }],
3293 };
3294 config.expand("src");
3295 let adapter_rule = config.rules.iter().find(|r| r.from == "adapters").unwrap();
3296 assert_eq!(adapter_rule.allow, vec!["ports", "domain"]);
3297 assert_eq!(
3298 config.rules.iter().filter(|r| r.from == "adapters").count(),
3299 1
3300 );
3301 }
3302
3303 #[test]
3304 fn expand_without_preset_is_noop() {
3305 let mut config = BoundaryConfig {
3306 coverage: BoundaryCoverageConfig::default(),
3307 calls: BoundaryCallsConfig::default(),
3308 preset: None,
3309 zones: vec![BoundaryZone {
3310 name: "ui".to_string(),
3311 patterns: vec!["src/ui/**".to_string()],
3312 auto_discover: vec![],
3313 root: None,
3314 }],
3315 rules: vec![],
3316 };
3317 config.expand("src");
3318 assert_eq!(config.zones.len(), 1);
3319 assert_eq!(config.zones[0].name, "ui");
3320 }
3321
3322 #[test]
3323 fn expand_then_validate_succeeds() {
3324 let mut config = BoundaryConfig {
3325 coverage: BoundaryCoverageConfig::default(),
3326 calls: BoundaryCallsConfig::default(),
3327 preset: Some(BoundaryPreset::Layered),
3328 zones: vec![],
3329 rules: vec![],
3330 };
3331 config.expand("src");
3332 assert!(config.validate_zone_references().is_empty());
3333 }
3334
3335 #[test]
3336 fn expand_then_resolve_classifies() {
3337 let mut config = BoundaryConfig {
3338 coverage: BoundaryCoverageConfig::default(),
3339 calls: BoundaryCallsConfig::default(),
3340 preset: Some(BoundaryPreset::Hexagonal),
3341 zones: vec![],
3342 rules: vec![],
3343 };
3344 config.expand("src");
3345 let resolved = config.resolve();
3346 assert_eq!(
3347 resolved.classify_zone("src/adapters/http/handler.ts"),
3348 Some("adapters")
3349 );
3350 assert_eq!(resolved.classify_zone("src/domain/user.ts"), Some("domain"));
3351 assert!(!resolved.is_import_allowed("adapters", "domain"));
3352 assert!(resolved.is_import_allowed("adapters", "ports"));
3353 }
3354
3355 #[test]
3356 fn preset_name_returns_correct_string() {
3357 let config = BoundaryConfig {
3358 coverage: BoundaryCoverageConfig::default(),
3359 calls: BoundaryCallsConfig::default(),
3360 preset: Some(BoundaryPreset::FeatureSliced),
3361 zones: vec![],
3362 rules: vec![],
3363 };
3364 assert_eq!(config.preset_name(), Some("feature-sliced"));
3365
3366 let empty = BoundaryConfig::default();
3367 assert_eq!(empty.preset_name(), None);
3368 }
3369
3370 #[test]
3371 fn preset_name_all_variants() {
3372 let cases = [
3373 (BoundaryPreset::Layered, "layered"),
3374 (BoundaryPreset::Hexagonal, "hexagonal"),
3375 (BoundaryPreset::FeatureSliced, "feature-sliced"),
3376 (BoundaryPreset::Bulletproof, "bulletproof"),
3377 ];
3378 for (preset, expected_name) in cases {
3379 let config = BoundaryConfig {
3380 coverage: BoundaryCoverageConfig::default(),
3381 calls: BoundaryCallsConfig::default(),
3382 preset: Some(preset),
3383 zones: vec![],
3384 rules: vec![],
3385 };
3386 assert_eq!(
3387 config.preset_name(),
3388 Some(expected_name),
3389 "preset_name() mismatch for variant"
3390 );
3391 }
3392 }
3393
3394 #[test]
3395 fn resolved_boundary_config_empty() {
3396 let resolved = ResolvedBoundaryConfig::default();
3397 assert!(resolved.is_empty());
3398 }
3399
3400 #[test]
3401 fn resolved_boundary_config_with_zones_not_empty() {
3402 let config = BoundaryConfig {
3403 coverage: BoundaryCoverageConfig::default(),
3404 calls: BoundaryCallsConfig::default(),
3405 preset: None,
3406 zones: vec![BoundaryZone {
3407 name: "ui".to_string(),
3408 patterns: vec!["src/ui/**".to_string()],
3409 auto_discover: vec![],
3410 root: None,
3411 }],
3412 rules: vec![],
3413 };
3414 let resolved = config.resolve();
3415 assert!(!resolved.is_empty());
3416 }
3417
3418 #[test]
3419 fn resolved_boundary_config_with_only_logical_groups_not_empty() {
3420 let resolved = ResolvedBoundaryConfig {
3421 zones: vec![],
3422 rules: vec![],
3423 logical_groups: vec![LogicalGroup {
3424 name: "features".to_string(),
3425 children: vec![],
3426 auto_discover: vec!["src/features".to_string()],
3427 authored_rule: None,
3428 fallback_zone: None,
3429 source_zone_index: 0,
3430 status: LogicalGroupStatus::Empty,
3431 merged_from: None,
3432 original_zone_root: None,
3433 child_source_indices: vec![],
3434 }],
3435 coverage: ResolvedBoundaryCoverageConfig::default(),
3436 calls_forbidden_by_zone: rustc_hash::FxHashMap::default(),
3437 };
3438 assert!(!resolved.is_empty());
3439 }
3440
3441 #[test]
3442 fn boundary_config_with_only_rules_is_empty() {
3443 let config = BoundaryConfig {
3444 coverage: BoundaryCoverageConfig::default(),
3445 calls: BoundaryCallsConfig::default(),
3446 preset: None,
3447 zones: vec![],
3448 rules: vec![BoundaryRule {
3449 from: "ui".to_string(),
3450 allow: vec!["db".to_string()],
3451 allow_type_only: vec![],
3452 }],
3453 };
3454 assert!(config.is_empty());
3455 }
3456
3457 #[test]
3458 fn boundary_config_with_zones_not_empty() {
3459 let config = BoundaryConfig {
3460 coverage: BoundaryCoverageConfig::default(),
3461 calls: BoundaryCallsConfig::default(),
3462 preset: None,
3463 zones: vec![BoundaryZone {
3464 name: "ui".to_string(),
3465 patterns: vec![],
3466 auto_discover: vec![],
3467 root: None,
3468 }],
3469 rules: vec![],
3470 };
3471 assert!(!config.is_empty());
3472 }
3473
3474 #[test]
3475 fn zone_with_multiple_patterns_matches_any() {
3476 let config = BoundaryConfig {
3477 coverage: BoundaryCoverageConfig::default(),
3478 calls: BoundaryCallsConfig::default(),
3479 preset: None,
3480 zones: vec![BoundaryZone {
3481 name: "ui".to_string(),
3482 patterns: vec![
3483 "src/components/**".to_string(),
3484 "src/pages/**".to_string(),
3485 "src/views/**".to_string(),
3486 ],
3487 auto_discover: vec![],
3488 root: None,
3489 }],
3490 rules: vec![],
3491 };
3492 let resolved = config.resolve();
3493 assert_eq!(
3494 resolved.classify_zone("src/components/Button.tsx"),
3495 Some("ui")
3496 );
3497 assert_eq!(resolved.classify_zone("src/pages/Home.tsx"), Some("ui"));
3498 assert_eq!(
3499 resolved.classify_zone("src/views/Dashboard.tsx"),
3500 Some("ui")
3501 );
3502 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
3503 }
3504
3505 #[test]
3506 fn validate_zone_references_multiple_errors() {
3507 let config = BoundaryConfig {
3508 coverage: BoundaryCoverageConfig::default(),
3509 calls: BoundaryCallsConfig::default(),
3510 preset: None,
3511 zones: vec![BoundaryZone {
3512 name: "ui".to_string(),
3513 patterns: vec![],
3514 auto_discover: vec![],
3515 root: None,
3516 }],
3517 rules: vec![
3518 BoundaryRule {
3519 from: "nonexistent_from".to_string(),
3520 allow: vec!["nonexistent_allow".to_string()],
3521 allow_type_only: vec![],
3522 },
3523 BoundaryRule {
3524 from: "ui".to_string(),
3525 allow: vec!["also_nonexistent".to_string()],
3526 allow_type_only: vec![],
3527 },
3528 ],
3529 };
3530 let errors = config.validate_zone_references();
3531 assert_eq!(errors.len(), 3);
3532 }
3533
3534 #[test]
3535 fn expand_feature_sliced_with_custom_root() {
3536 let mut config = BoundaryConfig {
3537 coverage: BoundaryCoverageConfig::default(),
3538 calls: BoundaryCallsConfig::default(),
3539 preset: Some(BoundaryPreset::FeatureSliced),
3540 zones: vec![],
3541 rules: vec![],
3542 };
3543 config.expand("lib");
3544 assert_eq!(config.zones[0].patterns, vec!["lib/app/**"]);
3545 assert_eq!(config.zones[5].patterns, vec!["lib/shared/**"]);
3546 }
3547
3548 #[test]
3549 fn zone_not_in_rules_is_unrestricted() {
3550 let config = BoundaryConfig {
3551 coverage: BoundaryCoverageConfig::default(),
3552 calls: BoundaryCallsConfig::default(),
3553 preset: None,
3554 zones: vec![
3555 BoundaryZone {
3556 name: "a".to_string(),
3557 patterns: vec![],
3558 auto_discover: vec![],
3559 root: None,
3560 },
3561 BoundaryZone {
3562 name: "b".to_string(),
3563 patterns: vec![],
3564 auto_discover: vec![],
3565 root: None,
3566 },
3567 BoundaryZone {
3568 name: "c".to_string(),
3569 patterns: vec![],
3570 auto_discover: vec![],
3571 root: None,
3572 },
3573 ],
3574 rules: vec![BoundaryRule {
3575 from: "a".to_string(),
3576 allow: vec!["b".to_string()],
3577 allow_type_only: vec![],
3578 }],
3579 };
3580 let resolved = config.resolve();
3581 assert!(resolved.is_import_allowed("a", "b"));
3582 assert!(!resolved.is_import_allowed("a", "c"));
3583 assert!(resolved.is_import_allowed("b", "a"));
3584 assert!(resolved.is_import_allowed("b", "c"));
3585 assert!(resolved.is_import_allowed("c", "a"));
3586 }
3587
3588 #[test]
3589 fn boundary_preset_json_roundtrip() {
3590 let presets = [
3591 BoundaryPreset::Layered,
3592 BoundaryPreset::Hexagonal,
3593 BoundaryPreset::FeatureSliced,
3594 BoundaryPreset::Bulletproof,
3595 ];
3596 for preset in presets {
3597 let json = serde_json::to_string(&preset).unwrap();
3598 let restored: BoundaryPreset = serde_json::from_str(&json).unwrap();
3599 assert_eq!(restored, preset);
3600 }
3601 }
3602
3603 #[test]
3604 fn deserialize_preset_bulletproof_json() {
3605 let json = r#"{ "preset": "bulletproof" }"#;
3606 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
3607 assert_eq!(config.preset, Some(BoundaryPreset::Bulletproof));
3608 }
3609
3610 #[test]
3611 #[should_panic(expected = "validated at config load time")]
3612 fn resolve_panics_on_unvalidated_invalid_zone_glob() {
3613 let config = BoundaryConfig {
3614 coverage: BoundaryCoverageConfig::default(),
3615 calls: BoundaryCallsConfig::default(),
3616 preset: None,
3617 zones: vec![BoundaryZone {
3618 name: "broken".to_string(),
3619 patterns: vec!["[invalid".to_string()],
3620 auto_discover: vec![],
3621 root: None,
3622 }],
3623 rules: vec![],
3624 };
3625 let _ = config.resolve();
3626 }
3627}