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