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