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