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