1use std::fmt;
4use std::path::Path;
5
6use globset::Glob;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ZoneReferenceKind {
16 From,
18 Allow,
20 AllowTypeOnly,
22}
23
24impl ZoneReferenceKind {
25 fn config_field(self) -> &'static str {
26 match self {
27 Self::From => "from",
28 Self::Allow => "allow",
29 Self::AllowTypeOnly => "allowTypeOnly",
30 }
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct UnknownZoneRef {
37 pub rule_index: usize,
39 pub kind: ZoneReferenceKind,
41 pub zone_name: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct RedundantRootPrefix {
51 pub zone_name: String,
53 pub pattern: String,
55 pub root: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum ZoneValidationError {
62 UnknownZoneReference(UnknownZoneRef),
65 RedundantRootPrefix(RedundantRootPrefix),
68}
69
70impl fmt::Display for ZoneValidationError {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 match self {
73 Self::UnknownZoneReference(err) => write!(
74 f,
75 "boundaries.rules[{}].{}: references undefined zone '{}'",
76 err.rule_index,
77 err.kind.config_field(),
78 err.zone_name,
79 ),
80 Self::RedundantRootPrefix(err) => write!(
81 f,
82 "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.",
83 err.zone_name, err.pattern, err.root,
84 ),
85 }
86 }
87}
88
89impl std::error::Error for ZoneValidationError {}
90
91#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
107#[serde(rename_all = "kebab-case")]
108pub enum BoundaryPreset {
109 Layered,
112 Hexagonal,
114 FeatureSliced,
117 Bulletproof,
125}
126
127impl BoundaryPreset {
128 #[must_use]
133 pub fn default_config(&self, source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
134 match self {
135 Self::Layered => Self::layered_config(source_root),
136 Self::Hexagonal => Self::hexagonal_config(source_root),
137 Self::FeatureSliced => Self::feature_sliced_config(source_root),
138 Self::Bulletproof => Self::bulletproof_config(source_root),
139 }
140 }
141
142 fn zone(name: &str, source_root: &str) -> BoundaryZone {
143 BoundaryZone {
144 name: name.to_owned(),
145 patterns: vec![format!("{source_root}/{name}/**")],
146 auto_discover: vec![],
147 root: None,
148 }
149 }
150
151 fn rule(from: &str, allow: &[&str]) -> BoundaryRule {
152 BoundaryRule {
153 from: from.to_owned(),
154 allow: allow.iter().map(|s| (*s).to_owned()).collect(),
155 allow_type_only: Vec::new(),
156 }
157 }
158
159 fn layered_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
160 let zones = vec![
161 Self::zone("presentation", source_root),
162 Self::zone("application", source_root),
163 Self::zone("domain", source_root),
164 Self::zone("infrastructure", source_root),
165 ];
166 let rules = vec![
167 Self::rule("presentation", &["application"]),
168 Self::rule("application", &["domain"]),
169 Self::rule("domain", &[]),
170 Self::rule("infrastructure", &["domain", "application"]),
171 ];
172 (zones, rules)
173 }
174
175 fn hexagonal_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
176 let zones = vec![
177 Self::zone("adapters", source_root),
178 Self::zone("ports", source_root),
179 Self::zone("domain", source_root),
180 ];
181 let rules = vec![
182 Self::rule("adapters", &["ports"]),
183 Self::rule("ports", &["domain"]),
184 Self::rule("domain", &[]),
185 ];
186 (zones, rules)
187 }
188
189 fn feature_sliced_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
190 let layer_names = ["app", "pages", "widgets", "features", "entities", "shared"];
191 let zones = layer_names
192 .iter()
193 .map(|name| Self::zone(name, source_root))
194 .collect();
195 let rules = layer_names
196 .iter()
197 .enumerate()
198 .map(|(i, name)| {
199 let below: Vec<&str> = layer_names[i + 1..].to_vec();
200 Self::rule(name, &below)
201 })
202 .collect();
203 (zones, rules)
204 }
205
206 fn bulletproof_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
207 let zones = vec![
208 Self::zone("app", source_root),
209 BoundaryZone {
210 name: "features".to_owned(),
214 patterns: vec![format!("{source_root}/features/**")],
215 auto_discover: vec![format!("{source_root}/features")],
216 root: None,
217 },
218 BoundaryZone {
219 name: "shared".to_owned(),
220 patterns: [
221 "components",
222 "hooks",
223 "lib",
224 "utils",
225 "utilities",
226 "providers",
227 "shared",
228 "types",
229 "styles",
230 "i18n",
231 ]
232 .iter()
233 .map(|dir| format!("{source_root}/{dir}/**"))
234 .collect(),
235 auto_discover: vec![],
236 root: None,
237 },
238 Self::zone("server", source_root),
239 ];
240 let rules = vec![
241 Self::rule("app", &["features", "shared", "server"]),
242 Self::rule("features", &["shared", "server"]),
243 Self::rule("server", &["shared"]),
244 Self::rule("shared", &[]),
245 ];
246 (zones, rules)
247 }
248}
249
250#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
286#[serde(rename_all = "camelCase")]
287pub struct BoundaryConfig {
288 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub preset: Option<BoundaryPreset>,
297 #[serde(default)]
299 pub zones: Vec<BoundaryZone>,
300 #[serde(default)]
303 pub rules: Vec<BoundaryRule>,
304}
305
306#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
308#[serde(rename_all = "camelCase")]
309pub struct BoundaryZone {
310 pub name: String,
312 #[serde(default, skip_serializing_if = "Vec::is_empty")]
315 pub patterns: Vec<String>,
316 #[serde(default, skip_serializing_if = "Vec::is_empty")]
325 pub auto_discover: Vec<String>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub root: Option<String>,
347}
348
349#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
351#[serde(rename_all = "camelCase")]
352pub struct BoundaryRule {
353 pub from: String,
355 #[serde(default)]
358 pub allow: Vec<String>,
359 #[serde(default, skip_serializing_if = "Vec::is_empty")]
371 pub allow_type_only: Vec<String>,
372}
373
374#[derive(Debug, Default)]
376pub struct ResolvedBoundaryConfig {
377 pub zones: Vec<ResolvedZone>,
379 pub rules: Vec<ResolvedBoundaryRule>,
381 pub logical_groups: Vec<LogicalGroup>,
387}
388
389#[derive(Debug, Clone, Serialize, JsonSchema)]
395#[serde(rename_all = "snake_case")]
396pub struct LogicalGroup {
397 pub name: String,
399 pub children: Vec<String>,
403 pub auto_discover: Vec<String>,
407 #[serde(skip_serializing_if = "Option::is_none")]
412 pub authored_rule: Option<AuthoredRule>,
413 #[serde(skip_serializing_if = "Option::is_none")]
420 pub fallback_zone: Option<String>,
421 pub source_zone_index: usize,
425 pub status: LogicalGroupStatus,
427 #[serde(skip_serializing_if = "Option::is_none")]
437 pub merged_from: Option<Vec<usize>>,
438 #[serde(skip_serializing_if = "Option::is_none")]
445 pub original_zone_root: Option<String>,
446 #[serde(default, skip_serializing_if = "Vec::is_empty")]
460 pub child_source_indices: Vec<usize>,
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
468#[serde(rename_all = "snake_case")]
469pub enum LogicalGroupStatus {
470 Ok,
472 Empty,
475 InvalidPath,
480}
481
482#[derive(Debug, Clone, Serialize, JsonSchema)]
487pub struct AuthoredRule {
488 pub allow: Vec<String>,
490 #[serde(default, skip_serializing_if = "Vec::is_empty")]
495 pub allow_type_only: Vec<String>,
496}
497
498#[derive(Debug)]
500pub struct ResolvedZone {
501 pub name: String,
503 pub matchers: Vec<globset::GlobMatcher>,
507 pub root: Option<String>,
513}
514
515#[derive(Debug)]
517pub struct ResolvedBoundaryRule {
518 pub from_zone: String,
520 pub allowed_zones: Vec<String>,
522 pub allow_type_only_zones: Vec<String>,
525}
526
527impl BoundaryConfig {
528 #[must_use]
530 pub fn is_empty(&self) -> bool {
531 self.preset.is_none() && self.zones.is_empty()
532 }
533
534 pub fn expand(&mut self, source_root: &str) {
544 let Some(preset) = self.preset.take() else {
545 return;
546 };
547
548 let (preset_zones, preset_rules) = preset.default_config(source_root);
549
550 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
556 .into_iter()
557 .filter(|pz| {
558 if user_zone_names.contains(pz.name.as_str()) {
559 tracing::info!(
560 "boundary preset: user zone '{}' replaces preset zone",
561 pz.name
562 );
563 false
564 } else {
565 true
566 }
567 })
568 .collect();
569 merged_zones.append(&mut self.zones);
571 self.zones = merged_zones;
572
573 let user_rule_sources: rustc_hash::FxHashSet<&str> =
575 self.rules.iter().map(|r| r.from.as_str()).collect();
576
577 let mut merged_rules: Vec<BoundaryRule> = preset_rules
578 .into_iter()
579 .filter(|pr| {
580 if user_rule_sources.contains(pr.from.as_str()) {
581 tracing::info!(
582 "boundary preset: user rule for '{}' replaces preset rule",
583 pr.from
584 );
585 false
586 } else {
587 true
588 }
589 })
590 .collect();
591 merged_rules.append(&mut self.rules);
592 self.rules = merged_rules;
593 }
594
595 pub fn expand_auto_discover(&mut self, project_root: &Path) -> Vec<LogicalGroup> {
625 if self.zones.iter().all(|zone| zone.auto_discover.is_empty()) {
626 return Vec::new();
627 }
628
629 let original_zones = std::mem::take(&mut self.zones);
630 let mut expanded_zones = Vec::new();
631 let mut group_expansions: rustc_hash::FxHashMap<String, Vec<String>> =
632 rustc_hash::FxHashMap::default();
633 let mut group_drafts: Vec<LogicalGroupDraft> = Vec::new();
637
638 for (source_zone_index, mut zone) in original_zones.into_iter().enumerate() {
639 if zone.auto_discover.is_empty() {
640 expanded_zones.push(zone);
641 continue;
642 }
643
644 let group_name = zone.name.clone();
645 let raw_auto_discover = zone.auto_discover.clone();
649 let original_zone_root = zone.root.clone();
650 let DiscoveryOutcome {
651 zones: discovered_zones,
652 source_indices: discovered_source_indices,
653 had_invalid_path,
654 } = discover_child_zones(project_root, &zone);
655 let discovered_count = discovered_zones.len();
656 let mut expanded_names: Vec<String> = discovered_zones
657 .iter()
658 .map(|child| child.name.clone())
659 .collect();
660 let child_names_only = expanded_names.clone();
661 for child_zone in discovered_zones {
662 merge_zone_by_name(&mut expanded_zones, child_zone);
663 }
664
665 let fallback_zone = if zone.patterns.is_empty() {
666 None
667 } else {
668 expanded_names.push(group_name.clone());
669 zone.auto_discover.clear();
670 merge_zone_by_name(&mut expanded_zones, zone);
671 Some(group_name.clone())
672 };
673
674 if !expanded_names.is_empty() {
675 group_expansions
676 .entry(group_name.clone())
677 .or_default()
678 .extend(expanded_names);
679 }
680
681 let status = if discovered_count > 0 {
682 LogicalGroupStatus::Ok
683 } else if had_invalid_path {
684 LogicalGroupStatus::InvalidPath
685 } else {
686 LogicalGroupStatus::Empty
687 };
688
689 if let Some(existing) = group_drafts.iter_mut().find(|d| d.name == group_name) {
701 tracing::warn!(
702 "boundary zone '{}' is declared multiple times with autoDiscover; merging discovered children",
703 group_name
704 );
705 let auto_discover_offset = existing.auto_discover.len();
706 existing.auto_discover.extend(raw_auto_discover);
707 let existing_children: rustc_hash::FxHashSet<String> =
708 existing.children.iter().cloned().collect();
709 for (idx, name) in child_names_only.iter().enumerate() {
710 if existing_children.contains(name) {
711 continue;
712 }
713 existing.children.push(name.clone());
714 existing
715 .child_source_indices
716 .push(discovered_source_indices[idx] + auto_discover_offset);
717 }
718 if existing.fallback_zone.is_none() {
719 existing.fallback_zone = fallback_zone;
720 }
721 existing.status = merge_status(existing.status, status);
722 let chain = existing
723 .merged_from
724 .get_or_insert_with(|| vec![existing.source_zone_index]);
725 chain.push(source_zone_index);
726 } else {
727 group_drafts.push(LogicalGroupDraft {
728 name: group_name,
729 children: child_names_only,
730 auto_discover: raw_auto_discover,
731 fallback_zone,
732 source_zone_index,
733 status,
734 merged_from: None,
735 original_zone_root,
736 child_source_indices: discovered_source_indices,
737 });
738 }
739 }
740
741 self.zones = expanded_zones;
742
743 let draft_names: rustc_hash::FxHashSet<&str> =
751 group_drafts.iter().map(|d| d.name.as_str()).collect();
752
753 let original_rules = std::mem::take(&mut self.rules);
757 let authored_rules: rustc_hash::FxHashMap<&str, AuthoredRule> = original_rules
758 .iter()
759 .filter(|rule| draft_names.contains(rule.from.as_str()))
760 .map(|rule| {
761 (
762 rule.from.as_str(),
763 AuthoredRule {
764 allow: rule.allow.clone(),
765 allow_type_only: rule.allow_type_only.clone(),
766 },
767 )
768 })
769 .collect();
770
771 let logical_groups: Vec<LogicalGroup> = group_drafts
772 .into_iter()
773 .map(|draft| {
774 let child_source_indices = if draft.auto_discover.len() > 1 {
780 draft.child_source_indices
781 } else {
782 Vec::new()
783 };
784 LogicalGroup {
785 authored_rule: authored_rules.get(draft.name.as_str()).cloned(),
786 name: draft.name,
787 children: draft.children,
788 auto_discover: draft.auto_discover,
789 fallback_zone: draft.fallback_zone,
790 source_zone_index: draft.source_zone_index,
791 status: draft.status,
792 merged_from: draft.merged_from,
793 original_zone_root: draft.original_zone_root,
794 child_source_indices,
795 }
796 })
797 .collect();
798
799 if group_expansions.is_empty() {
800 self.rules = original_rules;
806 return logical_groups;
807 }
808
809 self.rules = expand_rules_for_groups(original_rules, &group_expansions);
810 logical_groups
811 }
812}
813
814fn merge_zone_by_name(expanded_zones: &mut Vec<BoundaryZone>, zone: BoundaryZone) {
825 if let Some(existing) = expanded_zones.iter_mut().find(|z| z.name == zone.name) {
826 for pattern in zone.patterns {
827 if !existing.patterns.contains(&pattern) {
828 existing.patterns.push(pattern);
829 }
830 }
831 } else {
832 expanded_zones.push(zone);
833 }
834}
835
836fn expand_rules_for_groups(
850 original_rules: Vec<BoundaryRule>,
851 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
852) -> Vec<BoundaryRule> {
853 let mut generated_rules = Vec::new();
854 let mut explicit_rules = Vec::new();
855 for rule in original_rules {
856 let allow = expand_rule_allow(&rule.allow, group_expansions);
857 let allow_type_only = expand_rule_allow(&rule.allow_type_only, group_expansions);
858
859 if let Some(from_zones) = group_expansions.get(&rule.from) {
860 for from in from_zones {
861 let (allow, allow_type_only) = if from == &rule.from {
862 (
863 expand_parent_fallback_allow(&allow, from_zones, &rule.from),
864 allow_type_only.clone(),
865 )
866 } else {
867 (
868 expand_generated_child_allow(&rule.allow, group_expansions, &rule.from),
869 expand_generated_child_allow(
870 &rule.allow_type_only,
871 group_expansions,
872 &rule.from,
873 ),
874 )
875 };
876 let expanded_rule = BoundaryRule {
877 from: from.clone(),
878 allow,
879 allow_type_only,
880 };
881 if from == &rule.from {
882 explicit_rules.push(expanded_rule);
883 } else {
884 generated_rules.push(expanded_rule);
885 }
886 }
887 } else {
888 explicit_rules.push(BoundaryRule {
889 from: rule.from,
890 allow,
891 allow_type_only,
892 });
893 }
894 }
895
896 let mut expanded_rules = dedupe_rules_keep_last(generated_rules);
897 expanded_rules.extend(dedupe_rules_keep_last(explicit_rules));
898 dedupe_rules_keep_last(expanded_rules)
899}
900
901impl BoundaryConfig {
902 #[must_use]
904 pub fn preset_name(&self) -> Option<&str> {
905 self.preset.as_ref().map(|p| match p {
906 BoundaryPreset::Layered => "layered",
907 BoundaryPreset::Hexagonal => "hexagonal",
908 BoundaryPreset::FeatureSliced => "feature-sliced",
909 BoundaryPreset::Bulletproof => "bulletproof",
910 })
911 }
912
913 #[must_use]
923 pub fn validate_root_prefixes(&self) -> Vec<RedundantRootPrefix> {
924 let mut errors = Vec::new();
925 for zone in &self.zones {
926 let Some(raw_root) = zone.root.as_deref() else {
927 continue;
928 };
929 let normalized = normalize_zone_root(raw_root);
930 if normalized.is_empty() {
935 continue;
936 }
937 for pattern in &zone.patterns {
938 let normalized_pattern = pattern.replace('\\', "/");
939 let stripped = normalized_pattern
940 .strip_prefix("./")
941 .unwrap_or(&normalized_pattern);
942 if stripped.starts_with(&normalized) {
943 errors.push(RedundantRootPrefix {
944 zone_name: zone.name.clone(),
945 pattern: pattern.clone(),
946 root: normalized.clone(),
947 });
948 }
949 }
950 }
951 errors
952 }
953
954 #[must_use]
961 pub fn validate_zone_references(&self) -> Vec<UnknownZoneRef> {
962 let zone_names: rustc_hash::FxHashSet<&str> =
963 self.zones.iter().map(|z| z.name.as_str()).collect();
964
965 let mut errors = Vec::new();
966 for (i, rule) in self.rules.iter().enumerate() {
967 if !zone_names.contains(rule.from.as_str()) {
968 errors.push(UnknownZoneRef {
969 rule_index: i,
970 kind: ZoneReferenceKind::From,
971 zone_name: rule.from.clone(),
972 });
973 }
974 for allowed in &rule.allow {
975 if !zone_names.contains(allowed.as_str()) {
976 errors.push(UnknownZoneRef {
977 rule_index: i,
978 kind: ZoneReferenceKind::Allow,
979 zone_name: allowed.clone(),
980 });
981 }
982 }
983 for allowed_type_only in &rule.allow_type_only {
984 if !zone_names.contains(allowed_type_only.as_str()) {
985 errors.push(UnknownZoneRef {
986 rule_index: i,
987 kind: ZoneReferenceKind::AllowTypeOnly,
988 zone_name: allowed_type_only.clone(),
989 });
990 }
991 }
992 }
993 errors
994 }
995
996 #[must_use]
1001 pub fn resolve(&self) -> ResolvedBoundaryConfig {
1002 let zones = self
1003 .zones
1004 .iter()
1005 .map(|zone| {
1006 let matchers = zone
1007 .patterns
1008 .iter()
1009 .map(|pattern| {
1010 Glob::new(pattern)
1011 .expect("boundaries.zones[].patterns was validated at config load time")
1012 .compile_matcher()
1013 })
1014 .collect();
1015 let root = zone.root.as_deref().map(normalize_zone_root);
1016 ResolvedZone {
1017 name: zone.name.clone(),
1018 matchers,
1019 root,
1020 }
1021 })
1022 .collect();
1023
1024 let rules = self
1025 .rules
1026 .iter()
1027 .map(|rule| ResolvedBoundaryRule {
1028 from_zone: rule.from.clone(),
1029 allowed_zones: rule.allow.clone(),
1030 allow_type_only_zones: rule.allow_type_only.clone(),
1031 })
1032 .collect();
1033
1034 ResolvedBoundaryConfig {
1035 zones,
1036 rules,
1037 logical_groups: Vec::new(),
1043 }
1044 }
1045}
1046
1047fn normalize_zone_root(raw: &str) -> String {
1052 let with_slashes = raw.replace('\\', "/");
1053 let trimmed = with_slashes.trim_start_matches("./");
1054 let no_dot = if trimmed == "." { "" } else { trimmed };
1055 if no_dot.is_empty() {
1056 String::new()
1057 } else if no_dot.ends_with('/') {
1058 no_dot.to_owned()
1059 } else {
1060 format!("{no_dot}/")
1061 }
1062}
1063
1064fn normalize_auto_discover_dir(raw: &str) -> Option<String> {
1065 let with_slashes = raw.replace('\\', "/");
1066 let trimmed = with_slashes.trim_start_matches("./").trim_end_matches('/');
1067 if trimmed.starts_with('/') || trimmed.split('/').any(|part| part == "..") {
1068 None
1069 } else if trimmed == "." {
1070 Some(String::new())
1071 } else {
1072 Some(trimmed.to_owned())
1073 }
1074}
1075
1076fn join_relative_path(prefix: &str, suffix: &str) -> String {
1077 match (prefix.is_empty(), suffix.is_empty()) {
1078 (true, true) => String::new(),
1079 (true, false) => suffix.to_owned(),
1080 (false, true) => prefix.trim_end_matches('/').to_owned(),
1081 (false, false) => format!("{}/{}", prefix.trim_end_matches('/'), suffix),
1082 }
1083}
1084
1085struct DiscoveryOutcome {
1092 zones: Vec<BoundaryZone>,
1093 source_indices: Vec<usize>,
1094 had_invalid_path: bool,
1095}
1096
1097struct LogicalGroupDraft {
1101 name: String,
1102 children: Vec<String>,
1103 auto_discover: Vec<String>,
1104 fallback_zone: Option<String>,
1105 source_zone_index: usize,
1106 status: LogicalGroupStatus,
1107 merged_from: Option<Vec<usize>>,
1111 original_zone_root: Option<String>,
1115 child_source_indices: Vec<usize>,
1121}
1122
1123const fn merge_status(existing: LogicalGroupStatus, new: LogicalGroupStatus) -> LogicalGroupStatus {
1128 match (existing, new) {
1129 (LogicalGroupStatus::Ok, _) | (_, LogicalGroupStatus::Ok) => LogicalGroupStatus::Ok,
1130 (LogicalGroupStatus::InvalidPath, _) | (_, LogicalGroupStatus::InvalidPath) => {
1131 LogicalGroupStatus::InvalidPath
1132 }
1133 (LogicalGroupStatus::Empty, LogicalGroupStatus::Empty) => LogicalGroupStatus::Empty,
1134 }
1135}
1136
1137fn discover_child_zones(project_root: &Path, zone: &BoundaryZone) -> DiscoveryOutcome {
1138 let mut zones_by_name: rustc_hash::FxHashMap<String, BoundaryZone> =
1139 rustc_hash::FxHashMap::default();
1140 let mut first_source_index: rustc_hash::FxHashMap<String, usize> =
1145 rustc_hash::FxHashMap::default();
1146 let normalized_root = zone
1147 .root
1148 .as_deref()
1149 .map(normalize_zone_root)
1150 .unwrap_or_default();
1151 let mut had_invalid_path = false;
1152
1153 for (source_index, raw_dir) in zone.auto_discover.iter().enumerate() {
1154 let Some(discover_dir) = normalize_auto_discover_dir(raw_dir) else {
1155 tracing::warn!(
1156 "invalid boundary autoDiscover path '{}' in zone '{}': paths must be project-relative and must not contain '..'",
1157 raw_dir,
1158 zone.name
1159 );
1160 had_invalid_path = true;
1161 continue;
1162 };
1163
1164 let fs_relative = join_relative_path(&normalized_root, &discover_dir);
1165 let absolute_dir = if fs_relative.is_empty() {
1166 project_root.to_path_buf()
1167 } else {
1168 project_root.join(&fs_relative)
1169 };
1170 let Ok(entries) = std::fs::read_dir(&absolute_dir) else {
1171 tracing::warn!(
1172 "boundary zone '{}' autoDiscover path '{}' did not resolve to a readable directory",
1173 zone.name,
1174 raw_dir
1175 );
1176 had_invalid_path = true;
1177 continue;
1178 };
1179
1180 let mut children: Vec<_> = entries
1181 .filter_map(Result::ok)
1182 .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_dir()))
1183 .collect();
1184 children.sort_by_key(|entry| entry.file_name());
1185
1186 for child in children {
1187 let child_name = child.file_name().to_string_lossy().to_string();
1188 if child_name.is_empty() {
1189 continue;
1190 }
1191
1192 let zone_name = format!("{}/{}", zone.name, child_name);
1193 let child_pattern = format!("{}/**", join_relative_path(&discover_dir, &child_name));
1194 let entry = zones_by_name
1195 .entry(zone_name.clone())
1196 .or_insert_with(|| BoundaryZone {
1197 name: zone_name.clone(),
1198 patterns: vec![],
1199 auto_discover: vec![],
1200 root: zone.root.clone(),
1201 });
1202 if !entry
1203 .patterns
1204 .iter()
1205 .any(|pattern| pattern == &child_pattern)
1206 {
1207 entry.patterns.push(child_pattern);
1208 }
1209 first_source_index.entry(zone_name).or_insert(source_index);
1210 }
1211 }
1212
1213 let mut zones: Vec<_> = zones_by_name.into_values().collect();
1214 zones.sort_by(|a, b| a.name.cmp(&b.name));
1215 let source_indices: Vec<usize> = zones
1216 .iter()
1217 .map(|z| {
1218 first_source_index
1223 .get(z.name.as_str())
1224 .copied()
1225 .unwrap_or(0)
1226 })
1227 .collect();
1228 DiscoveryOutcome {
1229 zones,
1230 source_indices,
1231 had_invalid_path,
1232 }
1233}
1234
1235fn expand_rule_allow(
1236 allow: &[String],
1237 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
1238) -> Vec<String> {
1239 let mut expanded = Vec::new();
1240 for zone in allow {
1241 if let Some(expansion) = group_expansions.get(zone) {
1242 expanded.extend(expansion.iter().cloned());
1243 } else {
1244 expanded.push(zone.clone());
1245 }
1246 }
1247 dedupe_preserving_order(expanded)
1248}
1249
1250fn expand_parent_fallback_allow(
1251 allow: &[String],
1252 from_zones: &[String],
1253 parent_name: &str,
1254) -> Vec<String> {
1255 let mut expanded = allow.to_vec();
1256 expanded.extend(
1257 from_zones
1258 .iter()
1259 .filter(|from_zone| from_zone.as_str() != parent_name)
1260 .cloned(),
1261 );
1262 dedupe_preserving_order(expanded)
1263}
1264
1265fn expand_generated_child_allow(
1266 allow: &[String],
1267 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
1268 source_group: &str,
1269) -> Vec<String> {
1270 let mut expanded = Vec::new();
1271 for zone in allow {
1272 if zone == source_group {
1273 if group_expansions
1274 .get(source_group)
1275 .is_some_and(|from_zones| from_zones.iter().any(|from_zone| from_zone == zone))
1276 {
1277 expanded.push(zone.clone());
1278 }
1279 } else if let Some(expansion) = group_expansions.get(zone) {
1280 expanded.extend(expansion.iter().cloned());
1281 } else {
1282 expanded.push(zone.clone());
1283 }
1284 }
1285 dedupe_preserving_order(expanded)
1286}
1287
1288fn dedupe_preserving_order(values: Vec<String>) -> Vec<String> {
1289 let mut seen = rustc_hash::FxHashSet::default();
1290 values
1291 .into_iter()
1292 .filter(|value| seen.insert(value.clone()))
1293 .collect()
1294}
1295
1296fn dedupe_rules_keep_last(rules: Vec<BoundaryRule>) -> Vec<BoundaryRule> {
1297 let mut seen = rustc_hash::FxHashSet::default();
1298 let mut deduped: Vec<_> = rules
1299 .into_iter()
1300 .rev()
1301 .filter(|rule| seen.insert(rule.from.clone()))
1302 .collect();
1303 deduped.reverse();
1304 deduped
1305}
1306
1307impl ResolvedBoundaryConfig {
1308 #[must_use]
1317 pub fn is_empty(&self) -> bool {
1318 self.zones.is_empty() && self.logical_groups.is_empty()
1319 }
1320
1321 #[must_use]
1329 pub fn classify_zone(&self, relative_path: &str) -> Option<&str> {
1330 for zone in &self.zones {
1331 let candidate: &str = match zone.root.as_deref() {
1332 Some(root) if !root.is_empty() => {
1333 let Some(stripped) = relative_path.strip_prefix(root) else {
1334 continue;
1335 };
1336 stripped
1337 }
1338 _ => relative_path,
1339 };
1340 if zone.matchers.iter().any(|m| m.is_match(candidate)) {
1341 return Some(&zone.name);
1342 }
1343 }
1344 None
1345 }
1346
1347 #[must_use]
1350 pub fn is_import_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
1351 if from_zone == to_zone {
1353 return true;
1354 }
1355
1356 let rule = self.rules.iter().find(|r| r.from_zone == from_zone);
1358
1359 match rule {
1360 None => true,
1362 Some(r) => r.allowed_zones.iter().any(|z| z == to_zone),
1364 }
1365 }
1366
1367 #[must_use]
1376 pub fn is_type_only_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
1377 let Some(rule) = self.rules.iter().find(|r| r.from_zone == from_zone) else {
1378 return false;
1379 };
1380 rule.allow_type_only_zones.iter().any(|z| z == to_zone)
1381 }
1382}
1383
1384#[cfg(test)]
1385mod tests {
1386 use super::*;
1387
1388 #[test]
1389 fn empty_config() {
1390 let config = BoundaryConfig::default();
1391 assert!(config.is_empty());
1392 assert!(config.validate_zone_references().is_empty());
1393 }
1394
1395 #[test]
1396 fn deserialize_json() {
1397 let json = r#"{
1398 "zones": [
1399 { "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
1400 { "name": "db", "patterns": ["src/db/**"] },
1401 { "name": "shared", "patterns": ["src/shared/**"] }
1402 ],
1403 "rules": [
1404 { "from": "ui", "allow": ["shared"] },
1405 { "from": "db", "allow": ["shared"] }
1406 ]
1407 }"#;
1408 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1409 assert_eq!(config.zones.len(), 3);
1410 assert_eq!(config.rules.len(), 2);
1411 assert_eq!(config.zones[0].name, "ui");
1412 assert_eq!(
1413 config.zones[0].patterns,
1414 vec!["src/components/**", "src/pages/**"]
1415 );
1416 assert_eq!(config.rules[0].from, "ui");
1417 assert_eq!(config.rules[0].allow, vec!["shared"]);
1418 }
1419
1420 #[test]
1421 fn deserialize_toml() {
1422 let toml_str = r#"
1423[[zones]]
1424name = "ui"
1425patterns = ["src/components/**"]
1426
1427[[zones]]
1428name = "db"
1429patterns = ["src/db/**"]
1430
1431[[rules]]
1432from = "ui"
1433allow = ["db"]
1434"#;
1435 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
1436 assert_eq!(config.zones.len(), 2);
1437 assert_eq!(config.rules.len(), 1);
1438 }
1439
1440 #[test]
1441 fn auto_discover_expands_child_zones_and_parent_rules() {
1442 let temp = tempfile::tempdir().unwrap();
1443 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1444 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1445
1446 let mut config = BoundaryConfig {
1447 preset: None,
1448 zones: vec![
1449 BoundaryZone {
1450 name: "app".to_string(),
1451 patterns: vec!["src/app/**".to_string()],
1452 auto_discover: vec![],
1453 root: None,
1454 },
1455 BoundaryZone {
1456 name: "features".to_string(),
1457 patterns: vec![],
1458 auto_discover: vec!["src/features".to_string()],
1459 root: None,
1460 },
1461 ],
1462 rules: vec![
1463 BoundaryRule {
1464 from: "app".to_string(),
1465 allow: vec!["features".to_string()],
1466 allow_type_only: vec![],
1467 },
1468 BoundaryRule {
1469 from: "features".to_string(),
1470 allow: vec![],
1471 allow_type_only: vec![],
1472 },
1473 ],
1474 };
1475
1476 config.expand_auto_discover(temp.path());
1477
1478 let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
1479 assert_eq!(zone_names, vec!["app", "features/auth", "features/billing"]);
1480 assert_eq!(
1481 config.zones[1].patterns,
1482 vec!["src/features/auth/**".to_string()]
1483 );
1484 assert_eq!(
1485 config.zones[2].patterns,
1486 vec!["src/features/billing/**".to_string()]
1487 );
1488 let app_rule = config
1489 .rules
1490 .iter()
1491 .find(|rule| rule.from == "app")
1492 .expect("app rule should be preserved");
1493 assert_eq!(
1494 app_rule.allow,
1495 vec!["features/auth".to_string(), "features/billing".to_string()]
1496 );
1497 assert!(
1498 config
1499 .rules
1500 .iter()
1501 .any(|rule| rule.from == "features/auth" && rule.allow.is_empty())
1502 );
1503 assert!(
1504 config
1505 .rules
1506 .iter()
1507 .any(|rule| rule.from == "features/billing" && rule.allow.is_empty())
1508 );
1509 assert!(config.validate_zone_references().is_empty());
1510 }
1511
1512 #[test]
1513 fn auto_discover_parent_fallback_allows_children_without_relaxing_child_rules() {
1514 let temp = tempfile::tempdir().unwrap();
1515 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1516 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1517
1518 let mut config = BoundaryConfig {
1519 preset: None,
1520 zones: vec![
1521 BoundaryZone {
1522 name: "app".to_string(),
1523 patterns: vec!["src/app/**".to_string()],
1524 auto_discover: vec![],
1525 root: None,
1526 },
1527 BoundaryZone {
1528 name: "features".to_string(),
1529 patterns: vec!["src/features/**".to_string()],
1530 auto_discover: vec!["src/features".to_string()],
1531 root: None,
1532 },
1533 BoundaryZone {
1534 name: "shared".to_string(),
1535 patterns: vec!["src/shared/**".to_string()],
1536 auto_discover: vec![],
1537 root: None,
1538 },
1539 ],
1540 rules: vec![
1541 BoundaryRule {
1542 from: "app".to_string(),
1543 allow: vec!["features".to_string(), "shared".to_string()],
1544 allow_type_only: vec![],
1545 },
1546 BoundaryRule {
1547 from: "features".to_string(),
1548 allow: vec!["shared".to_string()],
1549 allow_type_only: vec![],
1550 },
1551 ],
1552 };
1553
1554 config.expand_auto_discover(temp.path());
1555
1556 let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
1557 assert_eq!(
1558 zone_names,
1559 vec![
1560 "app",
1561 "features/auth",
1562 "features/billing",
1563 "features",
1564 "shared"
1565 ]
1566 );
1567
1568 let app_rule = config
1569 .rules
1570 .iter()
1571 .find(|rule| rule.from == "app")
1572 .expect("app rule should be preserved");
1573 assert_eq!(
1574 app_rule.allow,
1575 vec![
1576 "features/auth".to_string(),
1577 "features/billing".to_string(),
1578 "features".to_string(),
1579 "shared".to_string()
1580 ]
1581 );
1582
1583 let parent_rule = config
1584 .rules
1585 .iter()
1586 .find(|rule| rule.from == "features")
1587 .expect("parent fallback rule should be preserved");
1588 assert_eq!(
1589 parent_rule.allow,
1590 vec![
1591 "shared".to_string(),
1592 "features/auth".to_string(),
1593 "features/billing".to_string()
1594 ]
1595 );
1596
1597 let auth_rule = config
1598 .rules
1599 .iter()
1600 .find(|rule| rule.from == "features/auth")
1601 .expect("auth child rule should be generated");
1602 assert_eq!(auth_rule.allow, vec!["shared".to_string()]);
1603
1604 let billing_rule = config
1605 .rules
1606 .iter()
1607 .find(|rule| rule.from == "features/billing")
1608 .expect("billing child rule should be generated");
1609 assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1610 assert!(config.validate_zone_references().is_empty());
1611 }
1612
1613 #[test]
1614 fn auto_discover_explicit_child_rule_wins_over_generated_parent_rule() {
1615 let temp = tempfile::tempdir().unwrap();
1616 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1617 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1618
1619 for explicit_child_first in [true, false] {
1620 let explicit_child_rule = BoundaryRule {
1621 from: "features/auth".to_string(),
1622 allow: vec!["shared".to_string(), "features/billing".to_string()],
1623 allow_type_only: vec![],
1624 };
1625 let parent_rule = BoundaryRule {
1626 from: "features".to_string(),
1627 allow: vec!["shared".to_string()],
1628 allow_type_only: vec![],
1629 };
1630 let rules = if explicit_child_first {
1631 vec![explicit_child_rule, parent_rule]
1632 } else {
1633 vec![parent_rule, explicit_child_rule]
1634 };
1635
1636 let mut config = BoundaryConfig {
1637 preset: None,
1638 zones: vec![
1639 BoundaryZone {
1640 name: "features".to_string(),
1641 patterns: vec![],
1642 auto_discover: vec!["src/features".to_string()],
1643 root: None,
1644 },
1645 BoundaryZone {
1646 name: "shared".to_string(),
1647 patterns: vec!["src/shared/**".to_string()],
1648 auto_discover: vec![],
1649 root: None,
1650 },
1651 ],
1652 rules,
1653 };
1654
1655 config.expand_auto_discover(temp.path());
1656
1657 let auth_rule = config
1658 .rules
1659 .iter()
1660 .find(|rule| rule.from == "features/auth")
1661 .expect("explicit child rule should remain");
1662 assert_eq!(
1663 auth_rule.allow,
1664 vec!["shared".to_string(), "features/billing".to_string()],
1665 "explicit child rule should win regardless of rule order"
1666 );
1667
1668 let billing_rule = config
1669 .rules
1670 .iter()
1671 .find(|rule| rule.from == "features/billing")
1672 .expect("parent rule should still generate sibling child rule");
1673 assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1674 assert!(config.validate_zone_references().is_empty());
1675 }
1676 }
1677
1678 #[test]
1681 fn logical_groups_returned_for_simple_auto_discover_zone() {
1682 let temp = tempfile::tempdir().unwrap();
1683 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1684 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1685
1686 let mut config = BoundaryConfig {
1687 preset: None,
1688 zones: vec![
1689 BoundaryZone {
1690 name: "app".to_string(),
1691 patterns: vec!["src/app/**".to_string()],
1692 auto_discover: vec![],
1693 root: None,
1694 },
1695 BoundaryZone {
1696 name: "features".to_string(),
1697 patterns: vec![],
1698 auto_discover: vec!["src/features".to_string()],
1699 root: None,
1700 },
1701 ],
1702 rules: vec![BoundaryRule {
1703 from: "features".to_string(),
1704 allow: vec!["app".to_string()],
1705 allow_type_only: vec![],
1706 }],
1707 };
1708
1709 let groups = config.expand_auto_discover(temp.path());
1710 assert_eq!(groups.len(), 1);
1711 let g = &groups[0];
1712 assert_eq!(g.name, "features");
1713 assert_eq!(g.children, vec!["features/auth", "features/billing"]);
1714 assert_eq!(g.auto_discover, vec!["src/features"]);
1715 assert_eq!(g.source_zone_index, 1);
1716 assert_eq!(g.status, LogicalGroupStatus::Ok);
1717 assert!(g.fallback_zone.is_none());
1719 let rule = g
1720 .authored_rule
1721 .as_ref()
1722 .expect("authored rule preserved verbatim");
1723 assert_eq!(rule.allow, vec!["app"]);
1724 assert!(rule.allow_type_only.is_empty());
1725 }
1726
1727 #[test]
1728 fn logical_groups_preserve_verbatim_auto_discover_strings() {
1729 let temp = tempfile::tempdir().unwrap();
1730 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1731
1732 let mut config = BoundaryConfig {
1733 preset: None,
1734 zones: vec![BoundaryZone {
1735 name: "features".to_string(),
1736 patterns: vec![],
1737 auto_discover: vec!["./src/features/".to_string()],
1741 root: None,
1742 }],
1743 rules: vec![],
1744 };
1745
1746 let groups = config.expand_auto_discover(temp.path());
1747 assert_eq!(groups.len(), 1);
1748 assert_eq!(groups[0].auto_discover, vec!["./src/features/"]);
1749 assert_eq!(groups[0].children, vec!["features/auth"]);
1750 }
1751
1752 #[test]
1753 fn logical_groups_bulletproof_keeps_fallback_zone_cross_reference() {
1754 let temp = tempfile::tempdir().unwrap();
1755 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1756
1757 let mut config = BoundaryConfig {
1758 preset: None,
1759 zones: vec![BoundaryZone {
1760 name: "features".to_string(),
1764 patterns: vec!["src/features/**".to_string()],
1765 auto_discover: vec!["src/features".to_string()],
1766 root: None,
1767 }],
1768 rules: vec![],
1769 };
1770
1771 let groups = config.expand_auto_discover(temp.path());
1772 assert_eq!(groups.len(), 1);
1773 assert_eq!(groups[0].fallback_zone.as_deref(), Some("features"));
1774 assert!(config.zones.iter().any(|z| z.name == "features"));
1776 }
1777
1778 #[test]
1779 fn logical_groups_status_empty_when_no_child_dirs() {
1780 let temp = tempfile::tempdir().unwrap();
1781 std::fs::create_dir_all(temp.path().join("src/features")).unwrap();
1782 let mut config = BoundaryConfig {
1785 preset: None,
1786 zones: vec![BoundaryZone {
1787 name: "features".to_string(),
1788 patterns: vec![],
1789 auto_discover: vec!["src/features".to_string()],
1790 root: None,
1791 }],
1792 rules: vec![],
1793 };
1794
1795 let groups = config.expand_auto_discover(temp.path());
1796 assert_eq!(groups.len(), 1);
1797 assert_eq!(groups[0].status, LogicalGroupStatus::Empty);
1798 assert!(groups[0].children.is_empty());
1799 }
1800
1801 #[test]
1802 fn logical_groups_status_invalid_path_when_dir_missing() {
1803 let temp = tempfile::tempdir().unwrap();
1804 let mut config = BoundaryConfig {
1807 preset: None,
1808 zones: vec![BoundaryZone {
1809 name: "features".to_string(),
1810 patterns: vec![],
1811 auto_discover: vec!["src/features".to_string()],
1812 root: None,
1813 }],
1814 rules: vec![],
1815 };
1816
1817 let groups = config.expand_auto_discover(temp.path());
1818 assert_eq!(groups.len(), 1);
1819 assert_eq!(groups[0].status, LogicalGroupStatus::InvalidPath);
1820 assert!(groups[0].children.is_empty());
1821 }
1822
1823 #[test]
1824 fn logical_groups_status_ok_wins_over_invalid_when_mixed() {
1825 let temp = tempfile::tempdir().unwrap();
1826 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1827 let mut config = BoundaryConfig {
1830 preset: None,
1831 zones: vec![BoundaryZone {
1832 name: "features".to_string(),
1833 patterns: vec![],
1834 auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
1835 root: None,
1836 }],
1837 rules: vec![],
1838 };
1839
1840 let groups = config.expand_auto_discover(temp.path());
1841 assert_eq!(groups.len(), 1);
1842 assert_eq!(groups[0].status, LogicalGroupStatus::Ok);
1845 assert_eq!(groups[0].children, vec!["features/auth"]);
1846 }
1847
1848 #[test]
1849 fn logical_groups_preserve_declaration_order() {
1850 let temp = tempfile::tempdir().unwrap();
1851 std::fs::create_dir_all(temp.path().join("src/zeta/a")).unwrap();
1852 std::fs::create_dir_all(temp.path().join("src/alpha/a")).unwrap();
1853 std::fs::create_dir_all(temp.path().join("src/mid/a")).unwrap();
1854
1855 let mut config = BoundaryConfig {
1856 preset: None,
1857 zones: vec![
1858 BoundaryZone {
1859 name: "zeta".to_string(),
1860 patterns: vec![],
1861 auto_discover: vec!["src/zeta".to_string()],
1862 root: None,
1863 },
1864 BoundaryZone {
1865 name: "alpha".to_string(),
1866 patterns: vec![],
1867 auto_discover: vec!["src/alpha".to_string()],
1868 root: None,
1869 },
1870 BoundaryZone {
1871 name: "mid".to_string(),
1872 patterns: vec![],
1873 auto_discover: vec!["src/mid".to_string()],
1874 root: None,
1875 },
1876 ],
1877 rules: vec![],
1878 };
1879
1880 let groups = config.expand_auto_discover(temp.path());
1881 let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
1883 assert_eq!(names, vec!["zeta", "alpha", "mid"]);
1884 }
1885
1886 #[test]
1887 fn logical_groups_merged_from_records_duplicate_indices() {
1888 let temp = tempfile::tempdir().unwrap();
1891 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1892 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
1893
1894 let mut config = BoundaryConfig {
1895 preset: None,
1896 zones: vec![
1897 BoundaryZone {
1898 name: "features".to_string(),
1899 patterns: vec![],
1900 auto_discover: vec!["src/features".to_string()],
1901 root: None,
1902 },
1903 BoundaryZone {
1904 name: "other".to_string(),
1905 patterns: vec!["src/other/**".to_string()],
1906 auto_discover: vec![],
1907 root: None,
1908 },
1909 BoundaryZone {
1910 name: "features".to_string(),
1911 patterns: vec![],
1912 auto_discover: vec!["src/extra".to_string()],
1913 root: None,
1914 },
1915 ],
1916 rules: vec![],
1917 };
1918 let groups = config.expand_auto_discover(temp.path());
1919 assert_eq!(groups.len(), 1);
1920 assert_eq!(groups[0].merged_from.as_deref(), Some(&[0_usize, 2][..]));
1924 assert_eq!(groups[0].source_zone_index, 0);
1926 }
1927
1928 #[test]
1929 fn logical_groups_merged_from_none_on_single_declaration() {
1930 let temp = tempfile::tempdir().unwrap();
1931 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1932
1933 let mut config = BoundaryConfig {
1934 preset: None,
1935 zones: vec![BoundaryZone {
1936 name: "features".to_string(),
1937 patterns: vec![],
1938 auto_discover: vec!["src/features".to_string()],
1939 root: None,
1940 }],
1941 rules: vec![],
1942 };
1943 let groups = config.expand_auto_discover(temp.path());
1944 assert!(groups[0].merged_from.is_none());
1946 }
1947
1948 #[test]
1949 fn logical_groups_echo_original_zone_root() {
1950 let temp = tempfile::tempdir().unwrap();
1951 std::fs::create_dir_all(temp.path().join("packages/app/src/features/auth")).unwrap();
1952
1953 let mut config = BoundaryConfig {
1954 preset: None,
1955 zones: vec![BoundaryZone {
1956 name: "features".to_string(),
1957 patterns: vec![],
1958 auto_discover: vec!["src/features".to_string()],
1959 root: Some("packages/app/".to_string()),
1963 }],
1964 rules: vec![],
1965 };
1966 let groups = config.expand_auto_discover(temp.path());
1967 assert_eq!(
1968 groups[0].original_zone_root.as_deref(),
1969 Some("packages/app/")
1970 );
1971 }
1972
1973 #[test]
1974 fn logical_groups_original_zone_root_none_when_unset() {
1975 let temp = tempfile::tempdir().unwrap();
1976 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1977
1978 let mut config = BoundaryConfig {
1979 preset: None,
1980 zones: vec![BoundaryZone {
1981 name: "features".to_string(),
1982 patterns: vec![],
1983 auto_discover: vec!["src/features".to_string()],
1984 root: None,
1985 }],
1986 rules: vec![],
1987 };
1988 let groups = config.expand_auto_discover(temp.path());
1989 assert!(groups[0].original_zone_root.is_none());
1990 }
1991
1992 #[test]
1993 fn logical_groups_child_source_indices_populated_for_multi_path() {
1994 let temp = tempfile::tempdir().unwrap();
1995 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1996 std::fs::create_dir_all(temp.path().join("src/modules/billing")).unwrap();
1997
1998 let mut config = BoundaryConfig {
1999 preset: None,
2000 zones: vec![BoundaryZone {
2001 name: "features".to_string(),
2002 patterns: vec![],
2003 auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
2007 root: None,
2008 }],
2009 rules: vec![],
2010 };
2011 let groups = config.expand_auto_discover(temp.path());
2012 assert_eq!(
2013 groups[0].children,
2014 vec!["features/auth", "features/billing"]
2015 );
2016 assert_eq!(groups[0].child_source_indices, vec![0, 1]);
2017 }
2018
2019 #[test]
2020 fn logical_groups_child_source_indices_empty_for_single_path() {
2021 let temp = tempfile::tempdir().unwrap();
2022 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2023 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
2024
2025 let mut config = BoundaryConfig {
2026 preset: None,
2027 zones: vec![BoundaryZone {
2028 name: "features".to_string(),
2029 patterns: vec![],
2030 auto_discover: vec!["src/features".to_string()],
2031 root: None,
2032 }],
2033 rules: vec![],
2034 };
2035 let groups = config.expand_auto_discover(temp.path());
2036 assert!(groups[0].child_source_indices.is_empty());
2040 }
2041
2042 #[test]
2043 fn logical_groups_child_source_indices_after_duplicate_merge_shifted() {
2044 let temp = tempfile::tempdir().unwrap();
2049 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2050 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
2051
2052 let mut config = BoundaryConfig {
2053 preset: None,
2054 zones: vec![
2055 BoundaryZone {
2056 name: "features".to_string(),
2057 patterns: vec![],
2058 auto_discover: vec!["src/features".to_string()],
2059 root: None,
2060 },
2061 BoundaryZone {
2062 name: "features".to_string(),
2063 patterns: vec![],
2064 auto_discover: vec!["src/extra".to_string()],
2065 root: None,
2066 },
2067 ],
2068 rules: vec![],
2069 };
2070 let groups = config.expand_auto_discover(temp.path());
2071 assert_eq!(groups.len(), 1);
2072 assert_eq!(groups[0].auto_discover, vec!["src/features", "src/extra"]);
2076 let auth_idx = groups[0]
2077 .children
2078 .iter()
2079 .position(|c| c == "features/auth")
2080 .unwrap();
2081 let billing_idx = groups[0]
2082 .children
2083 .iter()
2084 .position(|c| c == "features/billing")
2085 .unwrap();
2086 assert_eq!(groups[0].child_source_indices[auth_idx], 0);
2087 assert_eq!(groups[0].child_source_indices[billing_idx], 1);
2088 }
2089
2090 #[test]
2091 fn logical_groups_merge_duplicate_parent_zone_declarations() {
2092 let temp = tempfile::tempdir().unwrap();
2093 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2094 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
2095
2096 let mut config = BoundaryConfig {
2097 preset: None,
2098 zones: vec![
2099 BoundaryZone {
2100 name: "features".to_string(),
2101 patterns: vec![],
2102 auto_discover: vec!["src/features".to_string()],
2103 root: None,
2104 },
2105 BoundaryZone {
2106 name: "features".to_string(),
2107 patterns: vec![],
2108 auto_discover: vec!["src/extra".to_string()],
2109 root: None,
2110 },
2111 ],
2112 rules: vec![],
2113 };
2114
2115 let groups = config.expand_auto_discover(temp.path());
2116 assert_eq!(groups.len(), 1);
2119 assert_eq!(groups[0].name, "features");
2120 assert_eq!(groups[0].auto_discover, vec!["src/features", "src/extra"]);
2121 assert!(groups[0].children.iter().any(|c| c == "features/auth"));
2122 assert!(groups[0].children.iter().any(|c| c == "features/billing"));
2123 assert_eq!(groups[0].source_zone_index, 0);
2124 }
2125
2126 #[test]
2127 fn logical_groups_duplicate_identical_declarations_no_double_count() {
2128 let temp = tempfile::tempdir().unwrap();
2135 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2136 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
2137
2138 let mut config = BoundaryConfig {
2139 preset: None,
2140 zones: vec![
2141 BoundaryZone {
2142 name: "features".to_string(),
2143 patterns: vec![],
2144 auto_discover: vec!["src/features".to_string()],
2145 root: None,
2146 },
2147 BoundaryZone {
2148 name: "features".to_string(),
2149 patterns: vec![],
2150 auto_discover: vec!["src/features".to_string()],
2151 root: None,
2152 },
2153 ],
2154 rules: vec![],
2155 };
2156
2157 let groups = config.expand_auto_discover(temp.path());
2158 assert_eq!(groups.len(), 1);
2159 let zone_names: Vec<&str> = config.zones.iter().map(|z| z.name.as_str()).collect();
2162 assert_eq!(zone_names, vec!["features/auth", "features/billing"]);
2163 assert_eq!(
2165 groups[0].children,
2166 vec!["features/auth", "features/billing"]
2167 );
2168 assert_eq!(
2172 groups[0].auto_discover,
2173 vec!["src/features", "src/features"]
2174 );
2175 assert_eq!(groups[0].merged_from.as_deref(), Some(&[0_usize, 1][..]));
2177 }
2178
2179 #[test]
2180 fn logical_groups_empty_when_no_auto_discover_present() {
2181 let temp = tempfile::tempdir().unwrap();
2182 let mut config = BoundaryConfig {
2183 preset: None,
2184 zones: vec![BoundaryZone {
2185 name: "ui".to_string(),
2186 patterns: vec!["src/components/**".to_string()],
2187 auto_discover: vec![],
2188 root: None,
2189 }],
2190 rules: vec![],
2191 };
2192 let groups = config.expand_auto_discover(temp.path());
2193 assert!(groups.is_empty());
2194 }
2195
2196 #[test]
2197 fn logical_groups_propagate_through_resolve() {
2198 let temp = tempfile::tempdir().unwrap();
2202 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2203
2204 let mut config = BoundaryConfig {
2205 preset: None,
2206 zones: vec![BoundaryZone {
2207 name: "features".to_string(),
2208 patterns: vec![],
2209 auto_discover: vec!["src/features".to_string()],
2210 root: None,
2211 }],
2212 rules: vec![],
2213 };
2214 let groups = config.expand_auto_discover(temp.path());
2215 let mut resolved = config.resolve();
2216 resolved.logical_groups = groups;
2220 assert_eq!(resolved.logical_groups.len(), 1);
2221 assert_eq!(resolved.logical_groups[0].name, "features");
2222 assert_eq!(resolved.logical_groups[0].children, vec!["features/auth"]);
2223 }
2224
2225 #[test]
2226 fn validate_zone_references_valid() {
2227 let config = BoundaryConfig {
2228 preset: None,
2229 zones: vec![
2230 BoundaryZone {
2231 name: "ui".to_string(),
2232 patterns: vec![],
2233 auto_discover: vec![],
2234 root: None,
2235 },
2236 BoundaryZone {
2237 name: "db".to_string(),
2238 patterns: vec![],
2239 auto_discover: vec![],
2240 root: None,
2241 },
2242 ],
2243 rules: vec![BoundaryRule {
2244 from: "ui".to_string(),
2245 allow: vec!["db".to_string()],
2246 allow_type_only: vec![],
2247 }],
2248 };
2249 assert!(config.validate_zone_references().is_empty());
2250 }
2251
2252 #[test]
2253 fn validate_zone_references_invalid_from() {
2254 let config = BoundaryConfig {
2255 preset: None,
2256 zones: vec![BoundaryZone {
2257 name: "ui".to_string(),
2258 patterns: vec![],
2259 auto_discover: vec![],
2260 root: None,
2261 }],
2262 rules: vec![BoundaryRule {
2263 from: "nonexistent".to_string(),
2264 allow: vec!["ui".to_string()],
2265 allow_type_only: vec![],
2266 }],
2267 };
2268 let errors = config.validate_zone_references();
2269 assert_eq!(errors.len(), 1);
2270 assert_eq!(errors[0].zone_name, "nonexistent");
2271 assert_eq!(errors[0].kind, ZoneReferenceKind::From);
2272 assert_eq!(errors[0].rule_index, 0);
2273 }
2274
2275 #[test]
2276 fn validate_zone_references_invalid_allow() {
2277 let config = BoundaryConfig {
2278 preset: None,
2279 zones: vec![BoundaryZone {
2280 name: "ui".to_string(),
2281 patterns: vec![],
2282 auto_discover: vec![],
2283 root: None,
2284 }],
2285 rules: vec![BoundaryRule {
2286 from: "ui".to_string(),
2287 allow: vec!["nonexistent".to_string()],
2288 allow_type_only: vec![],
2289 }],
2290 };
2291 let errors = config.validate_zone_references();
2292 assert_eq!(errors.len(), 1);
2293 assert_eq!(errors[0].zone_name, "nonexistent");
2294 assert_eq!(errors[0].kind, ZoneReferenceKind::Allow);
2295 }
2296
2297 #[test]
2298 fn validate_zone_references_invalid_allow_type_only() {
2299 let config = BoundaryConfig {
2303 preset: None,
2304 zones: vec![BoundaryZone {
2305 name: "ui".to_string(),
2306 patterns: vec![],
2307 auto_discover: vec![],
2308 root: None,
2309 }],
2310 rules: vec![BoundaryRule {
2311 from: "ui".to_string(),
2312 allow: vec![],
2313 allow_type_only: vec!["nonexistent_type_zone".to_string()],
2314 }],
2315 };
2316 let errors = config.validate_zone_references();
2317 assert_eq!(errors.len(), 1, "got: {errors:?}");
2318 assert_eq!(errors[0].zone_name, "nonexistent_type_zone");
2319 assert_eq!(errors[0].kind, ZoneReferenceKind::AllowTypeOnly);
2320 }
2321
2322 #[test]
2323 fn resolve_and_classify() {
2324 let config = BoundaryConfig {
2325 preset: None,
2326 zones: vec![
2327 BoundaryZone {
2328 name: "ui".to_string(),
2329 patterns: vec!["src/components/**".to_string()],
2330 auto_discover: vec![],
2331 root: None,
2332 },
2333 BoundaryZone {
2334 name: "db".to_string(),
2335 patterns: vec!["src/db/**".to_string()],
2336 auto_discover: vec![],
2337 root: None,
2338 },
2339 ],
2340 rules: vec![],
2341 };
2342 let resolved = config.resolve();
2343 assert_eq!(
2344 resolved.classify_zone("src/components/Button.tsx"),
2345 Some("ui")
2346 );
2347 assert_eq!(resolved.classify_zone("src/db/queries.ts"), Some("db"));
2348 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
2349 }
2350
2351 #[test]
2352 fn first_match_wins() {
2353 let config = BoundaryConfig {
2354 preset: None,
2355 zones: vec![
2356 BoundaryZone {
2357 name: "specific".to_string(),
2358 patterns: vec!["src/shared/db-utils/**".to_string()],
2359 auto_discover: vec![],
2360 root: None,
2361 },
2362 BoundaryZone {
2363 name: "shared".to_string(),
2364 patterns: vec!["src/shared/**".to_string()],
2365 auto_discover: vec![],
2366 root: None,
2367 },
2368 ],
2369 rules: vec![],
2370 };
2371 let resolved = config.resolve();
2372 assert_eq!(
2373 resolved.classify_zone("src/shared/db-utils/pool.ts"),
2374 Some("specific")
2375 );
2376 assert_eq!(
2377 resolved.classify_zone("src/shared/helpers.ts"),
2378 Some("shared")
2379 );
2380 }
2381
2382 #[test]
2383 fn self_import_always_allowed() {
2384 let config = BoundaryConfig {
2385 preset: None,
2386 zones: vec![BoundaryZone {
2387 name: "ui".to_string(),
2388 patterns: vec![],
2389 auto_discover: vec![],
2390 root: None,
2391 }],
2392 rules: vec![BoundaryRule {
2393 from: "ui".to_string(),
2394 allow: vec![],
2395 allow_type_only: vec![],
2396 }],
2397 };
2398 let resolved = config.resolve();
2399 assert!(resolved.is_import_allowed("ui", "ui"));
2400 }
2401
2402 #[test]
2403 fn unrestricted_zone_allows_all() {
2404 let config = BoundaryConfig {
2405 preset: None,
2406 zones: vec![
2407 BoundaryZone {
2408 name: "shared".to_string(),
2409 patterns: vec![],
2410 auto_discover: vec![],
2411 root: None,
2412 },
2413 BoundaryZone {
2414 name: "db".to_string(),
2415 patterns: vec![],
2416 auto_discover: vec![],
2417 root: None,
2418 },
2419 ],
2420 rules: vec![],
2421 };
2422 let resolved = config.resolve();
2423 assert!(resolved.is_import_allowed("shared", "db"));
2424 }
2425
2426 #[test]
2427 fn restricted_zone_blocks_unlisted() {
2428 let config = BoundaryConfig {
2429 preset: None,
2430 zones: vec![
2431 BoundaryZone {
2432 name: "ui".to_string(),
2433 patterns: vec![],
2434 auto_discover: vec![],
2435 root: None,
2436 },
2437 BoundaryZone {
2438 name: "db".to_string(),
2439 patterns: vec![],
2440 auto_discover: vec![],
2441 root: None,
2442 },
2443 BoundaryZone {
2444 name: "shared".to_string(),
2445 patterns: vec![],
2446 auto_discover: vec![],
2447 root: None,
2448 },
2449 ],
2450 rules: vec![BoundaryRule {
2451 from: "ui".to_string(),
2452 allow: vec!["shared".to_string()],
2453 allow_type_only: vec![],
2454 }],
2455 };
2456 let resolved = config.resolve();
2457 assert!(resolved.is_import_allowed("ui", "shared"));
2458 assert!(!resolved.is_import_allowed("ui", "db"));
2459 }
2460
2461 #[test]
2462 fn empty_allow_blocks_all_except_self() {
2463 let config = BoundaryConfig {
2464 preset: None,
2465 zones: vec![
2466 BoundaryZone {
2467 name: "isolated".to_string(),
2468 patterns: vec![],
2469 auto_discover: vec![],
2470 root: None,
2471 },
2472 BoundaryZone {
2473 name: "other".to_string(),
2474 patterns: vec![],
2475 auto_discover: vec![],
2476 root: None,
2477 },
2478 ],
2479 rules: vec![BoundaryRule {
2480 from: "isolated".to_string(),
2481 allow: vec![],
2482 allow_type_only: vec![],
2483 }],
2484 };
2485 let resolved = config.resolve();
2486 assert!(resolved.is_import_allowed("isolated", "isolated"));
2487 assert!(!resolved.is_import_allowed("isolated", "other"));
2488 }
2489
2490 #[test]
2491 fn zone_root_filters_classification_to_subtree() {
2492 let config = BoundaryConfig {
2493 preset: None,
2494 zones: vec![
2495 BoundaryZone {
2496 name: "ui".to_string(),
2497 patterns: vec!["src/**".to_string()],
2498 auto_discover: vec![],
2499 root: Some("packages/app/".to_string()),
2500 },
2501 BoundaryZone {
2502 name: "domain".to_string(),
2503 patterns: vec!["src/**".to_string()],
2504 auto_discover: vec![],
2505 root: Some("packages/core/".to_string()),
2506 },
2507 ],
2508 rules: vec![],
2509 };
2510 let resolved = config.resolve();
2511 assert_eq!(
2513 resolved.classify_zone("packages/app/src/login.tsx"),
2514 Some("ui")
2515 );
2516 assert_eq!(
2518 resolved.classify_zone("packages/core/src/order.ts"),
2519 Some("domain")
2520 );
2521 assert_eq!(resolved.classify_zone("src/login.tsx"), None);
2523 assert_eq!(resolved.classify_zone("packages/utils/src/x.ts"), None);
2524 }
2525
2526 #[test]
2533 fn zone_root_is_case_sensitive() {
2534 let config = BoundaryConfig {
2535 preset: None,
2536 zones: vec![BoundaryZone {
2537 name: "ui".to_string(),
2538 patterns: vec!["src/**".to_string()],
2539 auto_discover: vec![],
2540 root: Some("packages/app/".to_string()),
2541 }],
2542 rules: vec![],
2543 };
2544 let resolved = config.resolve();
2545 assert_eq!(
2546 resolved.classify_zone("packages/app/src/login.tsx"),
2547 Some("ui"),
2548 "exact-case path classifies"
2549 );
2550 assert_eq!(
2551 resolved.classify_zone("packages/App/src/login.tsx"),
2552 None,
2553 "case-different path does not classify (root is case-sensitive)"
2554 );
2555 assert_eq!(
2556 resolved.classify_zone("Packages/app/src/login.tsx"),
2557 None,
2558 "case-different prefix does not classify"
2559 );
2560 }
2561
2562 #[test]
2563 fn zone_root_normalizes_trailing_slash_and_dot_prefix() {
2564 let config = BoundaryConfig {
2565 preset: None,
2566 zones: vec![
2567 BoundaryZone {
2568 name: "no-slash".to_string(),
2569 patterns: vec!["src/**".to_string()],
2570 auto_discover: vec![],
2571 root: Some("packages/app".to_string()),
2572 },
2573 BoundaryZone {
2574 name: "dot-prefixed".to_string(),
2575 patterns: vec!["src/**".to_string()],
2576 auto_discover: vec![],
2577 root: Some("./packages/lib/".to_string()),
2578 },
2579 ],
2580 rules: vec![],
2581 };
2582 let resolved = config.resolve();
2583 assert_eq!(resolved.zones[0].root.as_deref(), Some("packages/app/"));
2584 assert_eq!(resolved.zones[1].root.as_deref(), Some("packages/lib/"));
2585 assert_eq!(
2586 resolved.classify_zone("packages/app/src/x.ts"),
2587 Some("no-slash")
2588 );
2589 assert_eq!(
2590 resolved.classify_zone("packages/lib/src/x.ts"),
2591 Some("dot-prefixed")
2592 );
2593 }
2594
2595 #[test]
2596 fn validate_root_prefixes_flags_redundant_pattern() {
2597 let config = BoundaryConfig {
2598 preset: None,
2599 zones: vec![BoundaryZone {
2600 name: "ui".to_string(),
2601 patterns: vec!["packages/app/src/**".to_string()],
2602 auto_discover: vec![],
2603 root: Some("packages/app/".to_string()),
2604 }],
2605 rules: vec![],
2606 };
2607 let errors = config.validate_root_prefixes();
2608 assert_eq!(errors.len(), 1, "expected one redundant-prefix error");
2609 assert_eq!(errors[0].zone_name, "ui");
2610 assert_eq!(errors[0].pattern, "packages/app/src/**");
2611 assert_eq!(errors[0].root, "packages/app/");
2612 let rendered = ZoneValidationError::RedundantRootPrefix(errors[0].clone()).to_string();
2615 assert!(
2616 rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"),
2617 "Display should carry legacy tag: {rendered}"
2618 );
2619 assert!(
2620 rendered.contains("zone 'ui'"),
2621 "Display rendering: {rendered}"
2622 );
2623 assert!(
2624 rendered.contains("packages/app/src/**"),
2625 "Display rendering: {rendered}"
2626 );
2627 }
2628
2629 #[test]
2630 fn validate_root_prefixes_handles_unnormalized_root() {
2631 let config = BoundaryConfig {
2634 preset: None,
2635 zones: vec![BoundaryZone {
2636 name: "ui".to_string(),
2637 patterns: vec!["./packages/app/src/**".to_string()],
2638 auto_discover: vec![],
2639 root: Some("packages/app".to_string()),
2640 }],
2641 rules: vec![],
2642 };
2643 let errors = config.validate_root_prefixes();
2644 assert_eq!(errors.len(), 1);
2645 }
2646
2647 #[test]
2648 fn validate_root_prefixes_empty_when_no_overlap() {
2649 let config = BoundaryConfig {
2650 preset: None,
2651 zones: vec![BoundaryZone {
2652 name: "ui".to_string(),
2653 patterns: vec!["src/**".to_string()],
2654 auto_discover: vec![],
2655 root: Some("packages/app/".to_string()),
2656 }],
2657 rules: vec![],
2658 };
2659 assert!(config.validate_root_prefixes().is_empty());
2660 }
2661
2662 #[test]
2663 fn validate_root_prefixes_skips_zones_without_root() {
2664 let json = r#"{
2665 "zones": [{ "name": "ui", "patterns": ["src/**"] }],
2666 "rules": []
2667 }"#;
2668 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2669 assert!(config.validate_root_prefixes().is_empty());
2670 }
2671
2672 #[test]
2678 fn validate_root_prefixes_skips_empty_root() {
2679 for raw_root in ["", ".", "./"] {
2680 let config = BoundaryConfig {
2681 preset: None,
2682 zones: vec![BoundaryZone {
2683 name: "ui".to_string(),
2684 patterns: vec!["src/**".to_string(), "lib/**".to_string()],
2685 auto_discover: vec![],
2686 root: Some(raw_root.to_string()),
2687 }],
2688 rules: vec![],
2689 };
2690 let errors = config.validate_root_prefixes();
2691 assert!(
2692 errors.is_empty(),
2693 "empty-normalized root {raw_root:?} produced spurious errors: {errors:?}"
2694 );
2695 }
2696 }
2697
2698 #[test]
2699 fn deserialize_zone_with_root() {
2700 let json = r#"{
2701 "zones": [
2702 { "name": "ui", "patterns": ["src/**"], "root": "packages/app/" }
2703 ],
2704 "rules": []
2705 }"#;
2706 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2707 assert_eq!(config.zones[0].root.as_deref(), Some("packages/app/"));
2708 }
2709
2710 #[test]
2713 fn deserialize_preset_json() {
2714 let json = r#"{ "preset": "layered" }"#;
2715 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2716 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
2717 assert!(config.zones.is_empty());
2718 }
2719
2720 #[test]
2721 fn deserialize_preset_hexagonal_json() {
2722 let json = r#"{ "preset": "hexagonal" }"#;
2723 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2724 assert_eq!(config.preset, Some(BoundaryPreset::Hexagonal));
2725 }
2726
2727 #[test]
2728 fn deserialize_preset_feature_sliced_json() {
2729 let json = r#"{ "preset": "feature-sliced" }"#;
2730 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2731 assert_eq!(config.preset, Some(BoundaryPreset::FeatureSliced));
2732 }
2733
2734 #[test]
2735 fn deserialize_preset_toml() {
2736 let toml_str = r#"preset = "layered""#;
2737 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
2738 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
2739 }
2740
2741 #[test]
2742 fn deserialize_invalid_preset_rejected() {
2743 let json = r#"{ "preset": "invalid_preset" }"#;
2744 let result: Result<BoundaryConfig, _> = serde_json::from_str(json);
2745 assert!(result.is_err());
2746 }
2747
2748 #[test]
2749 fn preset_absent_by_default() {
2750 let config = BoundaryConfig::default();
2751 assert!(config.preset.is_none());
2752 assert!(config.is_empty());
2753 }
2754
2755 #[test]
2756 fn preset_makes_config_non_empty() {
2757 let config = BoundaryConfig {
2758 preset: Some(BoundaryPreset::Layered),
2759 zones: vec![],
2760 rules: vec![],
2761 };
2762 assert!(!config.is_empty());
2763 }
2764
2765 #[test]
2768 fn expand_layered_produces_four_zones() {
2769 let mut config = BoundaryConfig {
2770 preset: Some(BoundaryPreset::Layered),
2771 zones: vec![],
2772 rules: vec![],
2773 };
2774 config.expand("src");
2775 assert_eq!(config.zones.len(), 4);
2776 assert_eq!(config.rules.len(), 4);
2777 assert!(config.preset.is_none(), "preset cleared after expand");
2778 assert_eq!(config.zones[0].name, "presentation");
2779 assert_eq!(config.zones[0].patterns, vec!["src/presentation/**"]);
2780 }
2781
2782 #[test]
2783 fn expand_layered_rules_correct() {
2784 let mut config = BoundaryConfig {
2785 preset: Some(BoundaryPreset::Layered),
2786 zones: vec![],
2787 rules: vec![],
2788 };
2789 config.expand("src");
2790 let pres_rule = config
2792 .rules
2793 .iter()
2794 .find(|r| r.from == "presentation")
2795 .unwrap();
2796 assert_eq!(pres_rule.allow, vec!["application"]);
2797 let app_rule = config
2799 .rules
2800 .iter()
2801 .find(|r| r.from == "application")
2802 .unwrap();
2803 assert_eq!(app_rule.allow, vec!["domain"]);
2804 let dom_rule = config.rules.iter().find(|r| r.from == "domain").unwrap();
2806 assert!(dom_rule.allow.is_empty());
2807 let infra_rule = config
2809 .rules
2810 .iter()
2811 .find(|r| r.from == "infrastructure")
2812 .unwrap();
2813 assert_eq!(infra_rule.allow, vec!["domain", "application"]);
2814 }
2815
2816 #[test]
2817 fn expand_hexagonal_produces_three_zones() {
2818 let mut config = BoundaryConfig {
2819 preset: Some(BoundaryPreset::Hexagonal),
2820 zones: vec![],
2821 rules: vec![],
2822 };
2823 config.expand("src");
2824 assert_eq!(config.zones.len(), 3);
2825 assert_eq!(config.rules.len(), 3);
2826 assert_eq!(config.zones[0].name, "adapters");
2827 assert_eq!(config.zones[1].name, "ports");
2828 assert_eq!(config.zones[2].name, "domain");
2829 }
2830
2831 #[test]
2832 fn expand_feature_sliced_produces_six_zones() {
2833 let mut config = BoundaryConfig {
2834 preset: Some(BoundaryPreset::FeatureSliced),
2835 zones: vec![],
2836 rules: vec![],
2837 };
2838 config.expand("src");
2839 assert_eq!(config.zones.len(), 6);
2840 assert_eq!(config.rules.len(), 6);
2841 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
2843 assert_eq!(
2844 app_rule.allow,
2845 vec!["pages", "widgets", "features", "entities", "shared"]
2846 );
2847 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
2849 assert!(shared_rule.allow.is_empty());
2850 let ent_rule = config.rules.iter().find(|r| r.from == "entities").unwrap();
2852 assert_eq!(ent_rule.allow, vec!["shared"]);
2853 }
2854
2855 #[test]
2856 fn expand_bulletproof_produces_four_zones() {
2857 let mut config = BoundaryConfig {
2858 preset: Some(BoundaryPreset::Bulletproof),
2859 zones: vec![],
2860 rules: vec![],
2861 };
2862 config.expand("src");
2863 assert_eq!(config.zones.len(), 4);
2864 assert_eq!(config.rules.len(), 4);
2865 assert_eq!(config.zones[0].name, "app");
2866 assert_eq!(config.zones[1].name, "features");
2867 assert_eq!(config.zones[2].name, "shared");
2868 assert_eq!(config.zones[3].name, "server");
2869 assert!(config.zones[2].patterns.len() > 1);
2871 assert!(
2872 config.zones[2]
2873 .patterns
2874 .contains(&"src/components/**".to_string())
2875 );
2876 assert!(
2877 config.zones[2]
2878 .patterns
2879 .contains(&"src/hooks/**".to_string())
2880 );
2881 assert!(config.zones[2].patterns.contains(&"src/lib/**".to_string()));
2882 assert!(
2883 config.zones[2]
2884 .patterns
2885 .contains(&"src/providers/**".to_string())
2886 );
2887 }
2888
2889 #[test]
2890 fn expand_bulletproof_rules_correct() {
2891 let mut config = BoundaryConfig {
2892 preset: Some(BoundaryPreset::Bulletproof),
2893 zones: vec![],
2894 rules: vec![],
2895 };
2896 config.expand("src");
2897 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
2899 assert_eq!(app_rule.allow, vec!["features", "shared", "server"]);
2900 let feat_rule = config.rules.iter().find(|r| r.from == "features").unwrap();
2902 assert_eq!(feat_rule.allow, vec!["shared", "server"]);
2903 let srv_rule = config.rules.iter().find(|r| r.from == "server").unwrap();
2905 assert_eq!(srv_rule.allow, vec!["shared"]);
2906 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
2908 assert!(shared_rule.allow.is_empty());
2909 }
2910
2911 #[test]
2912 fn expand_bulletproof_then_resolve_classifies() {
2913 let mut config = BoundaryConfig {
2917 preset: Some(BoundaryPreset::Bulletproof),
2918 zones: vec![],
2919 rules: vec![],
2920 };
2921 config.expand("src");
2922 let resolved = config.resolve();
2923 assert_eq!(
2924 resolved.classify_zone("src/app/dashboard/page.tsx"),
2925 Some("app")
2926 );
2927 assert_eq!(
2928 resolved.classify_zone("src/features/auth/hooks/useAuth.ts"),
2929 Some("features"),
2930 "without expand_auto_discover, src/features/... falls back to the parent zone"
2931 );
2932 assert_eq!(
2933 resolved.classify_zone("src/components/Button/Button.tsx"),
2934 Some("shared")
2935 );
2936 assert_eq!(
2937 resolved.classify_zone("src/hooks/useFormatters.ts"),
2938 Some("shared")
2939 );
2940 assert_eq!(
2941 resolved.classify_zone("src/server/db/schema/users.ts"),
2942 Some("server")
2943 );
2944 assert!(resolved.is_import_allowed("features", "shared"));
2946 assert!(resolved.is_import_allowed("features", "server"));
2947 assert!(!resolved.is_import_allowed("features", "app"));
2948 assert!(!resolved.is_import_allowed("shared", "features"));
2949 assert!(!resolved.is_import_allowed("server", "features"));
2950 }
2951
2952 #[test]
2958 fn bulletproof_features_barrel_can_import_children() {
2959 let temp = tempfile::tempdir().unwrap();
2960 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2961 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
2962
2963 let mut config = BoundaryConfig {
2964 preset: Some(BoundaryPreset::Bulletproof),
2965 zones: vec![],
2966 rules: vec![],
2967 };
2968 config.expand("src");
2969 config.expand_auto_discover(temp.path());
2970 let resolved = config.resolve();
2971
2972 assert_eq!(
2974 resolved.classify_zone("src/features/index.ts"),
2975 Some("features"),
2976 "src/features/index.ts barrel should classify as the parent features zone"
2977 );
2978 assert_eq!(
2980 resolved.classify_zone("src/features/auth/login.ts"),
2981 Some("features/auth")
2982 );
2983 assert_eq!(
2984 resolved.classify_zone("src/features/billing/invoice.ts"),
2985 Some("features/billing")
2986 );
2987 assert!(resolved.is_import_allowed("features", "features/auth"));
2989 assert!(resolved.is_import_allowed("features", "features/billing"));
2990 assert!(!resolved.is_import_allowed("features/auth", "features/billing"));
2992 }
2993
2994 #[test]
2995 fn expand_uses_custom_source_root() {
2996 let mut config = BoundaryConfig {
2997 preset: Some(BoundaryPreset::Hexagonal),
2998 zones: vec![],
2999 rules: vec![],
3000 };
3001 config.expand("lib");
3002 assert_eq!(config.zones[0].patterns, vec!["lib/adapters/**"]);
3003 assert_eq!(config.zones[2].patterns, vec!["lib/domain/**"]);
3004 }
3005
3006 #[test]
3009 fn user_zone_replaces_preset_zone() {
3010 let mut config = BoundaryConfig {
3011 preset: Some(BoundaryPreset::Hexagonal),
3012 zones: vec![BoundaryZone {
3013 name: "domain".to_string(),
3014 patterns: vec!["src/core/**".to_string()],
3015 auto_discover: vec![],
3016 root: None,
3017 }],
3018 rules: vec![],
3019 };
3020 config.expand("src");
3021 assert_eq!(config.zones.len(), 3);
3023 let domain = config.zones.iter().find(|z| z.name == "domain").unwrap();
3024 assert_eq!(domain.patterns, vec!["src/core/**"]);
3025 }
3026
3027 #[test]
3028 fn user_zone_adds_to_preset() {
3029 let mut config = BoundaryConfig {
3030 preset: Some(BoundaryPreset::Hexagonal),
3031 zones: vec![BoundaryZone {
3032 name: "shared".to_string(),
3033 patterns: vec!["src/shared/**".to_string()],
3034 auto_discover: vec![],
3035 root: None,
3036 }],
3037 rules: vec![],
3038 };
3039 config.expand("src");
3040 assert_eq!(config.zones.len(), 4); assert!(config.zones.iter().any(|z| z.name == "shared"));
3042 }
3043
3044 #[test]
3045 fn user_rule_replaces_preset_rule() {
3046 let mut config = BoundaryConfig {
3047 preset: Some(BoundaryPreset::Hexagonal),
3048 zones: vec![],
3049 rules: vec![BoundaryRule {
3050 from: "adapters".to_string(),
3051 allow: vec!["ports".to_string(), "domain".to_string()],
3052 allow_type_only: vec![],
3053 }],
3054 };
3055 config.expand("src");
3056 let adapter_rule = config.rules.iter().find(|r| r.from == "adapters").unwrap();
3057 assert_eq!(adapter_rule.allow, vec!["ports", "domain"]);
3059 assert_eq!(
3061 config.rules.iter().filter(|r| r.from == "adapters").count(),
3062 1
3063 );
3064 }
3065
3066 #[test]
3067 fn expand_without_preset_is_noop() {
3068 let mut config = BoundaryConfig {
3069 preset: None,
3070 zones: vec![BoundaryZone {
3071 name: "ui".to_string(),
3072 patterns: vec!["src/ui/**".to_string()],
3073 auto_discover: vec![],
3074 root: None,
3075 }],
3076 rules: vec![],
3077 };
3078 config.expand("src");
3079 assert_eq!(config.zones.len(), 1);
3080 assert_eq!(config.zones[0].name, "ui");
3081 }
3082
3083 #[test]
3084 fn expand_then_validate_succeeds() {
3085 let mut config = BoundaryConfig {
3086 preset: Some(BoundaryPreset::Layered),
3087 zones: vec![],
3088 rules: vec![],
3089 };
3090 config.expand("src");
3091 assert!(config.validate_zone_references().is_empty());
3092 }
3093
3094 #[test]
3095 fn expand_then_resolve_classifies() {
3096 let mut config = BoundaryConfig {
3097 preset: Some(BoundaryPreset::Hexagonal),
3098 zones: vec![],
3099 rules: vec![],
3100 };
3101 config.expand("src");
3102 let resolved = config.resolve();
3103 assert_eq!(
3104 resolved.classify_zone("src/adapters/http/handler.ts"),
3105 Some("adapters")
3106 );
3107 assert_eq!(resolved.classify_zone("src/domain/user.ts"), Some("domain"));
3108 assert!(!resolved.is_import_allowed("adapters", "domain"));
3109 assert!(resolved.is_import_allowed("adapters", "ports"));
3110 }
3111
3112 #[test]
3113 fn preset_name_returns_correct_string() {
3114 let config = BoundaryConfig {
3115 preset: Some(BoundaryPreset::FeatureSliced),
3116 zones: vec![],
3117 rules: vec![],
3118 };
3119 assert_eq!(config.preset_name(), Some("feature-sliced"));
3120
3121 let empty = BoundaryConfig::default();
3122 assert_eq!(empty.preset_name(), None);
3123 }
3124
3125 #[test]
3126 fn preset_name_all_variants() {
3127 let cases = [
3128 (BoundaryPreset::Layered, "layered"),
3129 (BoundaryPreset::Hexagonal, "hexagonal"),
3130 (BoundaryPreset::FeatureSliced, "feature-sliced"),
3131 (BoundaryPreset::Bulletproof, "bulletproof"),
3132 ];
3133 for (preset, expected_name) in cases {
3134 let config = BoundaryConfig {
3135 preset: Some(preset),
3136 zones: vec![],
3137 rules: vec![],
3138 };
3139 assert_eq!(
3140 config.preset_name(),
3141 Some(expected_name),
3142 "preset_name() mismatch for variant"
3143 );
3144 }
3145 }
3146
3147 #[test]
3150 fn resolved_boundary_config_empty() {
3151 let resolved = ResolvedBoundaryConfig::default();
3152 assert!(resolved.is_empty());
3153 }
3154
3155 #[test]
3156 fn resolved_boundary_config_with_zones_not_empty() {
3157 let config = BoundaryConfig {
3158 preset: None,
3159 zones: vec![BoundaryZone {
3160 name: "ui".to_string(),
3161 patterns: vec!["src/ui/**".to_string()],
3162 auto_discover: vec![],
3163 root: None,
3164 }],
3165 rules: vec![],
3166 };
3167 let resolved = config.resolve();
3168 assert!(!resolved.is_empty());
3169 }
3170
3171 #[test]
3172 fn resolved_boundary_config_with_only_logical_groups_not_empty() {
3173 let resolved = ResolvedBoundaryConfig {
3180 zones: vec![],
3181 rules: vec![],
3182 logical_groups: vec![LogicalGroup {
3183 name: "features".to_string(),
3184 children: vec![],
3185 auto_discover: vec!["src/features".to_string()],
3186 authored_rule: None,
3187 fallback_zone: None,
3188 source_zone_index: 0,
3189 status: LogicalGroupStatus::Empty,
3190 merged_from: None,
3191 original_zone_root: None,
3192 child_source_indices: vec![],
3193 }],
3194 };
3195 assert!(!resolved.is_empty());
3196 }
3197
3198 #[test]
3201 fn boundary_config_with_only_rules_is_empty() {
3202 let config = BoundaryConfig {
3205 preset: None,
3206 zones: vec![],
3207 rules: vec![BoundaryRule {
3208 from: "ui".to_string(),
3209 allow: vec!["db".to_string()],
3210 allow_type_only: vec![],
3211 }],
3212 };
3213 assert!(config.is_empty());
3214 }
3215
3216 #[test]
3217 fn boundary_config_with_zones_not_empty() {
3218 let config = BoundaryConfig {
3219 preset: None,
3220 zones: vec![BoundaryZone {
3221 name: "ui".to_string(),
3222 patterns: vec![],
3223 auto_discover: vec![],
3224 root: None,
3225 }],
3226 rules: vec![],
3227 };
3228 assert!(!config.is_empty());
3229 }
3230
3231 #[test]
3234 fn zone_with_multiple_patterns_matches_any() {
3235 let config = BoundaryConfig {
3236 preset: None,
3237 zones: vec![BoundaryZone {
3238 name: "ui".to_string(),
3239 patterns: vec![
3240 "src/components/**".to_string(),
3241 "src/pages/**".to_string(),
3242 "src/views/**".to_string(),
3243 ],
3244 auto_discover: vec![],
3245 root: None,
3246 }],
3247 rules: vec![],
3248 };
3249 let resolved = config.resolve();
3250 assert_eq!(
3251 resolved.classify_zone("src/components/Button.tsx"),
3252 Some("ui")
3253 );
3254 assert_eq!(resolved.classify_zone("src/pages/Home.tsx"), Some("ui"));
3255 assert_eq!(
3256 resolved.classify_zone("src/views/Dashboard.tsx"),
3257 Some("ui")
3258 );
3259 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
3260 }
3261
3262 #[test]
3265 fn validate_zone_references_multiple_errors() {
3266 let config = BoundaryConfig {
3267 preset: None,
3268 zones: vec![BoundaryZone {
3269 name: "ui".to_string(),
3270 patterns: vec![],
3271 auto_discover: vec![],
3272 root: None,
3273 }],
3274 rules: vec![
3275 BoundaryRule {
3276 from: "nonexistent_from".to_string(),
3277 allow: vec!["nonexistent_allow".to_string()],
3278 allow_type_only: vec![],
3279 },
3280 BoundaryRule {
3281 from: "ui".to_string(),
3282 allow: vec!["also_nonexistent".to_string()],
3283 allow_type_only: vec![],
3284 },
3285 ],
3286 };
3287 let errors = config.validate_zone_references();
3288 assert_eq!(errors.len(), 3);
3291 }
3292
3293 #[test]
3296 fn expand_feature_sliced_with_custom_root() {
3297 let mut config = BoundaryConfig {
3298 preset: Some(BoundaryPreset::FeatureSliced),
3299 zones: vec![],
3300 rules: vec![],
3301 };
3302 config.expand("lib");
3303 assert_eq!(config.zones[0].patterns, vec!["lib/app/**"]);
3304 assert_eq!(config.zones[5].patterns, vec!["lib/shared/**"]);
3305 }
3306
3307 #[test]
3310 fn zone_not_in_rules_is_unrestricted() {
3311 let config = BoundaryConfig {
3312 preset: None,
3313 zones: vec![
3314 BoundaryZone {
3315 name: "a".to_string(),
3316 patterns: vec![],
3317 auto_discover: vec![],
3318 root: None,
3319 },
3320 BoundaryZone {
3321 name: "b".to_string(),
3322 patterns: vec![],
3323 auto_discover: vec![],
3324 root: None,
3325 },
3326 BoundaryZone {
3327 name: "c".to_string(),
3328 patterns: vec![],
3329 auto_discover: vec![],
3330 root: None,
3331 },
3332 ],
3333 rules: vec![BoundaryRule {
3334 from: "a".to_string(),
3335 allow: vec!["b".to_string()],
3336 allow_type_only: vec![],
3337 }],
3338 };
3339 let resolved = config.resolve();
3340 assert!(resolved.is_import_allowed("a", "b"));
3342 assert!(!resolved.is_import_allowed("a", "c"));
3343 assert!(resolved.is_import_allowed("b", "a"));
3345 assert!(resolved.is_import_allowed("b", "c"));
3346 assert!(resolved.is_import_allowed("c", "a"));
3348 }
3349
3350 #[test]
3353 fn boundary_preset_json_roundtrip() {
3354 let presets = [
3355 BoundaryPreset::Layered,
3356 BoundaryPreset::Hexagonal,
3357 BoundaryPreset::FeatureSliced,
3358 BoundaryPreset::Bulletproof,
3359 ];
3360 for preset in presets {
3361 let json = serde_json::to_string(&preset).unwrap();
3362 let restored: BoundaryPreset = serde_json::from_str(&json).unwrap();
3363 assert_eq!(restored, preset);
3364 }
3365 }
3366
3367 #[test]
3368 fn deserialize_preset_bulletproof_json() {
3369 let json = r#"{ "preset": "bulletproof" }"#;
3370 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
3371 assert_eq!(config.preset, Some(BoundaryPreset::Bulletproof));
3372 }
3373
3374 #[test]
3377 #[should_panic(expected = "validated at config load time")]
3378 fn resolve_panics_on_unvalidated_invalid_zone_glob() {
3379 let config = BoundaryConfig {
3384 preset: None,
3385 zones: vec![BoundaryZone {
3386 name: "broken".to_string(),
3387 patterns: vec!["[invalid".to_string()],
3388 auto_discover: vec![],
3389 root: None,
3390 }],
3391 rules: vec![],
3392 };
3393 let _ = config.resolve();
3394 }
3395}