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