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