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