1use std::path::Path;
4
5use globset::Glob;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
25#[serde(rename_all = "kebab-case")]
26pub enum BoundaryPreset {
27 Layered,
30 Hexagonal,
32 FeatureSliced,
35 Bulletproof,
43}
44
45impl BoundaryPreset {
46 #[must_use]
51 pub fn default_config(&self, source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
52 match self {
53 Self::Layered => Self::layered_config(source_root),
54 Self::Hexagonal => Self::hexagonal_config(source_root),
55 Self::FeatureSliced => Self::feature_sliced_config(source_root),
56 Self::Bulletproof => Self::bulletproof_config(source_root),
57 }
58 }
59
60 fn zone(name: &str, source_root: &str) -> BoundaryZone {
61 BoundaryZone {
62 name: name.to_owned(),
63 patterns: vec![format!("{source_root}/{name}/**")],
64 auto_discover: vec![],
65 root: None,
66 }
67 }
68
69 fn rule(from: &str, allow: &[&str]) -> BoundaryRule {
70 BoundaryRule {
71 from: from.to_owned(),
72 allow: allow.iter().map(|s| (*s).to_owned()).collect(),
73 allow_type_only: Vec::new(),
74 }
75 }
76
77 fn layered_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
78 let zones = vec![
79 Self::zone("presentation", source_root),
80 Self::zone("application", source_root),
81 Self::zone("domain", source_root),
82 Self::zone("infrastructure", source_root),
83 ];
84 let rules = vec![
85 Self::rule("presentation", &["application"]),
86 Self::rule("application", &["domain"]),
87 Self::rule("domain", &[]),
88 Self::rule("infrastructure", &["domain", "application"]),
89 ];
90 (zones, rules)
91 }
92
93 fn hexagonal_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
94 let zones = vec![
95 Self::zone("adapters", source_root),
96 Self::zone("ports", source_root),
97 Self::zone("domain", source_root),
98 ];
99 let rules = vec![
100 Self::rule("adapters", &["ports"]),
101 Self::rule("ports", &["domain"]),
102 Self::rule("domain", &[]),
103 ];
104 (zones, rules)
105 }
106
107 fn feature_sliced_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
108 let layer_names = ["app", "pages", "widgets", "features", "entities", "shared"];
109 let zones = layer_names
110 .iter()
111 .map(|name| Self::zone(name, source_root))
112 .collect();
113 let rules = layer_names
114 .iter()
115 .enumerate()
116 .map(|(i, name)| {
117 let below: Vec<&str> = layer_names[i + 1..].to_vec();
118 Self::rule(name, &below)
119 })
120 .collect();
121 (zones, rules)
122 }
123
124 fn bulletproof_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
125 let zones = vec![
126 Self::zone("app", source_root),
127 BoundaryZone {
128 name: "features".to_owned(),
132 patterns: vec![format!("{source_root}/features/**")],
133 auto_discover: vec![format!("{source_root}/features")],
134 root: None,
135 },
136 BoundaryZone {
137 name: "shared".to_owned(),
138 patterns: [
139 "components",
140 "hooks",
141 "lib",
142 "utils",
143 "utilities",
144 "providers",
145 "shared",
146 "types",
147 "styles",
148 "i18n",
149 ]
150 .iter()
151 .map(|dir| format!("{source_root}/{dir}/**"))
152 .collect(),
153 auto_discover: vec![],
154 root: None,
155 },
156 Self::zone("server", source_root),
157 ];
158 let rules = vec![
159 Self::rule("app", &["features", "shared", "server"]),
160 Self::rule("features", &["shared", "server"]),
161 Self::rule("server", &["shared"]),
162 Self::rule("shared", &[]),
163 ];
164 (zones, rules)
165 }
166}
167
168#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
204#[serde(rename_all = "camelCase")]
205pub struct BoundaryConfig {
206 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub preset: Option<BoundaryPreset>,
215 #[serde(default)]
217 pub zones: Vec<BoundaryZone>,
218 #[serde(default)]
221 pub rules: Vec<BoundaryRule>,
222}
223
224#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
226#[serde(rename_all = "camelCase")]
227pub struct BoundaryZone {
228 pub name: String,
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub patterns: Vec<String>,
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub auto_discover: Vec<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub root: Option<String>,
265}
266
267#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
269#[serde(rename_all = "camelCase")]
270pub struct BoundaryRule {
271 pub from: String,
273 #[serde(default)]
276 pub allow: Vec<String>,
277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub allow_type_only: Vec<String>,
290}
291
292#[derive(Debug, Default)]
294pub struct ResolvedBoundaryConfig {
295 pub zones: Vec<ResolvedZone>,
297 pub rules: Vec<ResolvedBoundaryRule>,
299 pub logical_groups: Vec<LogicalGroup>,
305}
306
307#[derive(Debug, Clone, Serialize, JsonSchema)]
313#[serde(rename_all = "snake_case")]
314pub struct LogicalGroup {
315 pub name: String,
317 pub children: Vec<String>,
321 pub auto_discover: Vec<String>,
325 #[serde(skip_serializing_if = "Option::is_none")]
330 pub authored_rule: Option<AuthoredRule>,
331 #[serde(skip_serializing_if = "Option::is_none")]
338 pub fallback_zone: Option<String>,
339 pub source_zone_index: usize,
343 pub status: LogicalGroupStatus,
345 #[serde(skip_serializing_if = "Option::is_none")]
355 pub merged_from: Option<Vec<usize>>,
356 #[serde(skip_serializing_if = "Option::is_none")]
363 pub original_zone_root: Option<String>,
364 #[serde(default, skip_serializing_if = "Vec::is_empty")]
378 pub child_source_indices: Vec<usize>,
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
386#[serde(rename_all = "snake_case")]
387pub enum LogicalGroupStatus {
388 Ok,
390 Empty,
393 InvalidPath,
398}
399
400#[derive(Debug, Clone, Serialize, JsonSchema)]
405pub struct AuthoredRule {
406 pub allow: Vec<String>,
408 #[serde(default, skip_serializing_if = "Vec::is_empty")]
413 pub allow_type_only: Vec<String>,
414}
415
416#[derive(Debug)]
418pub struct ResolvedZone {
419 pub name: String,
421 pub matchers: Vec<globset::GlobMatcher>,
425 pub root: Option<String>,
431}
432
433#[derive(Debug)]
435pub struct ResolvedBoundaryRule {
436 pub from_zone: String,
438 pub allowed_zones: Vec<String>,
440 pub allow_type_only_zones: Vec<String>,
443}
444
445impl BoundaryConfig {
446 #[must_use]
448 pub fn is_empty(&self) -> bool {
449 self.preset.is_none() && self.zones.is_empty()
450 }
451
452 pub fn expand(&mut self, source_root: &str) {
462 let Some(preset) = self.preset.take() else {
463 return;
464 };
465
466 let (preset_zones, preset_rules) = preset.default_config(source_root);
467
468 let user_zone_names: rustc_hash::FxHashSet<&str> =
470 self.zones.iter().map(|z| z.name.as_str()).collect();
471
472 let mut merged_zones: Vec<BoundaryZone> = preset_zones
474 .into_iter()
475 .filter(|pz| {
476 if user_zone_names.contains(pz.name.as_str()) {
477 tracing::info!(
478 "boundary preset: user zone '{}' replaces preset zone",
479 pz.name
480 );
481 false
482 } else {
483 true
484 }
485 })
486 .collect();
487 merged_zones.append(&mut self.zones);
489 self.zones = merged_zones;
490
491 let user_rule_sources: rustc_hash::FxHashSet<&str> =
493 self.rules.iter().map(|r| r.from.as_str()).collect();
494
495 let mut merged_rules: Vec<BoundaryRule> = preset_rules
496 .into_iter()
497 .filter(|pr| {
498 if user_rule_sources.contains(pr.from.as_str()) {
499 tracing::info!(
500 "boundary preset: user rule for '{}' replaces preset rule",
501 pr.from
502 );
503 false
504 } else {
505 true
506 }
507 })
508 .collect();
509 merged_rules.append(&mut self.rules);
510 self.rules = merged_rules;
511 }
512
513 pub fn expand_auto_discover(&mut self, project_root: &Path) -> Vec<LogicalGroup> {
543 if self.zones.iter().all(|zone| zone.auto_discover.is_empty()) {
544 return Vec::new();
545 }
546
547 let original_zones = std::mem::take(&mut self.zones);
548 let mut expanded_zones = Vec::new();
549 let mut group_expansions: rustc_hash::FxHashMap<String, Vec<String>> =
550 rustc_hash::FxHashMap::default();
551 let mut group_drafts: Vec<LogicalGroupDraft> = Vec::new();
555
556 for (source_zone_index, mut zone) in original_zones.into_iter().enumerate() {
557 if zone.auto_discover.is_empty() {
558 expanded_zones.push(zone);
559 continue;
560 }
561
562 let group_name = zone.name.clone();
563 let raw_auto_discover = zone.auto_discover.clone();
567 let original_zone_root = zone.root.clone();
568 let DiscoveryOutcome {
569 zones: discovered_zones,
570 source_indices: discovered_source_indices,
571 had_invalid_path,
572 } = discover_child_zones(project_root, &zone);
573 let discovered_count = discovered_zones.len();
574 let mut expanded_names: Vec<String> = discovered_zones
575 .iter()
576 .map(|child| child.name.clone())
577 .collect();
578 let child_names_only = expanded_names.clone();
579 for child_zone in discovered_zones {
580 merge_zone_by_name(&mut expanded_zones, child_zone);
581 }
582
583 let fallback_zone = if zone.patterns.is_empty() {
584 None
585 } else {
586 expanded_names.push(group_name.clone());
587 zone.auto_discover.clear();
588 merge_zone_by_name(&mut expanded_zones, zone);
589 Some(group_name.clone())
590 };
591
592 if !expanded_names.is_empty() {
593 group_expansions
594 .entry(group_name.clone())
595 .or_default()
596 .extend(expanded_names);
597 }
598
599 let status = if discovered_count > 0 {
600 LogicalGroupStatus::Ok
601 } else if had_invalid_path {
602 LogicalGroupStatus::InvalidPath
603 } else {
604 LogicalGroupStatus::Empty
605 };
606
607 if let Some(existing) = group_drafts.iter_mut().find(|d| d.name == group_name) {
619 tracing::warn!(
620 "boundary zone '{}' is declared multiple times with autoDiscover; merging discovered children",
621 group_name
622 );
623 let auto_discover_offset = existing.auto_discover.len();
624 existing.auto_discover.extend(raw_auto_discover);
625 let existing_children: rustc_hash::FxHashSet<String> =
626 existing.children.iter().cloned().collect();
627 for (idx, name) in child_names_only.iter().enumerate() {
628 if existing_children.contains(name) {
629 continue;
630 }
631 existing.children.push(name.clone());
632 existing
633 .child_source_indices
634 .push(discovered_source_indices[idx] + auto_discover_offset);
635 }
636 if existing.fallback_zone.is_none() {
637 existing.fallback_zone = fallback_zone;
638 }
639 existing.status = merge_status(existing.status, status);
640 let chain = existing
641 .merged_from
642 .get_or_insert_with(|| vec![existing.source_zone_index]);
643 chain.push(source_zone_index);
644 } else {
645 group_drafts.push(LogicalGroupDraft {
646 name: group_name,
647 children: child_names_only,
648 auto_discover: raw_auto_discover,
649 fallback_zone,
650 source_zone_index,
651 status,
652 merged_from: None,
653 original_zone_root,
654 child_source_indices: discovered_source_indices,
655 });
656 }
657 }
658
659 self.zones = expanded_zones;
660
661 let draft_names: rustc_hash::FxHashSet<&str> =
669 group_drafts.iter().map(|d| d.name.as_str()).collect();
670
671 let original_rules = std::mem::take(&mut self.rules);
675 let authored_rules: rustc_hash::FxHashMap<&str, AuthoredRule> = original_rules
676 .iter()
677 .filter(|rule| draft_names.contains(rule.from.as_str()))
678 .map(|rule| {
679 (
680 rule.from.as_str(),
681 AuthoredRule {
682 allow: rule.allow.clone(),
683 allow_type_only: rule.allow_type_only.clone(),
684 },
685 )
686 })
687 .collect();
688
689 let logical_groups: Vec<LogicalGroup> = group_drafts
690 .into_iter()
691 .map(|draft| {
692 let child_source_indices = if draft.auto_discover.len() > 1 {
698 draft.child_source_indices
699 } else {
700 Vec::new()
701 };
702 LogicalGroup {
703 authored_rule: authored_rules.get(draft.name.as_str()).cloned(),
704 name: draft.name,
705 children: draft.children,
706 auto_discover: draft.auto_discover,
707 fallback_zone: draft.fallback_zone,
708 source_zone_index: draft.source_zone_index,
709 status: draft.status,
710 merged_from: draft.merged_from,
711 original_zone_root: draft.original_zone_root,
712 child_source_indices,
713 }
714 })
715 .collect();
716
717 if group_expansions.is_empty() {
718 self.rules = original_rules;
724 return logical_groups;
725 }
726
727 self.rules = expand_rules_for_groups(original_rules, &group_expansions);
728 logical_groups
729 }
730}
731
732fn merge_zone_by_name(expanded_zones: &mut Vec<BoundaryZone>, zone: BoundaryZone) {
743 if let Some(existing) = expanded_zones.iter_mut().find(|z| z.name == zone.name) {
744 for pattern in zone.patterns {
745 if !existing.patterns.contains(&pattern) {
746 existing.patterns.push(pattern);
747 }
748 }
749 } else {
750 expanded_zones.push(zone);
751 }
752}
753
754fn expand_rules_for_groups(
768 original_rules: Vec<BoundaryRule>,
769 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
770) -> Vec<BoundaryRule> {
771 let mut generated_rules = Vec::new();
772 let mut explicit_rules = Vec::new();
773 for rule in original_rules {
774 let allow = expand_rule_allow(&rule.allow, group_expansions);
775 let allow_type_only = expand_rule_allow(&rule.allow_type_only, group_expansions);
776
777 if let Some(from_zones) = group_expansions.get(&rule.from) {
778 for from in from_zones {
779 let (allow, allow_type_only) = if from == &rule.from {
780 (
781 expand_parent_fallback_allow(&allow, from_zones, &rule.from),
782 allow_type_only.clone(),
783 )
784 } else {
785 (
786 expand_generated_child_allow(&rule.allow, group_expansions, &rule.from),
787 expand_generated_child_allow(
788 &rule.allow_type_only,
789 group_expansions,
790 &rule.from,
791 ),
792 )
793 };
794 let expanded_rule = BoundaryRule {
795 from: from.clone(),
796 allow,
797 allow_type_only,
798 };
799 if from == &rule.from {
800 explicit_rules.push(expanded_rule);
801 } else {
802 generated_rules.push(expanded_rule);
803 }
804 }
805 } else {
806 explicit_rules.push(BoundaryRule {
807 from: rule.from,
808 allow,
809 allow_type_only,
810 });
811 }
812 }
813
814 let mut expanded_rules = dedupe_rules_keep_last(generated_rules);
815 expanded_rules.extend(dedupe_rules_keep_last(explicit_rules));
816 dedupe_rules_keep_last(expanded_rules)
817}
818
819impl BoundaryConfig {
820 #[must_use]
822 pub fn preset_name(&self) -> Option<&str> {
823 self.preset.as_ref().map(|p| match p {
824 BoundaryPreset::Layered => "layered",
825 BoundaryPreset::Hexagonal => "hexagonal",
826 BoundaryPreset::FeatureSliced => "feature-sliced",
827 BoundaryPreset::Bulletproof => "bulletproof",
828 })
829 }
830
831 #[must_use]
837 pub fn validate_root_prefixes(&self) -> Vec<String> {
838 let mut errors = Vec::new();
839 for zone in &self.zones {
840 let Some(raw_root) = zone.root.as_deref() else {
841 continue;
842 };
843 let normalized = normalize_zone_root(raw_root);
844 if normalized.is_empty() {
849 continue;
850 }
851 for pattern in &zone.patterns {
852 let normalized_pattern = pattern.replace('\\', "/");
853 let stripped = normalized_pattern
854 .strip_prefix("./")
855 .unwrap_or(&normalized_pattern);
856 if stripped.starts_with(&normalized) {
857 errors.push(format!(
858 "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.",
859 zone.name, pattern, normalized
860 ));
861 }
862 }
863 }
864 errors
865 }
866
867 #[must_use]
875 pub fn validate_zone_references(&self) -> Vec<(usize, &str)> {
876 let zone_names: rustc_hash::FxHashSet<&str> =
877 self.zones.iter().map(|z| z.name.as_str()).collect();
878
879 let mut errors = Vec::new();
880 for (i, rule) in self.rules.iter().enumerate() {
881 if !zone_names.contains(rule.from.as_str()) {
882 errors.push((i, rule.from.as_str()));
883 }
884 for allowed in &rule.allow {
885 if !zone_names.contains(allowed.as_str()) {
886 errors.push((i, allowed.as_str()));
887 }
888 }
889 for allowed_type_only in &rule.allow_type_only {
890 if !zone_names.contains(allowed_type_only.as_str()) {
891 errors.push((i, allowed_type_only.as_str()));
892 }
893 }
894 }
895 errors
896 }
897
898 #[must_use]
901 pub fn resolve(&self) -> ResolvedBoundaryConfig {
902 let zones = self
903 .zones
904 .iter()
905 .map(|zone| {
906 let matchers = zone
907 .patterns
908 .iter()
909 .filter_map(|pattern| match Glob::new(pattern) {
910 Ok(glob) => Some(glob.compile_matcher()),
911 Err(e) => {
912 tracing::warn!(
913 "invalid boundary zone glob pattern '{}' in zone '{}': {e}",
914 pattern,
915 zone.name
916 );
917 None
918 }
919 })
920 .collect();
921 let root = zone.root.as_deref().map(normalize_zone_root);
922 ResolvedZone {
923 name: zone.name.clone(),
924 matchers,
925 root,
926 }
927 })
928 .collect();
929
930 let rules = self
931 .rules
932 .iter()
933 .map(|rule| ResolvedBoundaryRule {
934 from_zone: rule.from.clone(),
935 allowed_zones: rule.allow.clone(),
936 allow_type_only_zones: rule.allow_type_only.clone(),
937 })
938 .collect();
939
940 ResolvedBoundaryConfig {
941 zones,
942 rules,
943 logical_groups: Vec::new(),
949 }
950 }
951}
952
953fn normalize_zone_root(raw: &str) -> String {
958 let with_slashes = raw.replace('\\', "/");
959 let trimmed = with_slashes.trim_start_matches("./");
960 let no_dot = if trimmed == "." { "" } else { trimmed };
961 if no_dot.is_empty() {
962 String::new()
963 } else if no_dot.ends_with('/') {
964 no_dot.to_owned()
965 } else {
966 format!("{no_dot}/")
967 }
968}
969
970fn normalize_auto_discover_dir(raw: &str) -> Option<String> {
971 let with_slashes = raw.replace('\\', "/");
972 let trimmed = with_slashes.trim_start_matches("./").trim_end_matches('/');
973 if trimmed.starts_with('/') || trimmed.split('/').any(|part| part == "..") {
974 None
975 } else if trimmed == "." {
976 Some(String::new())
977 } else {
978 Some(trimmed.to_owned())
979 }
980}
981
982fn join_relative_path(prefix: &str, suffix: &str) -> String {
983 match (prefix.is_empty(), suffix.is_empty()) {
984 (true, true) => String::new(),
985 (true, false) => suffix.to_owned(),
986 (false, true) => prefix.trim_end_matches('/').to_owned(),
987 (false, false) => format!("{}/{}", prefix.trim_end_matches('/'), suffix),
988 }
989}
990
991struct DiscoveryOutcome {
998 zones: Vec<BoundaryZone>,
999 source_indices: Vec<usize>,
1000 had_invalid_path: bool,
1001}
1002
1003struct LogicalGroupDraft {
1007 name: String,
1008 children: Vec<String>,
1009 auto_discover: Vec<String>,
1010 fallback_zone: Option<String>,
1011 source_zone_index: usize,
1012 status: LogicalGroupStatus,
1013 merged_from: Option<Vec<usize>>,
1017 original_zone_root: Option<String>,
1021 child_source_indices: Vec<usize>,
1027}
1028
1029const fn merge_status(existing: LogicalGroupStatus, new: LogicalGroupStatus) -> LogicalGroupStatus {
1034 match (existing, new) {
1035 (LogicalGroupStatus::Ok, _) | (_, LogicalGroupStatus::Ok) => LogicalGroupStatus::Ok,
1036 (LogicalGroupStatus::InvalidPath, _) | (_, LogicalGroupStatus::InvalidPath) => {
1037 LogicalGroupStatus::InvalidPath
1038 }
1039 (LogicalGroupStatus::Empty, LogicalGroupStatus::Empty) => LogicalGroupStatus::Empty,
1040 }
1041}
1042
1043fn discover_child_zones(project_root: &Path, zone: &BoundaryZone) -> DiscoveryOutcome {
1044 let mut zones_by_name: rustc_hash::FxHashMap<String, BoundaryZone> =
1045 rustc_hash::FxHashMap::default();
1046 let mut first_source_index: rustc_hash::FxHashMap<String, usize> =
1051 rustc_hash::FxHashMap::default();
1052 let normalized_root = zone
1053 .root
1054 .as_deref()
1055 .map(normalize_zone_root)
1056 .unwrap_or_default();
1057 let mut had_invalid_path = false;
1058
1059 for (source_index, raw_dir) in zone.auto_discover.iter().enumerate() {
1060 let Some(discover_dir) = normalize_auto_discover_dir(raw_dir) else {
1061 tracing::warn!(
1062 "invalid boundary autoDiscover path '{}' in zone '{}': paths must be project-relative and must not contain '..'",
1063 raw_dir,
1064 zone.name
1065 );
1066 had_invalid_path = true;
1067 continue;
1068 };
1069
1070 let fs_relative = join_relative_path(&normalized_root, &discover_dir);
1071 let absolute_dir = if fs_relative.is_empty() {
1072 project_root.to_path_buf()
1073 } else {
1074 project_root.join(&fs_relative)
1075 };
1076 let Ok(entries) = std::fs::read_dir(&absolute_dir) else {
1077 tracing::warn!(
1078 "boundary zone '{}' autoDiscover path '{}' did not resolve to a readable directory",
1079 zone.name,
1080 raw_dir
1081 );
1082 had_invalid_path = true;
1083 continue;
1084 };
1085
1086 let mut children: Vec<_> = entries
1087 .filter_map(Result::ok)
1088 .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_dir()))
1089 .collect();
1090 children.sort_by_key(|entry| entry.file_name());
1091
1092 for child in children {
1093 let child_name = child.file_name().to_string_lossy().to_string();
1094 if child_name.is_empty() {
1095 continue;
1096 }
1097
1098 let zone_name = format!("{}/{}", zone.name, child_name);
1099 let child_pattern = format!("{}/**", join_relative_path(&discover_dir, &child_name));
1100 let entry = zones_by_name
1101 .entry(zone_name.clone())
1102 .or_insert_with(|| BoundaryZone {
1103 name: zone_name.clone(),
1104 patterns: vec![],
1105 auto_discover: vec![],
1106 root: zone.root.clone(),
1107 });
1108 if !entry
1109 .patterns
1110 .iter()
1111 .any(|pattern| pattern == &child_pattern)
1112 {
1113 entry.patterns.push(child_pattern);
1114 }
1115 first_source_index.entry(zone_name).or_insert(source_index);
1116 }
1117 }
1118
1119 let mut zones: Vec<_> = zones_by_name.into_values().collect();
1120 zones.sort_by(|a, b| a.name.cmp(&b.name));
1121 let source_indices: Vec<usize> = zones
1122 .iter()
1123 .map(|z| {
1124 first_source_index
1129 .get(z.name.as_str())
1130 .copied()
1131 .unwrap_or(0)
1132 })
1133 .collect();
1134 DiscoveryOutcome {
1135 zones,
1136 source_indices,
1137 had_invalid_path,
1138 }
1139}
1140
1141fn expand_rule_allow(
1142 allow: &[String],
1143 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
1144) -> Vec<String> {
1145 let mut expanded = Vec::new();
1146 for zone in allow {
1147 if let Some(expansion) = group_expansions.get(zone) {
1148 expanded.extend(expansion.iter().cloned());
1149 } else {
1150 expanded.push(zone.clone());
1151 }
1152 }
1153 dedupe_preserving_order(expanded)
1154}
1155
1156fn expand_parent_fallback_allow(
1157 allow: &[String],
1158 from_zones: &[String],
1159 parent_name: &str,
1160) -> Vec<String> {
1161 let mut expanded = allow.to_vec();
1162 expanded.extend(
1163 from_zones
1164 .iter()
1165 .filter(|from_zone| from_zone.as_str() != parent_name)
1166 .cloned(),
1167 );
1168 dedupe_preserving_order(expanded)
1169}
1170
1171fn expand_generated_child_allow(
1172 allow: &[String],
1173 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
1174 source_group: &str,
1175) -> Vec<String> {
1176 let mut expanded = Vec::new();
1177 for zone in allow {
1178 if zone == source_group {
1179 if group_expansions
1180 .get(source_group)
1181 .is_some_and(|from_zones| from_zones.iter().any(|from_zone| from_zone == zone))
1182 {
1183 expanded.push(zone.clone());
1184 }
1185 } else if let Some(expansion) = group_expansions.get(zone) {
1186 expanded.extend(expansion.iter().cloned());
1187 } else {
1188 expanded.push(zone.clone());
1189 }
1190 }
1191 dedupe_preserving_order(expanded)
1192}
1193
1194fn dedupe_preserving_order(values: Vec<String>) -> Vec<String> {
1195 let mut seen = rustc_hash::FxHashSet::default();
1196 values
1197 .into_iter()
1198 .filter(|value| seen.insert(value.clone()))
1199 .collect()
1200}
1201
1202fn dedupe_rules_keep_last(rules: Vec<BoundaryRule>) -> Vec<BoundaryRule> {
1203 let mut seen = rustc_hash::FxHashSet::default();
1204 let mut deduped: Vec<_> = rules
1205 .into_iter()
1206 .rev()
1207 .filter(|rule| seen.insert(rule.from.clone()))
1208 .collect();
1209 deduped.reverse();
1210 deduped
1211}
1212
1213impl ResolvedBoundaryConfig {
1214 #[must_use]
1223 pub fn is_empty(&self) -> bool {
1224 self.zones.is_empty() && self.logical_groups.is_empty()
1225 }
1226
1227 #[must_use]
1235 pub fn classify_zone(&self, relative_path: &str) -> Option<&str> {
1236 for zone in &self.zones {
1237 let candidate: &str = match zone.root.as_deref() {
1238 Some(root) if !root.is_empty() => {
1239 let Some(stripped) = relative_path.strip_prefix(root) else {
1240 continue;
1241 };
1242 stripped
1243 }
1244 _ => relative_path,
1245 };
1246 if zone.matchers.iter().any(|m| m.is_match(candidate)) {
1247 return Some(&zone.name);
1248 }
1249 }
1250 None
1251 }
1252
1253 #[must_use]
1256 pub fn is_import_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
1257 if from_zone == to_zone {
1259 return true;
1260 }
1261
1262 let rule = self.rules.iter().find(|r| r.from_zone == from_zone);
1264
1265 match rule {
1266 None => true,
1268 Some(r) => r.allowed_zones.iter().any(|z| z == to_zone),
1270 }
1271 }
1272
1273 #[must_use]
1282 pub fn is_type_only_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
1283 let Some(rule) = self.rules.iter().find(|r| r.from_zone == from_zone) else {
1284 return false;
1285 };
1286 rule.allow_type_only_zones.iter().any(|z| z == to_zone)
1287 }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292 use super::*;
1293
1294 #[test]
1295 fn empty_config() {
1296 let config = BoundaryConfig::default();
1297 assert!(config.is_empty());
1298 assert!(config.validate_zone_references().is_empty());
1299 }
1300
1301 #[test]
1302 fn deserialize_json() {
1303 let json = r#"{
1304 "zones": [
1305 { "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
1306 { "name": "db", "patterns": ["src/db/**"] },
1307 { "name": "shared", "patterns": ["src/shared/**"] }
1308 ],
1309 "rules": [
1310 { "from": "ui", "allow": ["shared"] },
1311 { "from": "db", "allow": ["shared"] }
1312 ]
1313 }"#;
1314 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1315 assert_eq!(config.zones.len(), 3);
1316 assert_eq!(config.rules.len(), 2);
1317 assert_eq!(config.zones[0].name, "ui");
1318 assert_eq!(
1319 config.zones[0].patterns,
1320 vec!["src/components/**", "src/pages/**"]
1321 );
1322 assert_eq!(config.rules[0].from, "ui");
1323 assert_eq!(config.rules[0].allow, vec!["shared"]);
1324 }
1325
1326 #[test]
1327 fn deserialize_toml() {
1328 let toml_str = r#"
1329[[zones]]
1330name = "ui"
1331patterns = ["src/components/**"]
1332
1333[[zones]]
1334name = "db"
1335patterns = ["src/db/**"]
1336
1337[[rules]]
1338from = "ui"
1339allow = ["db"]
1340"#;
1341 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
1342 assert_eq!(config.zones.len(), 2);
1343 assert_eq!(config.rules.len(), 1);
1344 }
1345
1346 #[test]
1347 fn auto_discover_expands_child_zones_and_parent_rules() {
1348 let temp = tempfile::tempdir().unwrap();
1349 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1350 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1351
1352 let mut config = BoundaryConfig {
1353 preset: None,
1354 zones: vec![
1355 BoundaryZone {
1356 name: "app".to_string(),
1357 patterns: vec!["src/app/**".to_string()],
1358 auto_discover: vec![],
1359 root: None,
1360 },
1361 BoundaryZone {
1362 name: "features".to_string(),
1363 patterns: vec![],
1364 auto_discover: vec!["src/features".to_string()],
1365 root: None,
1366 },
1367 ],
1368 rules: vec![
1369 BoundaryRule {
1370 from: "app".to_string(),
1371 allow: vec!["features".to_string()],
1372 allow_type_only: vec![],
1373 },
1374 BoundaryRule {
1375 from: "features".to_string(),
1376 allow: vec![],
1377 allow_type_only: vec![],
1378 },
1379 ],
1380 };
1381
1382 config.expand_auto_discover(temp.path());
1383
1384 let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
1385 assert_eq!(zone_names, vec!["app", "features/auth", "features/billing"]);
1386 assert_eq!(
1387 config.zones[1].patterns,
1388 vec!["src/features/auth/**".to_string()]
1389 );
1390 assert_eq!(
1391 config.zones[2].patterns,
1392 vec!["src/features/billing/**".to_string()]
1393 );
1394 let app_rule = config
1395 .rules
1396 .iter()
1397 .find(|rule| rule.from == "app")
1398 .expect("app rule should be preserved");
1399 assert_eq!(
1400 app_rule.allow,
1401 vec!["features/auth".to_string(), "features/billing".to_string()]
1402 );
1403 assert!(
1404 config
1405 .rules
1406 .iter()
1407 .any(|rule| rule.from == "features/auth" && rule.allow.is_empty())
1408 );
1409 assert!(
1410 config
1411 .rules
1412 .iter()
1413 .any(|rule| rule.from == "features/billing" && rule.allow.is_empty())
1414 );
1415 assert!(config.validate_zone_references().is_empty());
1416 }
1417
1418 #[test]
1419 fn auto_discover_parent_fallback_allows_children_without_relaxing_child_rules() {
1420 let temp = tempfile::tempdir().unwrap();
1421 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1422 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1423
1424 let mut config = BoundaryConfig {
1425 preset: None,
1426 zones: vec![
1427 BoundaryZone {
1428 name: "app".to_string(),
1429 patterns: vec!["src/app/**".to_string()],
1430 auto_discover: vec![],
1431 root: None,
1432 },
1433 BoundaryZone {
1434 name: "features".to_string(),
1435 patterns: vec!["src/features/**".to_string()],
1436 auto_discover: vec!["src/features".to_string()],
1437 root: None,
1438 },
1439 BoundaryZone {
1440 name: "shared".to_string(),
1441 patterns: vec!["src/shared/**".to_string()],
1442 auto_discover: vec![],
1443 root: None,
1444 },
1445 ],
1446 rules: vec![
1447 BoundaryRule {
1448 from: "app".to_string(),
1449 allow: vec!["features".to_string(), "shared".to_string()],
1450 allow_type_only: vec![],
1451 },
1452 BoundaryRule {
1453 from: "features".to_string(),
1454 allow: vec!["shared".to_string()],
1455 allow_type_only: vec![],
1456 },
1457 ],
1458 };
1459
1460 config.expand_auto_discover(temp.path());
1461
1462 let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
1463 assert_eq!(
1464 zone_names,
1465 vec![
1466 "app",
1467 "features/auth",
1468 "features/billing",
1469 "features",
1470 "shared"
1471 ]
1472 );
1473
1474 let app_rule = config
1475 .rules
1476 .iter()
1477 .find(|rule| rule.from == "app")
1478 .expect("app rule should be preserved");
1479 assert_eq!(
1480 app_rule.allow,
1481 vec![
1482 "features/auth".to_string(),
1483 "features/billing".to_string(),
1484 "features".to_string(),
1485 "shared".to_string()
1486 ]
1487 );
1488
1489 let parent_rule = config
1490 .rules
1491 .iter()
1492 .find(|rule| rule.from == "features")
1493 .expect("parent fallback rule should be preserved");
1494 assert_eq!(
1495 parent_rule.allow,
1496 vec![
1497 "shared".to_string(),
1498 "features/auth".to_string(),
1499 "features/billing".to_string()
1500 ]
1501 );
1502
1503 let auth_rule = config
1504 .rules
1505 .iter()
1506 .find(|rule| rule.from == "features/auth")
1507 .expect("auth child rule should be generated");
1508 assert_eq!(auth_rule.allow, vec!["shared".to_string()]);
1509
1510 let billing_rule = config
1511 .rules
1512 .iter()
1513 .find(|rule| rule.from == "features/billing")
1514 .expect("billing child rule should be generated");
1515 assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1516 assert!(config.validate_zone_references().is_empty());
1517 }
1518
1519 #[test]
1520 fn auto_discover_explicit_child_rule_wins_over_generated_parent_rule() {
1521 let temp = tempfile::tempdir().unwrap();
1522 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1523 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1524
1525 for explicit_child_first in [true, false] {
1526 let explicit_child_rule = BoundaryRule {
1527 from: "features/auth".to_string(),
1528 allow: vec!["shared".to_string(), "features/billing".to_string()],
1529 allow_type_only: vec![],
1530 };
1531 let parent_rule = BoundaryRule {
1532 from: "features".to_string(),
1533 allow: vec!["shared".to_string()],
1534 allow_type_only: vec![],
1535 };
1536 let rules = if explicit_child_first {
1537 vec![explicit_child_rule, parent_rule]
1538 } else {
1539 vec![parent_rule, explicit_child_rule]
1540 };
1541
1542 let mut config = BoundaryConfig {
1543 preset: None,
1544 zones: vec![
1545 BoundaryZone {
1546 name: "features".to_string(),
1547 patterns: vec![],
1548 auto_discover: vec!["src/features".to_string()],
1549 root: None,
1550 },
1551 BoundaryZone {
1552 name: "shared".to_string(),
1553 patterns: vec!["src/shared/**".to_string()],
1554 auto_discover: vec![],
1555 root: None,
1556 },
1557 ],
1558 rules,
1559 };
1560
1561 config.expand_auto_discover(temp.path());
1562
1563 let auth_rule = config
1564 .rules
1565 .iter()
1566 .find(|rule| rule.from == "features/auth")
1567 .expect("explicit child rule should remain");
1568 assert_eq!(
1569 auth_rule.allow,
1570 vec!["shared".to_string(), "features/billing".to_string()],
1571 "explicit child rule should win regardless of rule order"
1572 );
1573
1574 let billing_rule = config
1575 .rules
1576 .iter()
1577 .find(|rule| rule.from == "features/billing")
1578 .expect("parent rule should still generate sibling child rule");
1579 assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1580 assert!(config.validate_zone_references().is_empty());
1581 }
1582 }
1583
1584 #[test]
1587 fn logical_groups_returned_for_simple_auto_discover_zone() {
1588 let temp = tempfile::tempdir().unwrap();
1589 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1590 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1591
1592 let mut config = BoundaryConfig {
1593 preset: None,
1594 zones: vec![
1595 BoundaryZone {
1596 name: "app".to_string(),
1597 patterns: vec!["src/app/**".to_string()],
1598 auto_discover: vec![],
1599 root: None,
1600 },
1601 BoundaryZone {
1602 name: "features".to_string(),
1603 patterns: vec![],
1604 auto_discover: vec!["src/features".to_string()],
1605 root: None,
1606 },
1607 ],
1608 rules: vec![BoundaryRule {
1609 from: "features".to_string(),
1610 allow: vec!["app".to_string()],
1611 allow_type_only: vec![],
1612 }],
1613 };
1614
1615 let groups = config.expand_auto_discover(temp.path());
1616 assert_eq!(groups.len(), 1);
1617 let g = &groups[0];
1618 assert_eq!(g.name, "features");
1619 assert_eq!(g.children, vec!["features/auth", "features/billing"]);
1620 assert_eq!(g.auto_discover, vec!["src/features"]);
1621 assert_eq!(g.source_zone_index, 1);
1622 assert_eq!(g.status, LogicalGroupStatus::Ok);
1623 assert!(g.fallback_zone.is_none());
1625 let rule = g
1626 .authored_rule
1627 .as_ref()
1628 .expect("authored rule preserved verbatim");
1629 assert_eq!(rule.allow, vec!["app"]);
1630 assert!(rule.allow_type_only.is_empty());
1631 }
1632
1633 #[test]
1634 fn logical_groups_preserve_verbatim_auto_discover_strings() {
1635 let temp = tempfile::tempdir().unwrap();
1636 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1637
1638 let mut config = BoundaryConfig {
1639 preset: None,
1640 zones: vec![BoundaryZone {
1641 name: "features".to_string(),
1642 patterns: vec![],
1643 auto_discover: vec!["./src/features/".to_string()],
1647 root: None,
1648 }],
1649 rules: vec![],
1650 };
1651
1652 let groups = config.expand_auto_discover(temp.path());
1653 assert_eq!(groups.len(), 1);
1654 assert_eq!(groups[0].auto_discover, vec!["./src/features/"]);
1655 assert_eq!(groups[0].children, vec!["features/auth"]);
1656 }
1657
1658 #[test]
1659 fn logical_groups_bulletproof_keeps_fallback_zone_cross_reference() {
1660 let temp = tempfile::tempdir().unwrap();
1661 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1662
1663 let mut config = BoundaryConfig {
1664 preset: None,
1665 zones: vec![BoundaryZone {
1666 name: "features".to_string(),
1670 patterns: vec!["src/features/**".to_string()],
1671 auto_discover: vec!["src/features".to_string()],
1672 root: None,
1673 }],
1674 rules: vec![],
1675 };
1676
1677 let groups = config.expand_auto_discover(temp.path());
1678 assert_eq!(groups.len(), 1);
1679 assert_eq!(groups[0].fallback_zone.as_deref(), Some("features"));
1680 assert!(config.zones.iter().any(|z| z.name == "features"));
1682 }
1683
1684 #[test]
1685 fn logical_groups_status_empty_when_no_child_dirs() {
1686 let temp = tempfile::tempdir().unwrap();
1687 std::fs::create_dir_all(temp.path().join("src/features")).unwrap();
1688 let mut config = BoundaryConfig {
1691 preset: None,
1692 zones: vec![BoundaryZone {
1693 name: "features".to_string(),
1694 patterns: vec![],
1695 auto_discover: vec!["src/features".to_string()],
1696 root: None,
1697 }],
1698 rules: vec![],
1699 };
1700
1701 let groups = config.expand_auto_discover(temp.path());
1702 assert_eq!(groups.len(), 1);
1703 assert_eq!(groups[0].status, LogicalGroupStatus::Empty);
1704 assert!(groups[0].children.is_empty());
1705 }
1706
1707 #[test]
1708 fn logical_groups_status_invalid_path_when_dir_missing() {
1709 let temp = tempfile::tempdir().unwrap();
1710 let mut config = BoundaryConfig {
1713 preset: None,
1714 zones: vec![BoundaryZone {
1715 name: "features".to_string(),
1716 patterns: vec![],
1717 auto_discover: vec!["src/features".to_string()],
1718 root: None,
1719 }],
1720 rules: vec![],
1721 };
1722
1723 let groups = config.expand_auto_discover(temp.path());
1724 assert_eq!(groups.len(), 1);
1725 assert_eq!(groups[0].status, LogicalGroupStatus::InvalidPath);
1726 assert!(groups[0].children.is_empty());
1727 }
1728
1729 #[test]
1730 fn logical_groups_status_ok_wins_over_invalid_when_mixed() {
1731 let temp = tempfile::tempdir().unwrap();
1732 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1733 let mut config = BoundaryConfig {
1736 preset: None,
1737 zones: vec![BoundaryZone {
1738 name: "features".to_string(),
1739 patterns: vec![],
1740 auto_discover: vec!["src/features".to_string(), "src/modules".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].status, LogicalGroupStatus::Ok);
1751 assert_eq!(groups[0].children, vec!["features/auth"]);
1752 }
1753
1754 #[test]
1755 fn logical_groups_preserve_declaration_order() {
1756 let temp = tempfile::tempdir().unwrap();
1757 std::fs::create_dir_all(temp.path().join("src/zeta/a")).unwrap();
1758 std::fs::create_dir_all(temp.path().join("src/alpha/a")).unwrap();
1759 std::fs::create_dir_all(temp.path().join("src/mid/a")).unwrap();
1760
1761 let mut config = BoundaryConfig {
1762 preset: None,
1763 zones: vec![
1764 BoundaryZone {
1765 name: "zeta".to_string(),
1766 patterns: vec![],
1767 auto_discover: vec!["src/zeta".to_string()],
1768 root: None,
1769 },
1770 BoundaryZone {
1771 name: "alpha".to_string(),
1772 patterns: vec![],
1773 auto_discover: vec!["src/alpha".to_string()],
1774 root: None,
1775 },
1776 BoundaryZone {
1777 name: "mid".to_string(),
1778 patterns: vec![],
1779 auto_discover: vec!["src/mid".to_string()],
1780 root: None,
1781 },
1782 ],
1783 rules: vec![],
1784 };
1785
1786 let groups = config.expand_auto_discover(temp.path());
1787 let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
1789 assert_eq!(names, vec!["zeta", "alpha", "mid"]);
1790 }
1791
1792 #[test]
1793 fn logical_groups_merged_from_records_duplicate_indices() {
1794 let temp = tempfile::tempdir().unwrap();
1797 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1798 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
1799
1800 let mut config = BoundaryConfig {
1801 preset: None,
1802 zones: vec![
1803 BoundaryZone {
1804 name: "features".to_string(),
1805 patterns: vec![],
1806 auto_discover: vec!["src/features".to_string()],
1807 root: None,
1808 },
1809 BoundaryZone {
1810 name: "other".to_string(),
1811 patterns: vec!["src/other/**".to_string()],
1812 auto_discover: vec![],
1813 root: None,
1814 },
1815 BoundaryZone {
1816 name: "features".to_string(),
1817 patterns: vec![],
1818 auto_discover: vec!["src/extra".to_string()],
1819 root: None,
1820 },
1821 ],
1822 rules: vec![],
1823 };
1824 let groups = config.expand_auto_discover(temp.path());
1825 assert_eq!(groups.len(), 1);
1826 assert_eq!(groups[0].merged_from.as_deref(), Some(&[0_usize, 2][..]));
1830 assert_eq!(groups[0].source_zone_index, 0);
1832 }
1833
1834 #[test]
1835 fn logical_groups_merged_from_none_on_single_declaration() {
1836 let temp = tempfile::tempdir().unwrap();
1837 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1838
1839 let mut config = BoundaryConfig {
1840 preset: None,
1841 zones: vec![BoundaryZone {
1842 name: "features".to_string(),
1843 patterns: vec![],
1844 auto_discover: vec!["src/features".to_string()],
1845 root: None,
1846 }],
1847 rules: vec![],
1848 };
1849 let groups = config.expand_auto_discover(temp.path());
1850 assert!(groups[0].merged_from.is_none());
1852 }
1853
1854 #[test]
1855 fn logical_groups_echo_original_zone_root() {
1856 let temp = tempfile::tempdir().unwrap();
1857 std::fs::create_dir_all(temp.path().join("packages/app/src/features/auth")).unwrap();
1858
1859 let mut config = BoundaryConfig {
1860 preset: None,
1861 zones: vec![BoundaryZone {
1862 name: "features".to_string(),
1863 patterns: vec![],
1864 auto_discover: vec!["src/features".to_string()],
1865 root: Some("packages/app/".to_string()),
1869 }],
1870 rules: vec![],
1871 };
1872 let groups = config.expand_auto_discover(temp.path());
1873 assert_eq!(
1874 groups[0].original_zone_root.as_deref(),
1875 Some("packages/app/")
1876 );
1877 }
1878
1879 #[test]
1880 fn logical_groups_original_zone_root_none_when_unset() {
1881 let temp = tempfile::tempdir().unwrap();
1882 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1883
1884 let mut config = BoundaryConfig {
1885 preset: None,
1886 zones: vec![BoundaryZone {
1887 name: "features".to_string(),
1888 patterns: vec![],
1889 auto_discover: vec!["src/features".to_string()],
1890 root: None,
1891 }],
1892 rules: vec![],
1893 };
1894 let groups = config.expand_auto_discover(temp.path());
1895 assert!(groups[0].original_zone_root.is_none());
1896 }
1897
1898 #[test]
1899 fn logical_groups_child_source_indices_populated_for_multi_path() {
1900 let temp = tempfile::tempdir().unwrap();
1901 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1902 std::fs::create_dir_all(temp.path().join("src/modules/billing")).unwrap();
1903
1904 let mut config = BoundaryConfig {
1905 preset: None,
1906 zones: vec![BoundaryZone {
1907 name: "features".to_string(),
1908 patterns: vec![],
1909 auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
1913 root: None,
1914 }],
1915 rules: vec![],
1916 };
1917 let groups = config.expand_auto_discover(temp.path());
1918 assert_eq!(
1919 groups[0].children,
1920 vec!["features/auth", "features/billing"]
1921 );
1922 assert_eq!(groups[0].child_source_indices, vec![0, 1]);
1923 }
1924
1925 #[test]
1926 fn logical_groups_child_source_indices_empty_for_single_path() {
1927 let temp = tempfile::tempdir().unwrap();
1928 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1929 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1930
1931 let mut config = BoundaryConfig {
1932 preset: None,
1933 zones: vec![BoundaryZone {
1934 name: "features".to_string(),
1935 patterns: vec![],
1936 auto_discover: vec!["src/features".to_string()],
1937 root: None,
1938 }],
1939 rules: vec![],
1940 };
1941 let groups = config.expand_auto_discover(temp.path());
1942 assert!(groups[0].child_source_indices.is_empty());
1946 }
1947
1948 #[test]
1949 fn logical_groups_child_source_indices_after_duplicate_merge_shifted() {
1950 let temp = tempfile::tempdir().unwrap();
1955 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1956 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
1957
1958 let mut config = BoundaryConfig {
1959 preset: None,
1960 zones: vec![
1961 BoundaryZone {
1962 name: "features".to_string(),
1963 patterns: vec![],
1964 auto_discover: vec!["src/features".to_string()],
1965 root: None,
1966 },
1967 BoundaryZone {
1968 name: "features".to_string(),
1969 patterns: vec![],
1970 auto_discover: vec!["src/extra".to_string()],
1971 root: None,
1972 },
1973 ],
1974 rules: vec![],
1975 };
1976 let groups = config.expand_auto_discover(temp.path());
1977 assert_eq!(groups.len(), 1);
1978 assert_eq!(groups[0].auto_discover, vec!["src/features", "src/extra"]);
1982 let auth_idx = groups[0]
1983 .children
1984 .iter()
1985 .position(|c| c == "features/auth")
1986 .unwrap();
1987 let billing_idx = groups[0]
1988 .children
1989 .iter()
1990 .position(|c| c == "features/billing")
1991 .unwrap();
1992 assert_eq!(groups[0].child_source_indices[auth_idx], 0);
1993 assert_eq!(groups[0].child_source_indices[billing_idx], 1);
1994 }
1995
1996 #[test]
1997 fn logical_groups_merge_duplicate_parent_zone_declarations() {
1998 let temp = tempfile::tempdir().unwrap();
1999 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2000 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
2001
2002 let mut config = BoundaryConfig {
2003 preset: None,
2004 zones: vec![
2005 BoundaryZone {
2006 name: "features".to_string(),
2007 patterns: vec![],
2008 auto_discover: vec!["src/features".to_string()],
2009 root: None,
2010 },
2011 BoundaryZone {
2012 name: "features".to_string(),
2013 patterns: vec![],
2014 auto_discover: vec!["src/extra".to_string()],
2015 root: None,
2016 },
2017 ],
2018 rules: vec![],
2019 };
2020
2021 let groups = config.expand_auto_discover(temp.path());
2022 assert_eq!(groups.len(), 1);
2025 assert_eq!(groups[0].name, "features");
2026 assert_eq!(groups[0].auto_discover, vec!["src/features", "src/extra"]);
2027 assert!(groups[0].children.iter().any(|c| c == "features/auth"));
2028 assert!(groups[0].children.iter().any(|c| c == "features/billing"));
2029 assert_eq!(groups[0].source_zone_index, 0);
2030 }
2031
2032 #[test]
2033 fn logical_groups_duplicate_identical_declarations_no_double_count() {
2034 let temp = tempfile::tempdir().unwrap();
2041 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2042 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
2043
2044 let mut config = BoundaryConfig {
2045 preset: None,
2046 zones: vec![
2047 BoundaryZone {
2048 name: "features".to_string(),
2049 patterns: vec![],
2050 auto_discover: vec!["src/features".to_string()],
2051 root: None,
2052 },
2053 BoundaryZone {
2054 name: "features".to_string(),
2055 patterns: vec![],
2056 auto_discover: vec!["src/features".to_string()],
2057 root: None,
2058 },
2059 ],
2060 rules: vec![],
2061 };
2062
2063 let groups = config.expand_auto_discover(temp.path());
2064 assert_eq!(groups.len(), 1);
2065 let zone_names: Vec<&str> = config.zones.iter().map(|z| z.name.as_str()).collect();
2068 assert_eq!(zone_names, vec!["features/auth", "features/billing"]);
2069 assert_eq!(
2071 groups[0].children,
2072 vec!["features/auth", "features/billing"]
2073 );
2074 assert_eq!(
2078 groups[0].auto_discover,
2079 vec!["src/features", "src/features"]
2080 );
2081 assert_eq!(groups[0].merged_from.as_deref(), Some(&[0_usize, 1][..]));
2083 }
2084
2085 #[test]
2086 fn logical_groups_empty_when_no_auto_discover_present() {
2087 let temp = tempfile::tempdir().unwrap();
2088 let mut config = BoundaryConfig {
2089 preset: None,
2090 zones: vec![BoundaryZone {
2091 name: "ui".to_string(),
2092 patterns: vec!["src/components/**".to_string()],
2093 auto_discover: vec![],
2094 root: None,
2095 }],
2096 rules: vec![],
2097 };
2098 let groups = config.expand_auto_discover(temp.path());
2099 assert!(groups.is_empty());
2100 }
2101
2102 #[test]
2103 fn logical_groups_propagate_through_resolve() {
2104 let temp = tempfile::tempdir().unwrap();
2108 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2109
2110 let mut config = BoundaryConfig {
2111 preset: None,
2112 zones: vec![BoundaryZone {
2113 name: "features".to_string(),
2114 patterns: vec![],
2115 auto_discover: vec!["src/features".to_string()],
2116 root: None,
2117 }],
2118 rules: vec![],
2119 };
2120 let groups = config.expand_auto_discover(temp.path());
2121 let mut resolved = config.resolve();
2122 resolved.logical_groups = groups;
2126 assert_eq!(resolved.logical_groups.len(), 1);
2127 assert_eq!(resolved.logical_groups[0].name, "features");
2128 assert_eq!(resolved.logical_groups[0].children, vec!["features/auth"]);
2129 }
2130
2131 #[test]
2132 fn validate_zone_references_valid() {
2133 let config = BoundaryConfig {
2134 preset: None,
2135 zones: vec![
2136 BoundaryZone {
2137 name: "ui".to_string(),
2138 patterns: vec![],
2139 auto_discover: vec![],
2140 root: None,
2141 },
2142 BoundaryZone {
2143 name: "db".to_string(),
2144 patterns: vec![],
2145 auto_discover: vec![],
2146 root: None,
2147 },
2148 ],
2149 rules: vec![BoundaryRule {
2150 from: "ui".to_string(),
2151 allow: vec!["db".to_string()],
2152 allow_type_only: vec![],
2153 }],
2154 };
2155 assert!(config.validate_zone_references().is_empty());
2156 }
2157
2158 #[test]
2159 fn validate_zone_references_invalid_from() {
2160 let config = BoundaryConfig {
2161 preset: None,
2162 zones: vec![BoundaryZone {
2163 name: "ui".to_string(),
2164 patterns: vec![],
2165 auto_discover: vec![],
2166 root: None,
2167 }],
2168 rules: vec![BoundaryRule {
2169 from: "nonexistent".to_string(),
2170 allow: vec!["ui".to_string()],
2171 allow_type_only: vec![],
2172 }],
2173 };
2174 let errors = config.validate_zone_references();
2175 assert_eq!(errors.len(), 1);
2176 assert_eq!(errors[0].1, "nonexistent");
2177 }
2178
2179 #[test]
2180 fn validate_zone_references_invalid_allow() {
2181 let config = BoundaryConfig {
2182 preset: None,
2183 zones: vec![BoundaryZone {
2184 name: "ui".to_string(),
2185 patterns: vec![],
2186 auto_discover: vec![],
2187 root: None,
2188 }],
2189 rules: vec![BoundaryRule {
2190 from: "ui".to_string(),
2191 allow: vec!["nonexistent".to_string()],
2192 allow_type_only: vec![],
2193 }],
2194 };
2195 let errors = config.validate_zone_references();
2196 assert_eq!(errors.len(), 1);
2197 assert_eq!(errors[0].1, "nonexistent");
2198 }
2199
2200 #[test]
2201 fn validate_zone_references_invalid_allow_type_only() {
2202 let config = BoundaryConfig {
2206 preset: None,
2207 zones: vec![BoundaryZone {
2208 name: "ui".to_string(),
2209 patterns: vec![],
2210 auto_discover: vec![],
2211 root: None,
2212 }],
2213 rules: vec![BoundaryRule {
2214 from: "ui".to_string(),
2215 allow: vec![],
2216 allow_type_only: vec!["nonexistent_type_zone".to_string()],
2217 }],
2218 };
2219 let errors = config.validate_zone_references();
2220 assert_eq!(errors.len(), 1, "got: {errors:?}");
2221 assert_eq!(errors[0].1, "nonexistent_type_zone");
2222 }
2223
2224 #[test]
2225 fn resolve_and_classify() {
2226 let config = BoundaryConfig {
2227 preset: None,
2228 zones: vec![
2229 BoundaryZone {
2230 name: "ui".to_string(),
2231 patterns: vec!["src/components/**".to_string()],
2232 auto_discover: vec![],
2233 root: None,
2234 },
2235 BoundaryZone {
2236 name: "db".to_string(),
2237 patterns: vec!["src/db/**".to_string()],
2238 auto_discover: vec![],
2239 root: None,
2240 },
2241 ],
2242 rules: vec![],
2243 };
2244 let resolved = config.resolve();
2245 assert_eq!(
2246 resolved.classify_zone("src/components/Button.tsx"),
2247 Some("ui")
2248 );
2249 assert_eq!(resolved.classify_zone("src/db/queries.ts"), Some("db"));
2250 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
2251 }
2252
2253 #[test]
2254 fn first_match_wins() {
2255 let config = BoundaryConfig {
2256 preset: None,
2257 zones: vec![
2258 BoundaryZone {
2259 name: "specific".to_string(),
2260 patterns: vec!["src/shared/db-utils/**".to_string()],
2261 auto_discover: vec![],
2262 root: None,
2263 },
2264 BoundaryZone {
2265 name: "shared".to_string(),
2266 patterns: vec!["src/shared/**".to_string()],
2267 auto_discover: vec![],
2268 root: None,
2269 },
2270 ],
2271 rules: vec![],
2272 };
2273 let resolved = config.resolve();
2274 assert_eq!(
2275 resolved.classify_zone("src/shared/db-utils/pool.ts"),
2276 Some("specific")
2277 );
2278 assert_eq!(
2279 resolved.classify_zone("src/shared/helpers.ts"),
2280 Some("shared")
2281 );
2282 }
2283
2284 #[test]
2285 fn self_import_always_allowed() {
2286 let config = BoundaryConfig {
2287 preset: None,
2288 zones: vec![BoundaryZone {
2289 name: "ui".to_string(),
2290 patterns: vec![],
2291 auto_discover: vec![],
2292 root: None,
2293 }],
2294 rules: vec![BoundaryRule {
2295 from: "ui".to_string(),
2296 allow: vec![],
2297 allow_type_only: vec![],
2298 }],
2299 };
2300 let resolved = config.resolve();
2301 assert!(resolved.is_import_allowed("ui", "ui"));
2302 }
2303
2304 #[test]
2305 fn unrestricted_zone_allows_all() {
2306 let config = BoundaryConfig {
2307 preset: None,
2308 zones: vec![
2309 BoundaryZone {
2310 name: "shared".to_string(),
2311 patterns: vec![],
2312 auto_discover: vec![],
2313 root: None,
2314 },
2315 BoundaryZone {
2316 name: "db".to_string(),
2317 patterns: vec![],
2318 auto_discover: vec![],
2319 root: None,
2320 },
2321 ],
2322 rules: vec![],
2323 };
2324 let resolved = config.resolve();
2325 assert!(resolved.is_import_allowed("shared", "db"));
2326 }
2327
2328 #[test]
2329 fn restricted_zone_blocks_unlisted() {
2330 let config = BoundaryConfig {
2331 preset: None,
2332 zones: vec![
2333 BoundaryZone {
2334 name: "ui".to_string(),
2335 patterns: vec![],
2336 auto_discover: vec![],
2337 root: None,
2338 },
2339 BoundaryZone {
2340 name: "db".to_string(),
2341 patterns: vec![],
2342 auto_discover: vec![],
2343 root: None,
2344 },
2345 BoundaryZone {
2346 name: "shared".to_string(),
2347 patterns: vec![],
2348 auto_discover: vec![],
2349 root: None,
2350 },
2351 ],
2352 rules: vec![BoundaryRule {
2353 from: "ui".to_string(),
2354 allow: vec!["shared".to_string()],
2355 allow_type_only: vec![],
2356 }],
2357 };
2358 let resolved = config.resolve();
2359 assert!(resolved.is_import_allowed("ui", "shared"));
2360 assert!(!resolved.is_import_allowed("ui", "db"));
2361 }
2362
2363 #[test]
2364 fn empty_allow_blocks_all_except_self() {
2365 let config = BoundaryConfig {
2366 preset: None,
2367 zones: vec![
2368 BoundaryZone {
2369 name: "isolated".to_string(),
2370 patterns: vec![],
2371 auto_discover: vec![],
2372 root: None,
2373 },
2374 BoundaryZone {
2375 name: "other".to_string(),
2376 patterns: vec![],
2377 auto_discover: vec![],
2378 root: None,
2379 },
2380 ],
2381 rules: vec![BoundaryRule {
2382 from: "isolated".to_string(),
2383 allow: vec![],
2384 allow_type_only: vec![],
2385 }],
2386 };
2387 let resolved = config.resolve();
2388 assert!(resolved.is_import_allowed("isolated", "isolated"));
2389 assert!(!resolved.is_import_allowed("isolated", "other"));
2390 }
2391
2392 #[test]
2393 fn zone_root_filters_classification_to_subtree() {
2394 let config = BoundaryConfig {
2395 preset: None,
2396 zones: vec![
2397 BoundaryZone {
2398 name: "ui".to_string(),
2399 patterns: vec!["src/**".to_string()],
2400 auto_discover: vec![],
2401 root: Some("packages/app/".to_string()),
2402 },
2403 BoundaryZone {
2404 name: "domain".to_string(),
2405 patterns: vec!["src/**".to_string()],
2406 auto_discover: vec![],
2407 root: Some("packages/core/".to_string()),
2408 },
2409 ],
2410 rules: vec![],
2411 };
2412 let resolved = config.resolve();
2413 assert_eq!(
2415 resolved.classify_zone("packages/app/src/login.tsx"),
2416 Some("ui")
2417 );
2418 assert_eq!(
2420 resolved.classify_zone("packages/core/src/order.ts"),
2421 Some("domain")
2422 );
2423 assert_eq!(resolved.classify_zone("src/login.tsx"), None);
2425 assert_eq!(resolved.classify_zone("packages/utils/src/x.ts"), None);
2426 }
2427
2428 #[test]
2435 fn zone_root_is_case_sensitive() {
2436 let config = BoundaryConfig {
2437 preset: None,
2438 zones: vec![BoundaryZone {
2439 name: "ui".to_string(),
2440 patterns: vec!["src/**".to_string()],
2441 auto_discover: vec![],
2442 root: Some("packages/app/".to_string()),
2443 }],
2444 rules: vec![],
2445 };
2446 let resolved = config.resolve();
2447 assert_eq!(
2448 resolved.classify_zone("packages/app/src/login.tsx"),
2449 Some("ui"),
2450 "exact-case path classifies"
2451 );
2452 assert_eq!(
2453 resolved.classify_zone("packages/App/src/login.tsx"),
2454 None,
2455 "case-different path does not classify (root is case-sensitive)"
2456 );
2457 assert_eq!(
2458 resolved.classify_zone("Packages/app/src/login.tsx"),
2459 None,
2460 "case-different prefix does not classify"
2461 );
2462 }
2463
2464 #[test]
2465 fn zone_root_normalizes_trailing_slash_and_dot_prefix() {
2466 let config = BoundaryConfig {
2467 preset: None,
2468 zones: vec![
2469 BoundaryZone {
2470 name: "no-slash".to_string(),
2471 patterns: vec!["src/**".to_string()],
2472 auto_discover: vec![],
2473 root: Some("packages/app".to_string()),
2474 },
2475 BoundaryZone {
2476 name: "dot-prefixed".to_string(),
2477 patterns: vec!["src/**".to_string()],
2478 auto_discover: vec![],
2479 root: Some("./packages/lib/".to_string()),
2480 },
2481 ],
2482 rules: vec![],
2483 };
2484 let resolved = config.resolve();
2485 assert_eq!(resolved.zones[0].root.as_deref(), Some("packages/app/"));
2486 assert_eq!(resolved.zones[1].root.as_deref(), Some("packages/lib/"));
2487 assert_eq!(
2488 resolved.classify_zone("packages/app/src/x.ts"),
2489 Some("no-slash")
2490 );
2491 assert_eq!(
2492 resolved.classify_zone("packages/lib/src/x.ts"),
2493 Some("dot-prefixed")
2494 );
2495 }
2496
2497 #[test]
2498 fn validate_root_prefixes_flags_redundant_pattern() {
2499 let config = BoundaryConfig {
2500 preset: None,
2501 zones: vec![BoundaryZone {
2502 name: "ui".to_string(),
2503 patterns: vec!["packages/app/src/**".to_string()],
2504 auto_discover: vec![],
2505 root: Some("packages/app/".to_string()),
2506 }],
2507 rules: vec![],
2508 };
2509 let errors = config.validate_root_prefixes();
2510 assert_eq!(errors.len(), 1, "expected one redundant-prefix error");
2511 assert!(
2512 errors[0].contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"),
2513 "error should be tagged: {}",
2514 errors[0]
2515 );
2516 assert!(
2517 errors[0].contains("zone 'ui'"),
2518 "error should name the zone: {}",
2519 errors[0]
2520 );
2521 assert!(
2522 errors[0].contains("packages/app/src/**"),
2523 "error should quote the pattern: {}",
2524 errors[0]
2525 );
2526 }
2527
2528 #[test]
2529 fn validate_root_prefixes_handles_unnormalized_root() {
2530 let config = BoundaryConfig {
2533 preset: None,
2534 zones: vec![BoundaryZone {
2535 name: "ui".to_string(),
2536 patterns: vec!["./packages/app/src/**".to_string()],
2537 auto_discover: vec![],
2538 root: Some("packages/app".to_string()),
2539 }],
2540 rules: vec![],
2541 };
2542 let errors = config.validate_root_prefixes();
2543 assert_eq!(errors.len(), 1);
2544 }
2545
2546 #[test]
2547 fn validate_root_prefixes_empty_when_no_overlap() {
2548 let config = BoundaryConfig {
2549 preset: None,
2550 zones: vec![BoundaryZone {
2551 name: "ui".to_string(),
2552 patterns: vec!["src/**".to_string()],
2553 auto_discover: vec![],
2554 root: Some("packages/app/".to_string()),
2555 }],
2556 rules: vec![],
2557 };
2558 assert!(config.validate_root_prefixes().is_empty());
2559 }
2560
2561 #[test]
2562 fn validate_root_prefixes_skips_zones_without_root() {
2563 let json = r#"{
2564 "zones": [{ "name": "ui", "patterns": ["src/**"] }],
2565 "rules": []
2566 }"#;
2567 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2568 assert!(config.validate_root_prefixes().is_empty());
2569 }
2570
2571 #[test]
2577 fn validate_root_prefixes_skips_empty_root() {
2578 for raw_root in ["", ".", "./"] {
2579 let config = BoundaryConfig {
2580 preset: None,
2581 zones: vec![BoundaryZone {
2582 name: "ui".to_string(),
2583 patterns: vec!["src/**".to_string(), "lib/**".to_string()],
2584 auto_discover: vec![],
2585 root: Some(raw_root.to_string()),
2586 }],
2587 rules: vec![],
2588 };
2589 let errors = config.validate_root_prefixes();
2590 assert!(
2591 errors.is_empty(),
2592 "empty-normalized root {raw_root:?} produced spurious errors: {errors:?}"
2593 );
2594 }
2595 }
2596
2597 #[test]
2598 fn deserialize_zone_with_root() {
2599 let json = r#"{
2600 "zones": [
2601 { "name": "ui", "patterns": ["src/**"], "root": "packages/app/" }
2602 ],
2603 "rules": []
2604 }"#;
2605 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2606 assert_eq!(config.zones[0].root.as_deref(), Some("packages/app/"));
2607 }
2608
2609 #[test]
2612 fn deserialize_preset_json() {
2613 let json = r#"{ "preset": "layered" }"#;
2614 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2615 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
2616 assert!(config.zones.is_empty());
2617 }
2618
2619 #[test]
2620 fn deserialize_preset_hexagonal_json() {
2621 let json = r#"{ "preset": "hexagonal" }"#;
2622 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2623 assert_eq!(config.preset, Some(BoundaryPreset::Hexagonal));
2624 }
2625
2626 #[test]
2627 fn deserialize_preset_feature_sliced_json() {
2628 let json = r#"{ "preset": "feature-sliced" }"#;
2629 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2630 assert_eq!(config.preset, Some(BoundaryPreset::FeatureSliced));
2631 }
2632
2633 #[test]
2634 fn deserialize_preset_toml() {
2635 let toml_str = r#"preset = "layered""#;
2636 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
2637 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
2638 }
2639
2640 #[test]
2641 fn deserialize_invalid_preset_rejected() {
2642 let json = r#"{ "preset": "invalid_preset" }"#;
2643 let result: Result<BoundaryConfig, _> = serde_json::from_str(json);
2644 assert!(result.is_err());
2645 }
2646
2647 #[test]
2648 fn preset_absent_by_default() {
2649 let config = BoundaryConfig::default();
2650 assert!(config.preset.is_none());
2651 assert!(config.is_empty());
2652 }
2653
2654 #[test]
2655 fn preset_makes_config_non_empty() {
2656 let config = BoundaryConfig {
2657 preset: Some(BoundaryPreset::Layered),
2658 zones: vec![],
2659 rules: vec![],
2660 };
2661 assert!(!config.is_empty());
2662 }
2663
2664 #[test]
2667 fn expand_layered_produces_four_zones() {
2668 let mut config = BoundaryConfig {
2669 preset: Some(BoundaryPreset::Layered),
2670 zones: vec![],
2671 rules: vec![],
2672 };
2673 config.expand("src");
2674 assert_eq!(config.zones.len(), 4);
2675 assert_eq!(config.rules.len(), 4);
2676 assert!(config.preset.is_none(), "preset cleared after expand");
2677 assert_eq!(config.zones[0].name, "presentation");
2678 assert_eq!(config.zones[0].patterns, vec!["src/presentation/**"]);
2679 }
2680
2681 #[test]
2682 fn expand_layered_rules_correct() {
2683 let mut config = BoundaryConfig {
2684 preset: Some(BoundaryPreset::Layered),
2685 zones: vec![],
2686 rules: vec![],
2687 };
2688 config.expand("src");
2689 let pres_rule = config
2691 .rules
2692 .iter()
2693 .find(|r| r.from == "presentation")
2694 .unwrap();
2695 assert_eq!(pres_rule.allow, vec!["application"]);
2696 let app_rule = config
2698 .rules
2699 .iter()
2700 .find(|r| r.from == "application")
2701 .unwrap();
2702 assert_eq!(app_rule.allow, vec!["domain"]);
2703 let dom_rule = config.rules.iter().find(|r| r.from == "domain").unwrap();
2705 assert!(dom_rule.allow.is_empty());
2706 let infra_rule = config
2708 .rules
2709 .iter()
2710 .find(|r| r.from == "infrastructure")
2711 .unwrap();
2712 assert_eq!(infra_rule.allow, vec!["domain", "application"]);
2713 }
2714
2715 #[test]
2716 fn expand_hexagonal_produces_three_zones() {
2717 let mut config = BoundaryConfig {
2718 preset: Some(BoundaryPreset::Hexagonal),
2719 zones: vec![],
2720 rules: vec![],
2721 };
2722 config.expand("src");
2723 assert_eq!(config.zones.len(), 3);
2724 assert_eq!(config.rules.len(), 3);
2725 assert_eq!(config.zones[0].name, "adapters");
2726 assert_eq!(config.zones[1].name, "ports");
2727 assert_eq!(config.zones[2].name, "domain");
2728 }
2729
2730 #[test]
2731 fn expand_feature_sliced_produces_six_zones() {
2732 let mut config = BoundaryConfig {
2733 preset: Some(BoundaryPreset::FeatureSliced),
2734 zones: vec![],
2735 rules: vec![],
2736 };
2737 config.expand("src");
2738 assert_eq!(config.zones.len(), 6);
2739 assert_eq!(config.rules.len(), 6);
2740 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
2742 assert_eq!(
2743 app_rule.allow,
2744 vec!["pages", "widgets", "features", "entities", "shared"]
2745 );
2746 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
2748 assert!(shared_rule.allow.is_empty());
2749 let ent_rule = config.rules.iter().find(|r| r.from == "entities").unwrap();
2751 assert_eq!(ent_rule.allow, vec!["shared"]);
2752 }
2753
2754 #[test]
2755 fn expand_bulletproof_produces_four_zones() {
2756 let mut config = BoundaryConfig {
2757 preset: Some(BoundaryPreset::Bulletproof),
2758 zones: vec![],
2759 rules: vec![],
2760 };
2761 config.expand("src");
2762 assert_eq!(config.zones.len(), 4);
2763 assert_eq!(config.rules.len(), 4);
2764 assert_eq!(config.zones[0].name, "app");
2765 assert_eq!(config.zones[1].name, "features");
2766 assert_eq!(config.zones[2].name, "shared");
2767 assert_eq!(config.zones[3].name, "server");
2768 assert!(config.zones[2].patterns.len() > 1);
2770 assert!(
2771 config.zones[2]
2772 .patterns
2773 .contains(&"src/components/**".to_string())
2774 );
2775 assert!(
2776 config.zones[2]
2777 .patterns
2778 .contains(&"src/hooks/**".to_string())
2779 );
2780 assert!(config.zones[2].patterns.contains(&"src/lib/**".to_string()));
2781 assert!(
2782 config.zones[2]
2783 .patterns
2784 .contains(&"src/providers/**".to_string())
2785 );
2786 }
2787
2788 #[test]
2789 fn expand_bulletproof_rules_correct() {
2790 let mut config = BoundaryConfig {
2791 preset: Some(BoundaryPreset::Bulletproof),
2792 zones: vec![],
2793 rules: vec![],
2794 };
2795 config.expand("src");
2796 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
2798 assert_eq!(app_rule.allow, vec!["features", "shared", "server"]);
2799 let feat_rule = config.rules.iter().find(|r| r.from == "features").unwrap();
2801 assert_eq!(feat_rule.allow, vec!["shared", "server"]);
2802 let srv_rule = config.rules.iter().find(|r| r.from == "server").unwrap();
2804 assert_eq!(srv_rule.allow, vec!["shared"]);
2805 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
2807 assert!(shared_rule.allow.is_empty());
2808 }
2809
2810 #[test]
2811 fn expand_bulletproof_then_resolve_classifies() {
2812 let mut config = BoundaryConfig {
2816 preset: Some(BoundaryPreset::Bulletproof),
2817 zones: vec![],
2818 rules: vec![],
2819 };
2820 config.expand("src");
2821 let resolved = config.resolve();
2822 assert_eq!(
2823 resolved.classify_zone("src/app/dashboard/page.tsx"),
2824 Some("app")
2825 );
2826 assert_eq!(
2827 resolved.classify_zone("src/features/auth/hooks/useAuth.ts"),
2828 Some("features"),
2829 "without expand_auto_discover, src/features/... falls back to the parent zone"
2830 );
2831 assert_eq!(
2832 resolved.classify_zone("src/components/Button/Button.tsx"),
2833 Some("shared")
2834 );
2835 assert_eq!(
2836 resolved.classify_zone("src/hooks/useFormatters.ts"),
2837 Some("shared")
2838 );
2839 assert_eq!(
2840 resolved.classify_zone("src/server/db/schema/users.ts"),
2841 Some("server")
2842 );
2843 assert!(resolved.is_import_allowed("features", "shared"));
2845 assert!(resolved.is_import_allowed("features", "server"));
2846 assert!(!resolved.is_import_allowed("features", "app"));
2847 assert!(!resolved.is_import_allowed("shared", "features"));
2848 assert!(!resolved.is_import_allowed("server", "features"));
2849 }
2850
2851 #[test]
2857 fn bulletproof_features_barrel_can_import_children() {
2858 let temp = tempfile::tempdir().unwrap();
2859 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2860 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
2861
2862 let mut config = BoundaryConfig {
2863 preset: Some(BoundaryPreset::Bulletproof),
2864 zones: vec![],
2865 rules: vec![],
2866 };
2867 config.expand("src");
2868 config.expand_auto_discover(temp.path());
2869 let resolved = config.resolve();
2870
2871 assert_eq!(
2873 resolved.classify_zone("src/features/index.ts"),
2874 Some("features"),
2875 "src/features/index.ts barrel should classify as the parent features zone"
2876 );
2877 assert_eq!(
2879 resolved.classify_zone("src/features/auth/login.ts"),
2880 Some("features/auth")
2881 );
2882 assert_eq!(
2883 resolved.classify_zone("src/features/billing/invoice.ts"),
2884 Some("features/billing")
2885 );
2886 assert!(resolved.is_import_allowed("features", "features/auth"));
2888 assert!(resolved.is_import_allowed("features", "features/billing"));
2889 assert!(!resolved.is_import_allowed("features/auth", "features/billing"));
2891 }
2892
2893 #[test]
2894 fn expand_uses_custom_source_root() {
2895 let mut config = BoundaryConfig {
2896 preset: Some(BoundaryPreset::Hexagonal),
2897 zones: vec![],
2898 rules: vec![],
2899 };
2900 config.expand("lib");
2901 assert_eq!(config.zones[0].patterns, vec!["lib/adapters/**"]);
2902 assert_eq!(config.zones[2].patterns, vec!["lib/domain/**"]);
2903 }
2904
2905 #[test]
2908 fn user_zone_replaces_preset_zone() {
2909 let mut config = BoundaryConfig {
2910 preset: Some(BoundaryPreset::Hexagonal),
2911 zones: vec![BoundaryZone {
2912 name: "domain".to_string(),
2913 patterns: vec!["src/core/**".to_string()],
2914 auto_discover: vec![],
2915 root: None,
2916 }],
2917 rules: vec![],
2918 };
2919 config.expand("src");
2920 assert_eq!(config.zones.len(), 3);
2922 let domain = config.zones.iter().find(|z| z.name == "domain").unwrap();
2923 assert_eq!(domain.patterns, vec!["src/core/**"]);
2924 }
2925
2926 #[test]
2927 fn user_zone_adds_to_preset() {
2928 let mut config = BoundaryConfig {
2929 preset: Some(BoundaryPreset::Hexagonal),
2930 zones: vec![BoundaryZone {
2931 name: "shared".to_string(),
2932 patterns: vec!["src/shared/**".to_string()],
2933 auto_discover: vec![],
2934 root: None,
2935 }],
2936 rules: vec![],
2937 };
2938 config.expand("src");
2939 assert_eq!(config.zones.len(), 4); assert!(config.zones.iter().any(|z| z.name == "shared"));
2941 }
2942
2943 #[test]
2944 fn user_rule_replaces_preset_rule() {
2945 let mut config = BoundaryConfig {
2946 preset: Some(BoundaryPreset::Hexagonal),
2947 zones: vec![],
2948 rules: vec![BoundaryRule {
2949 from: "adapters".to_string(),
2950 allow: vec!["ports".to_string(), "domain".to_string()],
2951 allow_type_only: vec![],
2952 }],
2953 };
2954 config.expand("src");
2955 let adapter_rule = config.rules.iter().find(|r| r.from == "adapters").unwrap();
2956 assert_eq!(adapter_rule.allow, vec!["ports", "domain"]);
2958 assert_eq!(
2960 config.rules.iter().filter(|r| r.from == "adapters").count(),
2961 1
2962 );
2963 }
2964
2965 #[test]
2966 fn expand_without_preset_is_noop() {
2967 let mut config = BoundaryConfig {
2968 preset: None,
2969 zones: vec![BoundaryZone {
2970 name: "ui".to_string(),
2971 patterns: vec!["src/ui/**".to_string()],
2972 auto_discover: vec![],
2973 root: None,
2974 }],
2975 rules: vec![],
2976 };
2977 config.expand("src");
2978 assert_eq!(config.zones.len(), 1);
2979 assert_eq!(config.zones[0].name, "ui");
2980 }
2981
2982 #[test]
2983 fn expand_then_validate_succeeds() {
2984 let mut config = BoundaryConfig {
2985 preset: Some(BoundaryPreset::Layered),
2986 zones: vec![],
2987 rules: vec![],
2988 };
2989 config.expand("src");
2990 assert!(config.validate_zone_references().is_empty());
2991 }
2992
2993 #[test]
2994 fn expand_then_resolve_classifies() {
2995 let mut config = BoundaryConfig {
2996 preset: Some(BoundaryPreset::Hexagonal),
2997 zones: vec![],
2998 rules: vec![],
2999 };
3000 config.expand("src");
3001 let resolved = config.resolve();
3002 assert_eq!(
3003 resolved.classify_zone("src/adapters/http/handler.ts"),
3004 Some("adapters")
3005 );
3006 assert_eq!(resolved.classify_zone("src/domain/user.ts"), Some("domain"));
3007 assert!(!resolved.is_import_allowed("adapters", "domain"));
3008 assert!(resolved.is_import_allowed("adapters", "ports"));
3009 }
3010
3011 #[test]
3012 fn preset_name_returns_correct_string() {
3013 let config = BoundaryConfig {
3014 preset: Some(BoundaryPreset::FeatureSliced),
3015 zones: vec![],
3016 rules: vec![],
3017 };
3018 assert_eq!(config.preset_name(), Some("feature-sliced"));
3019
3020 let empty = BoundaryConfig::default();
3021 assert_eq!(empty.preset_name(), None);
3022 }
3023
3024 #[test]
3025 fn preset_name_all_variants() {
3026 let cases = [
3027 (BoundaryPreset::Layered, "layered"),
3028 (BoundaryPreset::Hexagonal, "hexagonal"),
3029 (BoundaryPreset::FeatureSliced, "feature-sliced"),
3030 (BoundaryPreset::Bulletproof, "bulletproof"),
3031 ];
3032 for (preset, expected_name) in cases {
3033 let config = BoundaryConfig {
3034 preset: Some(preset),
3035 zones: vec![],
3036 rules: vec![],
3037 };
3038 assert_eq!(
3039 config.preset_name(),
3040 Some(expected_name),
3041 "preset_name() mismatch for variant"
3042 );
3043 }
3044 }
3045
3046 #[test]
3049 fn resolved_boundary_config_empty() {
3050 let resolved = ResolvedBoundaryConfig::default();
3051 assert!(resolved.is_empty());
3052 }
3053
3054 #[test]
3055 fn resolved_boundary_config_with_zones_not_empty() {
3056 let config = BoundaryConfig {
3057 preset: None,
3058 zones: vec![BoundaryZone {
3059 name: "ui".to_string(),
3060 patterns: vec!["src/ui/**".to_string()],
3061 auto_discover: vec![],
3062 root: None,
3063 }],
3064 rules: vec![],
3065 };
3066 let resolved = config.resolve();
3067 assert!(!resolved.is_empty());
3068 }
3069
3070 #[test]
3071 fn resolved_boundary_config_with_only_logical_groups_not_empty() {
3072 let resolved = ResolvedBoundaryConfig {
3079 zones: vec![],
3080 rules: vec![],
3081 logical_groups: vec![LogicalGroup {
3082 name: "features".to_string(),
3083 children: vec![],
3084 auto_discover: vec!["src/features".to_string()],
3085 authored_rule: None,
3086 fallback_zone: None,
3087 source_zone_index: 0,
3088 status: LogicalGroupStatus::Empty,
3089 merged_from: None,
3090 original_zone_root: None,
3091 child_source_indices: vec![],
3092 }],
3093 };
3094 assert!(!resolved.is_empty());
3095 }
3096
3097 #[test]
3100 fn boundary_config_with_only_rules_is_empty() {
3101 let config = BoundaryConfig {
3104 preset: None,
3105 zones: vec![],
3106 rules: vec![BoundaryRule {
3107 from: "ui".to_string(),
3108 allow: vec!["db".to_string()],
3109 allow_type_only: vec![],
3110 }],
3111 };
3112 assert!(config.is_empty());
3113 }
3114
3115 #[test]
3116 fn boundary_config_with_zones_not_empty() {
3117 let config = BoundaryConfig {
3118 preset: None,
3119 zones: vec![BoundaryZone {
3120 name: "ui".to_string(),
3121 patterns: vec![],
3122 auto_discover: vec![],
3123 root: None,
3124 }],
3125 rules: vec![],
3126 };
3127 assert!(!config.is_empty());
3128 }
3129
3130 #[test]
3133 fn zone_with_multiple_patterns_matches_any() {
3134 let config = BoundaryConfig {
3135 preset: None,
3136 zones: vec![BoundaryZone {
3137 name: "ui".to_string(),
3138 patterns: vec![
3139 "src/components/**".to_string(),
3140 "src/pages/**".to_string(),
3141 "src/views/**".to_string(),
3142 ],
3143 auto_discover: vec![],
3144 root: None,
3145 }],
3146 rules: vec![],
3147 };
3148 let resolved = config.resolve();
3149 assert_eq!(
3150 resolved.classify_zone("src/components/Button.tsx"),
3151 Some("ui")
3152 );
3153 assert_eq!(resolved.classify_zone("src/pages/Home.tsx"), Some("ui"));
3154 assert_eq!(
3155 resolved.classify_zone("src/views/Dashboard.tsx"),
3156 Some("ui")
3157 );
3158 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
3159 }
3160
3161 #[test]
3164 fn validate_zone_references_multiple_errors() {
3165 let config = BoundaryConfig {
3166 preset: None,
3167 zones: vec![BoundaryZone {
3168 name: "ui".to_string(),
3169 patterns: vec![],
3170 auto_discover: vec![],
3171 root: None,
3172 }],
3173 rules: vec![
3174 BoundaryRule {
3175 from: "nonexistent_from".to_string(),
3176 allow: vec!["nonexistent_allow".to_string()],
3177 allow_type_only: vec![],
3178 },
3179 BoundaryRule {
3180 from: "ui".to_string(),
3181 allow: vec!["also_nonexistent".to_string()],
3182 allow_type_only: vec![],
3183 },
3184 ],
3185 };
3186 let errors = config.validate_zone_references();
3187 assert_eq!(errors.len(), 3);
3190 }
3191
3192 #[test]
3195 fn expand_feature_sliced_with_custom_root() {
3196 let mut config = BoundaryConfig {
3197 preset: Some(BoundaryPreset::FeatureSliced),
3198 zones: vec![],
3199 rules: vec![],
3200 };
3201 config.expand("lib");
3202 assert_eq!(config.zones[0].patterns, vec!["lib/app/**"]);
3203 assert_eq!(config.zones[5].patterns, vec!["lib/shared/**"]);
3204 }
3205
3206 #[test]
3209 fn zone_not_in_rules_is_unrestricted() {
3210 let config = BoundaryConfig {
3211 preset: None,
3212 zones: vec![
3213 BoundaryZone {
3214 name: "a".to_string(),
3215 patterns: vec![],
3216 auto_discover: vec![],
3217 root: None,
3218 },
3219 BoundaryZone {
3220 name: "b".to_string(),
3221 patterns: vec![],
3222 auto_discover: vec![],
3223 root: None,
3224 },
3225 BoundaryZone {
3226 name: "c".to_string(),
3227 patterns: vec![],
3228 auto_discover: vec![],
3229 root: None,
3230 },
3231 ],
3232 rules: vec![BoundaryRule {
3233 from: "a".to_string(),
3234 allow: vec!["b".to_string()],
3235 allow_type_only: vec![],
3236 }],
3237 };
3238 let resolved = config.resolve();
3239 assert!(resolved.is_import_allowed("a", "b"));
3241 assert!(!resolved.is_import_allowed("a", "c"));
3242 assert!(resolved.is_import_allowed("b", "a"));
3244 assert!(resolved.is_import_allowed("b", "c"));
3245 assert!(resolved.is_import_allowed("c", "a"));
3247 }
3248
3249 #[test]
3252 fn boundary_preset_json_roundtrip() {
3253 let presets = [
3254 BoundaryPreset::Layered,
3255 BoundaryPreset::Hexagonal,
3256 BoundaryPreset::FeatureSliced,
3257 BoundaryPreset::Bulletproof,
3258 ];
3259 for preset in presets {
3260 let json = serde_json::to_string(&preset).unwrap();
3261 let restored: BoundaryPreset = serde_json::from_str(&json).unwrap();
3262 assert_eq!(restored, preset);
3263 }
3264 }
3265
3266 #[test]
3267 fn deserialize_preset_bulletproof_json() {
3268 let json = r#"{ "preset": "bulletproof" }"#;
3269 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
3270 assert_eq!(config.preset, Some(BoundaryPreset::Bulletproof));
3271 }
3272
3273 #[test]
3276 fn resolve_skips_invalid_zone_glob() {
3277 let config = BoundaryConfig {
3278 preset: None,
3279 zones: vec![BoundaryZone {
3280 name: "broken".to_string(),
3281 patterns: vec!["[invalid".to_string()],
3282 auto_discover: vec![],
3283 root: None,
3284 }],
3285 rules: vec![],
3286 };
3287 let resolved = config.resolve();
3288 assert!(!resolved.is_empty());
3290 assert_eq!(resolved.classify_zone("anything.ts"), None);
3291 }
3292}