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)]
12pub enum ZoneReferenceKind {
13 From,
15 Allow,
17 AllowTypeOnly,
19}
20
21impl ZoneReferenceKind {
22 fn config_field(self) -> &'static str {
23 match self {
24 Self::From => "from",
25 Self::Allow => "allow",
26 Self::AllowTypeOnly => "allowTypeOnly",
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct UnknownZoneRef {
34 pub rule_index: usize,
36 pub kind: ZoneReferenceKind,
38 pub zone_name: String,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct RedundantRootPrefix {
45 pub zone_name: String,
47 pub pattern: String,
49 pub root: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum ZoneValidationError {
56 UnknownZoneReference(UnknownZoneRef),
58 RedundantRootPrefix(RedundantRootPrefix),
60}
61
62impl fmt::Display for ZoneValidationError {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 match self {
65 Self::UnknownZoneReference(err) => write!(
66 f,
67 "boundaries.rules[{}].{}: references undefined zone '{}'",
68 err.rule_index,
69 err.kind.config_field(),
70 err.zone_name,
71 ),
72 Self::RedundantRootPrefix(err) => write!(
73 f,
74 "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.",
75 err.zone_name, err.pattern, err.root,
76 ),
77 }
78 }
79}
80
81impl std::error::Error for ZoneValidationError {}
82
83#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
85#[serde(rename_all = "kebab-case")]
86pub enum BoundaryPreset {
87 Layered,
89 Hexagonal,
91 FeatureSliced,
93 Bulletproof,
95}
96
97impl BoundaryPreset {
98 #[must_use]
100 pub fn default_config(&self, source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
101 match self {
102 Self::Layered => Self::layered_config(source_root),
103 Self::Hexagonal => Self::hexagonal_config(source_root),
104 Self::FeatureSliced => Self::feature_sliced_config(source_root),
105 Self::Bulletproof => Self::bulletproof_config(source_root),
106 }
107 }
108
109 fn zone(name: &str, source_root: &str) -> BoundaryZone {
110 BoundaryZone {
111 name: name.to_owned(),
112 patterns: vec![format!("{source_root}/{name}/**")],
113 auto_discover: vec![],
114 root: None,
115 }
116 }
117
118 fn rule(from: &str, allow: &[&str]) -> BoundaryRule {
119 BoundaryRule {
120 from: from.to_owned(),
121 allow: allow.iter().map(|s| (*s).to_owned()).collect(),
122 allow_type_only: Vec::new(),
123 }
124 }
125
126 fn layered_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
127 let zones = vec![
128 Self::zone("presentation", source_root),
129 Self::zone("application", source_root),
130 Self::zone("domain", source_root),
131 Self::zone("infrastructure", source_root),
132 ];
133 let rules = vec![
134 Self::rule("presentation", &["application"]),
135 Self::rule("application", &["domain"]),
136 Self::rule("domain", &[]),
137 Self::rule("infrastructure", &["domain", "application"]),
138 ];
139 (zones, rules)
140 }
141
142 fn hexagonal_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
143 let zones = vec![
144 Self::zone("adapters", source_root),
145 Self::zone("ports", source_root),
146 Self::zone("domain", source_root),
147 ];
148 let rules = vec![
149 Self::rule("adapters", &["ports"]),
150 Self::rule("ports", &["domain"]),
151 Self::rule("domain", &[]),
152 ];
153 (zones, rules)
154 }
155
156 fn feature_sliced_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
157 let layer_names = ["app", "pages", "widgets", "features", "entities", "shared"];
158 let zones = layer_names
159 .iter()
160 .map(|name| Self::zone(name, source_root))
161 .collect();
162 let rules = layer_names
163 .iter()
164 .enumerate()
165 .map(|(i, name)| {
166 let below: Vec<&str> = layer_names[i + 1..].to_vec();
167 Self::rule(name, &below)
168 })
169 .collect();
170 (zones, rules)
171 }
172
173 fn bulletproof_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
174 let zones = vec![
175 Self::zone("app", source_root),
176 BoundaryZone {
177 name: "features".to_owned(),
178 patterns: vec![format!("{source_root}/features/**")],
179 auto_discover: vec![format!("{source_root}/features")],
180 root: None,
181 },
182 BoundaryZone {
183 name: "shared".to_owned(),
184 patterns: [
185 "components",
186 "hooks",
187 "lib",
188 "utils",
189 "utilities",
190 "providers",
191 "shared",
192 "types",
193 "styles",
194 "i18n",
195 ]
196 .iter()
197 .map(|dir| format!("{source_root}/{dir}/**"))
198 .collect(),
199 auto_discover: vec![],
200 root: None,
201 },
202 Self::zone("server", source_root),
203 ];
204 let rules = vec![
205 Self::rule("app", &["features", "shared", "server"]),
206 Self::rule("features", &["shared", "server"]),
207 Self::rule("server", &["shared"]),
208 Self::rule("shared", &[]),
209 ];
210 (zones, rules)
211 }
212}
213
214#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
216#[serde(rename_all = "camelCase")]
217pub struct BoundaryConfig {
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub preset: Option<BoundaryPreset>,
221 #[serde(default)]
223 pub zones: Vec<BoundaryZone>,
224 #[serde(default)]
226 pub rules: Vec<BoundaryRule>,
227}
228
229#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
231#[serde(rename_all = "camelCase")]
232pub struct BoundaryZone {
233 pub name: String,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
237 pub patterns: Vec<String>,
238 #[serde(default, skip_serializing_if = "Vec::is_empty")]
240 pub auto_discover: Vec<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub root: Option<String>,
244}
245
246#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
248#[serde(rename_all = "camelCase")]
249pub struct BoundaryRule {
250 pub from: String,
252 #[serde(default)]
254 pub allow: Vec<String>,
255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 pub allow_type_only: Vec<String>,
258}
259
260#[derive(Debug, Default)]
262pub struct ResolvedBoundaryConfig {
263 pub zones: Vec<ResolvedZone>,
265 pub rules: Vec<ResolvedBoundaryRule>,
267 pub logical_groups: Vec<LogicalGroup>,
269}
270
271#[derive(Debug, Clone, Serialize, JsonSchema)]
273#[serde(rename_all = "snake_case")]
274pub struct LogicalGroup {
275 pub name: String,
277 pub children: Vec<String>,
279 pub auto_discover: Vec<String>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub authored_rule: Option<AuthoredRule>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub fallback_zone: Option<String>,
287 pub source_zone_index: usize,
289 pub status: LogicalGroupStatus,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub merged_from: Option<Vec<usize>>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub original_zone_root: Option<String>,
297 #[serde(default, skip_serializing_if = "Vec::is_empty")]
299 pub child_source_indices: Vec<usize>,
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
304#[serde(rename_all = "snake_case")]
305pub enum LogicalGroupStatus {
306 Ok,
308 Empty,
310 InvalidPath,
312}
313
314#[derive(Debug, Clone, Serialize, JsonSchema)]
316pub struct AuthoredRule {
317 pub allow: Vec<String>,
319 #[serde(default, skip_serializing_if = "Vec::is_empty")]
321 pub allow_type_only: Vec<String>,
322}
323
324#[derive(Debug)]
326pub struct ResolvedZone {
327 pub name: String,
329 pub matchers: Vec<globset::GlobMatcher>,
331 pub root: Option<String>,
333}
334
335#[derive(Debug)]
337pub struct ResolvedBoundaryRule {
338 pub from_zone: String,
340 pub allowed_zones: Vec<String>,
342 pub allow_type_only_zones: Vec<String>,
344}
345
346impl BoundaryConfig {
347 #[must_use]
349 pub fn is_empty(&self) -> bool {
350 self.preset.is_none() && self.zones.is_empty()
351 }
352
353 pub fn expand(&mut self, source_root: &str) {
355 let Some(preset) = self.preset.take() else {
356 return;
357 };
358
359 let (preset_zones, preset_rules) = preset.default_config(source_root);
360
361 let user_zone_names: rustc_hash::FxHashSet<&str> =
362 self.zones.iter().map(|z| z.name.as_str()).collect();
363
364 let mut merged_zones: Vec<BoundaryZone> = preset_zones
365 .into_iter()
366 .filter(|pz| {
367 if user_zone_names.contains(pz.name.as_str()) {
368 tracing::info!(
369 "boundary preset: user zone '{}' replaces preset zone",
370 pz.name
371 );
372 false
373 } else {
374 true
375 }
376 })
377 .collect();
378 merged_zones.append(&mut self.zones);
379 self.zones = merged_zones;
380
381 let user_rule_sources: rustc_hash::FxHashSet<&str> =
382 self.rules.iter().map(|r| r.from.as_str()).collect();
383
384 let mut merged_rules: Vec<BoundaryRule> = preset_rules
385 .into_iter()
386 .filter(|pr| {
387 if user_rule_sources.contains(pr.from.as_str()) {
388 tracing::info!(
389 "boundary preset: user rule for '{}' replaces preset rule",
390 pr.from
391 );
392 false
393 } else {
394 true
395 }
396 })
397 .collect();
398 merged_rules.append(&mut self.rules);
399 self.rules = merged_rules;
400 }
401
402 pub fn expand_auto_discover(&mut self, project_root: &Path) -> Vec<LogicalGroup> {
404 if self.zones.iter().all(|zone| zone.auto_discover.is_empty()) {
405 return Vec::new();
406 }
407
408 let original_zones = std::mem::take(&mut self.zones);
409 let mut expanded_zones = Vec::new();
410 let mut group_expansions: rustc_hash::FxHashMap<String, Vec<String>> =
411 rustc_hash::FxHashMap::default();
412 let mut group_drafts: Vec<LogicalGroupDraft> = Vec::new();
413
414 for (source_zone_index, mut zone) in original_zones.into_iter().enumerate() {
415 if zone.auto_discover.is_empty() {
416 expanded_zones.push(zone);
417 continue;
418 }
419
420 let group_name = zone.name.clone();
421 let raw_auto_discover = zone.auto_discover.clone();
422 let original_zone_root = zone.root.clone();
423 let DiscoveryOutcome {
424 zones: discovered_zones,
425 source_indices: discovered_source_indices,
426 had_invalid_path,
427 } = discover_child_zones(project_root, &zone);
428 let discovered_count = discovered_zones.len();
429 let mut expanded_names: Vec<String> = discovered_zones
430 .iter()
431 .map(|child| child.name.clone())
432 .collect();
433 let child_names_only = expanded_names.clone();
434 for child_zone in discovered_zones {
435 merge_zone_by_name(&mut expanded_zones, child_zone);
436 }
437
438 let fallback_zone = if zone.patterns.is_empty() {
439 None
440 } else {
441 expanded_names.push(group_name.clone());
442 zone.auto_discover.clear();
443 merge_zone_by_name(&mut expanded_zones, zone);
444 Some(group_name.clone())
445 };
446
447 if !expanded_names.is_empty() {
448 group_expansions
449 .entry(group_name.clone())
450 .or_default()
451 .extend(expanded_names);
452 }
453
454 let status = if discovered_count > 0 {
455 LogicalGroupStatus::Ok
456 } else if had_invalid_path {
457 LogicalGroupStatus::InvalidPath
458 } else {
459 LogicalGroupStatus::Empty
460 };
461
462 if let Some(existing) = group_drafts.iter_mut().find(|d| d.name == group_name) {
463 tracing::warn!(
464 "boundary zone '{}' is declared multiple times with autoDiscover; merging discovered children",
465 group_name
466 );
467 let auto_discover_offset = existing.auto_discover.len();
468 existing.auto_discover.extend(raw_auto_discover);
469 let existing_children: rustc_hash::FxHashSet<String> =
470 existing.children.iter().cloned().collect();
471 for (idx, name) in child_names_only.iter().enumerate() {
472 if existing_children.contains(name) {
473 continue;
474 }
475 existing.children.push(name.clone());
476 existing
477 .child_source_indices
478 .push(discovered_source_indices[idx] + auto_discover_offset);
479 }
480 if existing.fallback_zone.is_none() {
481 existing.fallback_zone = fallback_zone;
482 }
483 existing.status = merge_status(existing.status, status);
484 let chain = existing
485 .merged_from
486 .get_or_insert_with(|| vec![existing.source_zone_index]);
487 chain.push(source_zone_index);
488 } else {
489 group_drafts.push(LogicalGroupDraft {
490 name: group_name,
491 children: child_names_only,
492 auto_discover: raw_auto_discover,
493 fallback_zone,
494 source_zone_index,
495 status,
496 merged_from: None,
497 original_zone_root,
498 child_source_indices: discovered_source_indices,
499 });
500 }
501 }
502
503 self.zones = expanded_zones;
504
505 let draft_names: rustc_hash::FxHashSet<&str> =
506 group_drafts.iter().map(|d| d.name.as_str()).collect();
507
508 let original_rules = std::mem::take(&mut self.rules);
509 let authored_rules: rustc_hash::FxHashMap<&str, AuthoredRule> = original_rules
510 .iter()
511 .filter(|rule| draft_names.contains(rule.from.as_str()))
512 .map(|rule| {
513 (
514 rule.from.as_str(),
515 AuthoredRule {
516 allow: rule.allow.clone(),
517 allow_type_only: rule.allow_type_only.clone(),
518 },
519 )
520 })
521 .collect();
522
523 let logical_groups: Vec<LogicalGroup> = group_drafts
524 .into_iter()
525 .map(|draft| {
526 let child_source_indices = if draft.auto_discover.len() > 1 {
527 draft.child_source_indices
528 } else {
529 Vec::new()
530 };
531 LogicalGroup {
532 authored_rule: authored_rules.get(draft.name.as_str()).cloned(),
533 name: draft.name,
534 children: draft.children,
535 auto_discover: draft.auto_discover,
536 fallback_zone: draft.fallback_zone,
537 source_zone_index: draft.source_zone_index,
538 status: draft.status,
539 merged_from: draft.merged_from,
540 original_zone_root: draft.original_zone_root,
541 child_source_indices,
542 }
543 })
544 .collect();
545
546 if group_expansions.is_empty() {
547 self.rules = original_rules;
548 return logical_groups;
549 }
550
551 self.rules = expand_rules_for_groups(original_rules, &group_expansions);
552 logical_groups
553 }
554}
555
556fn merge_zone_by_name(expanded_zones: &mut Vec<BoundaryZone>, zone: BoundaryZone) {
558 if let Some(existing) = expanded_zones.iter_mut().find(|z| z.name == zone.name) {
559 for pattern in zone.patterns {
560 if !existing.patterns.contains(&pattern) {
561 existing.patterns.push(pattern);
562 }
563 }
564 } else {
565 expanded_zones.push(zone);
566 }
567}
568
569fn expand_rules_for_groups(
571 original_rules: Vec<BoundaryRule>,
572 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
573) -> Vec<BoundaryRule> {
574 let mut generated_rules = Vec::new();
575 let mut explicit_rules = Vec::new();
576 for rule in original_rules {
577 let allow = expand_rule_allow(&rule.allow, group_expansions);
578 let allow_type_only = expand_rule_allow(&rule.allow_type_only, group_expansions);
579
580 if let Some(from_zones) = group_expansions.get(&rule.from) {
581 for from in from_zones {
582 let (allow, allow_type_only) = if from == &rule.from {
583 (
584 expand_parent_fallback_allow(&allow, from_zones, &rule.from),
585 allow_type_only.clone(),
586 )
587 } else {
588 (
589 expand_generated_child_allow(&rule.allow, group_expansions, &rule.from),
590 expand_generated_child_allow(
591 &rule.allow_type_only,
592 group_expansions,
593 &rule.from,
594 ),
595 )
596 };
597 let expanded_rule = BoundaryRule {
598 from: from.clone(),
599 allow,
600 allow_type_only,
601 };
602 if from == &rule.from {
603 explicit_rules.push(expanded_rule);
604 } else {
605 generated_rules.push(expanded_rule);
606 }
607 }
608 } else {
609 explicit_rules.push(BoundaryRule {
610 from: rule.from,
611 allow,
612 allow_type_only,
613 });
614 }
615 }
616
617 let mut expanded_rules = dedupe_rules_keep_last(generated_rules);
618 expanded_rules.extend(dedupe_rules_keep_last(explicit_rules));
619 dedupe_rules_keep_last(expanded_rules)
620}
621
622impl BoundaryConfig {
623 #[must_use]
625 pub fn preset_name(&self) -> Option<&str> {
626 self.preset.as_ref().map(|p| match p {
627 BoundaryPreset::Layered => "layered",
628 BoundaryPreset::Hexagonal => "hexagonal",
629 BoundaryPreset::FeatureSliced => "feature-sliced",
630 BoundaryPreset::Bulletproof => "bulletproof",
631 })
632 }
633
634 #[must_use]
636 pub fn validate_root_prefixes(&self) -> Vec<RedundantRootPrefix> {
637 let mut errors = Vec::new();
638 for zone in &self.zones {
639 let Some(raw_root) = zone.root.as_deref() else {
640 continue;
641 };
642 let normalized = normalize_zone_root(raw_root);
643 if normalized.is_empty() {
644 continue;
645 }
646 for pattern in &zone.patterns {
647 let normalized_pattern = pattern.replace('\\', "/");
648 let stripped = normalized_pattern
649 .strip_prefix("./")
650 .unwrap_or(&normalized_pattern);
651 if stripped.starts_with(&normalized) {
652 errors.push(RedundantRootPrefix {
653 zone_name: zone.name.clone(),
654 pattern: pattern.clone(),
655 root: normalized.clone(),
656 });
657 }
658 }
659 }
660 errors
661 }
662
663 #[must_use]
665 pub fn validate_zone_references(&self) -> Vec<UnknownZoneRef> {
666 let zone_names: rustc_hash::FxHashSet<&str> =
667 self.zones.iter().map(|z| z.name.as_str()).collect();
668
669 let mut errors = Vec::new();
670 for (i, rule) in self.rules.iter().enumerate() {
671 if !zone_names.contains(rule.from.as_str()) {
672 errors.push(UnknownZoneRef {
673 rule_index: i,
674 kind: ZoneReferenceKind::From,
675 zone_name: rule.from.clone(),
676 });
677 }
678 for allowed in &rule.allow {
679 if !zone_names.contains(allowed.as_str()) {
680 errors.push(UnknownZoneRef {
681 rule_index: i,
682 kind: ZoneReferenceKind::Allow,
683 zone_name: allowed.clone(),
684 });
685 }
686 }
687 for allowed_type_only in &rule.allow_type_only {
688 if !zone_names.contains(allowed_type_only.as_str()) {
689 errors.push(UnknownZoneRef {
690 rule_index: i,
691 kind: ZoneReferenceKind::AllowTypeOnly,
692 zone_name: allowed_type_only.clone(),
693 });
694 }
695 }
696 }
697 errors
698 }
699
700 #[expect(
702 clippy::expect_used,
703 reason = "boundary glob patterns are validated before config resolution"
704 )]
705 #[must_use]
706 pub fn resolve(&self) -> ResolvedBoundaryConfig {
707 let zones = self
708 .zones
709 .iter()
710 .map(|zone| {
711 let matchers = zone
712 .patterns
713 .iter()
714 .map(|pattern| {
715 Glob::new(pattern)
716 .expect("boundaries.zones[].patterns was validated at config load time")
717 .compile_matcher()
718 })
719 .collect();
720 let root = zone.root.as_deref().map(normalize_zone_root);
721 ResolvedZone {
722 name: zone.name.clone(),
723 matchers,
724 root,
725 }
726 })
727 .collect();
728
729 let rules = self
730 .rules
731 .iter()
732 .map(|rule| ResolvedBoundaryRule {
733 from_zone: rule.from.clone(),
734 allowed_zones: rule.allow.clone(),
735 allow_type_only_zones: rule.allow_type_only.clone(),
736 })
737 .collect();
738
739 ResolvedBoundaryConfig {
740 zones,
741 rules,
742 logical_groups: Vec::new(),
743 }
744 }
745}
746
747fn normalize_zone_root(raw: &str) -> String {
749 let with_slashes = raw.replace('\\', "/");
750 let trimmed = with_slashes.trim_start_matches("./");
751 let no_dot = if trimmed == "." { "" } else { trimmed };
752 if no_dot.is_empty() {
753 String::new()
754 } else if no_dot.ends_with('/') {
755 no_dot.to_owned()
756 } else {
757 format!("{no_dot}/")
758 }
759}
760
761fn normalize_auto_discover_dir(raw: &str) -> Option<String> {
762 let with_slashes = raw.replace('\\', "/");
763 let trimmed = with_slashes.trim_start_matches("./").trim_end_matches('/');
764 if trimmed.starts_with('/') || trimmed.split('/').any(|part| part == "..") {
765 None
766 } else if trimmed == "." {
767 Some(String::new())
768 } else {
769 Some(trimmed.to_owned())
770 }
771}
772
773fn join_relative_path(prefix: &str, suffix: &str) -> String {
774 match (prefix.is_empty(), suffix.is_empty()) {
775 (true, true) => String::new(),
776 (true, false) => suffix.to_owned(),
777 (false, true) => prefix.trim_end_matches('/').to_owned(),
778 (false, false) => format!("{}/{}", prefix.trim_end_matches('/'), suffix),
779 }
780}
781
782struct DiscoveryOutcome {
784 zones: Vec<BoundaryZone>,
785 source_indices: Vec<usize>,
786 had_invalid_path: bool,
787}
788
789struct LogicalGroupDraft {
791 name: String,
792 children: Vec<String>,
793 auto_discover: Vec<String>,
794 fallback_zone: Option<String>,
795 source_zone_index: usize,
796 status: LogicalGroupStatus,
797 merged_from: Option<Vec<usize>>,
799 original_zone_root: Option<String>,
801 child_source_indices: Vec<usize>,
803}
804
805const fn merge_status(existing: LogicalGroupStatus, new: LogicalGroupStatus) -> LogicalGroupStatus {
807 match (existing, new) {
808 (LogicalGroupStatus::Ok, _) | (_, LogicalGroupStatus::Ok) => LogicalGroupStatus::Ok,
809 (LogicalGroupStatus::InvalidPath, _) | (_, LogicalGroupStatus::InvalidPath) => {
810 LogicalGroupStatus::InvalidPath
811 }
812 (LogicalGroupStatus::Empty, LogicalGroupStatus::Empty) => LogicalGroupStatus::Empty,
813 }
814}
815
816fn discover_child_zones(project_root: &Path, zone: &BoundaryZone) -> DiscoveryOutcome {
817 let mut zones_by_name: rustc_hash::FxHashMap<String, BoundaryZone> =
818 rustc_hash::FxHashMap::default();
819 let mut first_source_index: rustc_hash::FxHashMap<String, usize> =
820 rustc_hash::FxHashMap::default();
821 let normalized_root = zone
822 .root
823 .as_deref()
824 .map(normalize_zone_root)
825 .unwrap_or_default();
826 let mut had_invalid_path = false;
827
828 for (source_index, raw_dir) in zone.auto_discover.iter().enumerate() {
829 let Some(discover_dir) = normalize_auto_discover_dir(raw_dir) else {
830 tracing::warn!(
831 "invalid boundary autoDiscover path '{}' in zone '{}': paths must be project-relative and must not contain '..'",
832 raw_dir,
833 zone.name
834 );
835 had_invalid_path = true;
836 continue;
837 };
838
839 let fs_relative = join_relative_path(&normalized_root, &discover_dir);
840 let absolute_dir = if fs_relative.is_empty() {
841 project_root.to_path_buf()
842 } else {
843 project_root.join(&fs_relative)
844 };
845 let Ok(entries) = std::fs::read_dir(&absolute_dir) else {
846 tracing::warn!(
847 "boundary zone '{}' autoDiscover path '{}' did not resolve to a readable directory",
848 zone.name,
849 raw_dir
850 );
851 had_invalid_path = true;
852 continue;
853 };
854
855 let mut children: Vec<_> = entries
856 .filter_map(Result::ok)
857 .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_dir()))
858 .collect();
859 children.sort_by_key(|entry| entry.file_name());
860
861 for child in children {
862 let child_name = child.file_name().to_string_lossy().to_string();
863 if child_name.is_empty() {
864 continue;
865 }
866
867 let zone_name = format!("{}/{}", zone.name, child_name);
868 let child_pattern = format!("{}/**", join_relative_path(&discover_dir, &child_name));
869 let entry = zones_by_name
870 .entry(zone_name.clone())
871 .or_insert_with(|| BoundaryZone {
872 name: zone_name.clone(),
873 patterns: vec![],
874 auto_discover: vec![],
875 root: zone.root.clone(),
876 });
877 if !entry
878 .patterns
879 .iter()
880 .any(|pattern| pattern == &child_pattern)
881 {
882 entry.patterns.push(child_pattern);
883 }
884 first_source_index.entry(zone_name).or_insert(source_index);
885 }
886 }
887
888 let mut zones: Vec<_> = zones_by_name.into_values().collect();
889 zones.sort_by(|a, b| a.name.cmp(&b.name));
890 let source_indices: Vec<usize> = zones
891 .iter()
892 .map(|z| {
893 first_source_index
894 .get(z.name.as_str())
895 .copied()
896 .unwrap_or(0)
897 })
898 .collect();
899 DiscoveryOutcome {
900 zones,
901 source_indices,
902 had_invalid_path,
903 }
904}
905
906fn expand_rule_allow(
907 allow: &[String],
908 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
909) -> Vec<String> {
910 let mut expanded = Vec::new();
911 for zone in allow {
912 if let Some(expansion) = group_expansions.get(zone) {
913 expanded.extend(expansion.iter().cloned());
914 } else {
915 expanded.push(zone.clone());
916 }
917 }
918 dedupe_preserving_order(expanded)
919}
920
921fn expand_parent_fallback_allow(
922 allow: &[String],
923 from_zones: &[String],
924 parent_name: &str,
925) -> Vec<String> {
926 let mut expanded = allow.to_vec();
927 expanded.extend(
928 from_zones
929 .iter()
930 .filter(|from_zone| from_zone.as_str() != parent_name)
931 .cloned(),
932 );
933 dedupe_preserving_order(expanded)
934}
935
936fn expand_generated_child_allow(
937 allow: &[String],
938 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
939 source_group: &str,
940) -> Vec<String> {
941 let mut expanded = Vec::new();
942 for zone in allow {
943 if zone == source_group {
944 if group_expansions
945 .get(source_group)
946 .is_some_and(|from_zones| from_zones.iter().any(|from_zone| from_zone == zone))
947 {
948 expanded.push(zone.clone());
949 }
950 } else if let Some(expansion) = group_expansions.get(zone) {
951 expanded.extend(expansion.iter().cloned());
952 } else {
953 expanded.push(zone.clone());
954 }
955 }
956 dedupe_preserving_order(expanded)
957}
958
959fn dedupe_preserving_order(values: Vec<String>) -> Vec<String> {
960 let mut seen = rustc_hash::FxHashSet::default();
961 values
962 .into_iter()
963 .filter(|value| seen.insert(value.clone()))
964 .collect()
965}
966
967fn dedupe_rules_keep_last(rules: Vec<BoundaryRule>) -> Vec<BoundaryRule> {
968 let mut seen = rustc_hash::FxHashSet::default();
969 let mut deduped: Vec<_> = rules
970 .into_iter()
971 .rev()
972 .filter(|rule| seen.insert(rule.from.clone()))
973 .collect();
974 deduped.reverse();
975 deduped
976}
977
978impl ResolvedBoundaryConfig {
979 #[must_use]
981 pub fn is_empty(&self) -> bool {
982 self.zones.is_empty() && self.logical_groups.is_empty()
983 }
984
985 #[must_use]
987 pub fn classify_zone(&self, relative_path: &str) -> Option<&str> {
988 for zone in &self.zones {
989 let candidate: &str = match zone.root.as_deref() {
990 Some(root) if !root.is_empty() => {
991 let Some(stripped) = relative_path.strip_prefix(root) else {
992 continue;
993 };
994 stripped
995 }
996 _ => relative_path,
997 };
998 if zone.matchers.iter().any(|m| m.is_match(candidate)) {
999 return Some(&zone.name);
1000 }
1001 }
1002 None
1003 }
1004
1005 #[must_use]
1007 pub fn is_import_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
1008 if from_zone == to_zone {
1009 return true;
1010 }
1011
1012 let rule = self.rules.iter().find(|r| r.from_zone == from_zone);
1013
1014 match rule {
1015 None => true,
1016 Some(r) => r.allowed_zones.iter().any(|z| z == to_zone),
1017 }
1018 }
1019
1020 #[must_use]
1022 pub fn is_type_only_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
1023 let Some(rule) = self.rules.iter().find(|r| r.from_zone == from_zone) else {
1024 return false;
1025 };
1026 rule.allow_type_only_zones.iter().any(|z| z == to_zone)
1027 }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032 use super::*;
1033
1034 #[test]
1035 fn empty_config() {
1036 let config = BoundaryConfig::default();
1037 assert!(config.is_empty());
1038 assert!(config.validate_zone_references().is_empty());
1039 }
1040
1041 #[test]
1042 fn deserialize_json() {
1043 let json = r#"{
1044 "zones": [
1045 { "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
1046 { "name": "db", "patterns": ["src/db/**"] },
1047 { "name": "shared", "patterns": ["src/shared/**"] }
1048 ],
1049 "rules": [
1050 { "from": "ui", "allow": ["shared"] },
1051 { "from": "db", "allow": ["shared"] }
1052 ]
1053 }"#;
1054 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1055 assert_eq!(config.zones.len(), 3);
1056 assert_eq!(config.rules.len(), 2);
1057 assert_eq!(config.zones[0].name, "ui");
1058 assert_eq!(
1059 config.zones[0].patterns,
1060 vec!["src/components/**", "src/pages/**"]
1061 );
1062 assert_eq!(config.rules[0].from, "ui");
1063 assert_eq!(config.rules[0].allow, vec!["shared"]);
1064 }
1065
1066 #[test]
1067 fn deserialize_toml() {
1068 let toml_str = r#"
1069[[zones]]
1070name = "ui"
1071patterns = ["src/components/**"]
1072
1073[[zones]]
1074name = "db"
1075patterns = ["src/db/**"]
1076
1077[[rules]]
1078from = "ui"
1079allow = ["db"]
1080"#;
1081 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
1082 assert_eq!(config.zones.len(), 2);
1083 assert_eq!(config.rules.len(), 1);
1084 }
1085
1086 #[test]
1087 fn auto_discover_expands_child_zones_and_parent_rules() {
1088 let temp = tempfile::tempdir().unwrap();
1089 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1090 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1091
1092 let mut config = BoundaryConfig {
1093 preset: None,
1094 zones: vec![
1095 BoundaryZone {
1096 name: "app".to_string(),
1097 patterns: vec!["src/app/**".to_string()],
1098 auto_discover: vec![],
1099 root: None,
1100 },
1101 BoundaryZone {
1102 name: "features".to_string(),
1103 patterns: vec![],
1104 auto_discover: vec!["src/features".to_string()],
1105 root: None,
1106 },
1107 ],
1108 rules: vec![
1109 BoundaryRule {
1110 from: "app".to_string(),
1111 allow: vec!["features".to_string()],
1112 allow_type_only: vec![],
1113 },
1114 BoundaryRule {
1115 from: "features".to_string(),
1116 allow: vec![],
1117 allow_type_only: vec![],
1118 },
1119 ],
1120 };
1121
1122 config.expand_auto_discover(temp.path());
1123
1124 let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
1125 assert_eq!(zone_names, vec!["app", "features/auth", "features/billing"]);
1126 assert_eq!(
1127 config.zones[1].patterns,
1128 vec!["src/features/auth/**".to_string()]
1129 );
1130 assert_eq!(
1131 config.zones[2].patterns,
1132 vec!["src/features/billing/**".to_string()]
1133 );
1134 let app_rule = config
1135 .rules
1136 .iter()
1137 .find(|rule| rule.from == "app")
1138 .expect("app rule should be preserved");
1139 assert_eq!(
1140 app_rule.allow,
1141 vec!["features/auth".to_string(), "features/billing".to_string()]
1142 );
1143 assert!(
1144 config
1145 .rules
1146 .iter()
1147 .any(|rule| rule.from == "features/auth" && rule.allow.is_empty())
1148 );
1149 assert!(
1150 config
1151 .rules
1152 .iter()
1153 .any(|rule| rule.from == "features/billing" && rule.allow.is_empty())
1154 );
1155 assert!(config.validate_zone_references().is_empty());
1156 }
1157
1158 #[test]
1159 fn auto_discover_parent_fallback_allows_children_without_relaxing_child_rules() {
1160 let temp = tempfile::tempdir().unwrap();
1161 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1162 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1163
1164 let mut config = BoundaryConfig {
1165 preset: None,
1166 zones: vec![
1167 BoundaryZone {
1168 name: "app".to_string(),
1169 patterns: vec!["src/app/**".to_string()],
1170 auto_discover: vec![],
1171 root: None,
1172 },
1173 BoundaryZone {
1174 name: "features".to_string(),
1175 patterns: vec!["src/features/**".to_string()],
1176 auto_discover: vec!["src/features".to_string()],
1177 root: None,
1178 },
1179 BoundaryZone {
1180 name: "shared".to_string(),
1181 patterns: vec!["src/shared/**".to_string()],
1182 auto_discover: vec![],
1183 root: None,
1184 },
1185 ],
1186 rules: vec![
1187 BoundaryRule {
1188 from: "app".to_string(),
1189 allow: vec!["features".to_string(), "shared".to_string()],
1190 allow_type_only: vec![],
1191 },
1192 BoundaryRule {
1193 from: "features".to_string(),
1194 allow: vec!["shared".to_string()],
1195 allow_type_only: vec![],
1196 },
1197 ],
1198 };
1199
1200 config.expand_auto_discover(temp.path());
1201
1202 let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
1203 assert_eq!(
1204 zone_names,
1205 vec![
1206 "app",
1207 "features/auth",
1208 "features/billing",
1209 "features",
1210 "shared"
1211 ]
1212 );
1213
1214 let app_rule = config
1215 .rules
1216 .iter()
1217 .find(|rule| rule.from == "app")
1218 .expect("app rule should be preserved");
1219 assert_eq!(
1220 app_rule.allow,
1221 vec![
1222 "features/auth".to_string(),
1223 "features/billing".to_string(),
1224 "features".to_string(),
1225 "shared".to_string()
1226 ]
1227 );
1228
1229 let parent_rule = config
1230 .rules
1231 .iter()
1232 .find(|rule| rule.from == "features")
1233 .expect("parent fallback rule should be preserved");
1234 assert_eq!(
1235 parent_rule.allow,
1236 vec![
1237 "shared".to_string(),
1238 "features/auth".to_string(),
1239 "features/billing".to_string()
1240 ]
1241 );
1242
1243 let auth_rule = config
1244 .rules
1245 .iter()
1246 .find(|rule| rule.from == "features/auth")
1247 .expect("auth child rule should be generated");
1248 assert_eq!(auth_rule.allow, vec!["shared".to_string()]);
1249
1250 let billing_rule = config
1251 .rules
1252 .iter()
1253 .find(|rule| rule.from == "features/billing")
1254 .expect("billing child rule should be generated");
1255 assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1256 assert!(config.validate_zone_references().is_empty());
1257 }
1258
1259 #[test]
1260 fn auto_discover_explicit_child_rule_wins_over_generated_parent_rule() {
1261 let temp = tempfile::tempdir().unwrap();
1262 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1263 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1264
1265 for explicit_child_first in [true, false] {
1266 let explicit_child_rule = BoundaryRule {
1267 from: "features/auth".to_string(),
1268 allow: vec!["shared".to_string(), "features/billing".to_string()],
1269 allow_type_only: vec![],
1270 };
1271 let parent_rule = BoundaryRule {
1272 from: "features".to_string(),
1273 allow: vec!["shared".to_string()],
1274 allow_type_only: vec![],
1275 };
1276 let rules = if explicit_child_first {
1277 vec![explicit_child_rule, parent_rule]
1278 } else {
1279 vec![parent_rule, explicit_child_rule]
1280 };
1281
1282 let mut config = BoundaryConfig {
1283 preset: None,
1284 zones: vec![
1285 BoundaryZone {
1286 name: "features".to_string(),
1287 patterns: vec![],
1288 auto_discover: vec!["src/features".to_string()],
1289 root: None,
1290 },
1291 BoundaryZone {
1292 name: "shared".to_string(),
1293 patterns: vec!["src/shared/**".to_string()],
1294 auto_discover: vec![],
1295 root: None,
1296 },
1297 ],
1298 rules,
1299 };
1300
1301 config.expand_auto_discover(temp.path());
1302
1303 let auth_rule = config
1304 .rules
1305 .iter()
1306 .find(|rule| rule.from == "features/auth")
1307 .expect("explicit child rule should remain");
1308 assert_eq!(
1309 auth_rule.allow,
1310 vec!["shared".to_string(), "features/billing".to_string()],
1311 "explicit child rule should win regardless of rule order"
1312 );
1313
1314 let billing_rule = config
1315 .rules
1316 .iter()
1317 .find(|rule| rule.from == "features/billing")
1318 .expect("parent rule should still generate sibling child rule");
1319 assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1320 assert!(config.validate_zone_references().is_empty());
1321 }
1322 }
1323
1324 #[test]
1325 fn logical_groups_returned_for_simple_auto_discover_zone() {
1326 let temp = tempfile::tempdir().unwrap();
1327 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1328 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1329
1330 let mut config = BoundaryConfig {
1331 preset: None,
1332 zones: vec![
1333 BoundaryZone {
1334 name: "app".to_string(),
1335 patterns: vec!["src/app/**".to_string()],
1336 auto_discover: vec![],
1337 root: None,
1338 },
1339 BoundaryZone {
1340 name: "features".to_string(),
1341 patterns: vec![],
1342 auto_discover: vec!["src/features".to_string()],
1343 root: None,
1344 },
1345 ],
1346 rules: vec![BoundaryRule {
1347 from: "features".to_string(),
1348 allow: vec!["app".to_string()],
1349 allow_type_only: vec![],
1350 }],
1351 };
1352
1353 let groups = config.expand_auto_discover(temp.path());
1354 assert_eq!(groups.len(), 1);
1355 let g = &groups[0];
1356 assert_eq!(g.name, "features");
1357 assert_eq!(g.children, vec!["features/auth", "features/billing"]);
1358 assert_eq!(g.auto_discover, vec!["src/features"]);
1359 assert_eq!(g.source_zone_index, 1);
1360 assert_eq!(g.status, LogicalGroupStatus::Ok);
1361 assert!(g.fallback_zone.is_none());
1362 let rule = g
1363 .authored_rule
1364 .as_ref()
1365 .expect("authored rule preserved verbatim");
1366 assert_eq!(rule.allow, vec!["app"]);
1367 assert!(rule.allow_type_only.is_empty());
1368 }
1369
1370 #[test]
1371 fn logical_groups_preserve_verbatim_auto_discover_strings() {
1372 let temp = tempfile::tempdir().unwrap();
1373 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1374
1375 let mut config = BoundaryConfig {
1376 preset: None,
1377 zones: vec![BoundaryZone {
1378 name: "features".to_string(),
1379 patterns: vec![],
1380 auto_discover: vec!["./src/features/".to_string()],
1381 root: None,
1382 }],
1383 rules: vec![],
1384 };
1385
1386 let groups = config.expand_auto_discover(temp.path());
1387 assert_eq!(groups.len(), 1);
1388 assert_eq!(groups[0].auto_discover, vec!["./src/features/"]);
1389 assert_eq!(groups[0].children, vec!["features/auth"]);
1390 }
1391
1392 #[test]
1393 fn logical_groups_bulletproof_keeps_fallback_zone_cross_reference() {
1394 let temp = tempfile::tempdir().unwrap();
1395 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1396
1397 let mut config = BoundaryConfig {
1398 preset: None,
1399 zones: vec![BoundaryZone {
1400 name: "features".to_string(),
1401 patterns: vec!["src/features/**".to_string()],
1402 auto_discover: vec!["src/features".to_string()],
1403 root: None,
1404 }],
1405 rules: vec![],
1406 };
1407
1408 let groups = config.expand_auto_discover(temp.path());
1409 assert_eq!(groups.len(), 1);
1410 assert_eq!(groups[0].fallback_zone.as_deref(), Some("features"));
1411 assert!(config.zones.iter().any(|z| z.name == "features"));
1412 }
1413
1414 #[test]
1415 fn logical_groups_status_empty_when_no_child_dirs() {
1416 let temp = tempfile::tempdir().unwrap();
1417 std::fs::create_dir_all(temp.path().join("src/features")).unwrap();
1418 let mut config = BoundaryConfig {
1419 preset: None,
1420 zones: vec![BoundaryZone {
1421 name: "features".to_string(),
1422 patterns: vec![],
1423 auto_discover: vec!["src/features".to_string()],
1424 root: None,
1425 }],
1426 rules: vec![],
1427 };
1428
1429 let groups = config.expand_auto_discover(temp.path());
1430 assert_eq!(groups.len(), 1);
1431 assert_eq!(groups[0].status, LogicalGroupStatus::Empty);
1432 assert!(groups[0].children.is_empty());
1433 }
1434
1435 #[test]
1436 fn logical_groups_status_invalid_path_when_dir_missing() {
1437 let temp = tempfile::tempdir().unwrap();
1438 let mut config = BoundaryConfig {
1439 preset: None,
1440 zones: vec![BoundaryZone {
1441 name: "features".to_string(),
1442 patterns: vec![],
1443 auto_discover: vec!["src/features".to_string()],
1444 root: None,
1445 }],
1446 rules: vec![],
1447 };
1448
1449 let groups = config.expand_auto_discover(temp.path());
1450 assert_eq!(groups.len(), 1);
1451 assert_eq!(groups[0].status, LogicalGroupStatus::InvalidPath);
1452 assert!(groups[0].children.is_empty());
1453 }
1454
1455 #[test]
1456 fn logical_groups_status_ok_wins_over_invalid_when_mixed() {
1457 let temp = tempfile::tempdir().unwrap();
1458 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1459 let mut config = BoundaryConfig {
1460 preset: None,
1461 zones: vec![BoundaryZone {
1462 name: "features".to_string(),
1463 patterns: vec![],
1464 auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
1465 root: None,
1466 }],
1467 rules: vec![],
1468 };
1469
1470 let groups = config.expand_auto_discover(temp.path());
1471 assert_eq!(groups.len(), 1);
1472 assert_eq!(groups[0].status, LogicalGroupStatus::Ok);
1473 assert_eq!(groups[0].children, vec!["features/auth"]);
1474 }
1475
1476 #[test]
1477 fn logical_groups_preserve_declaration_order() {
1478 let temp = tempfile::tempdir().unwrap();
1479 std::fs::create_dir_all(temp.path().join("src/zeta/a")).unwrap();
1480 std::fs::create_dir_all(temp.path().join("src/alpha/a")).unwrap();
1481 std::fs::create_dir_all(temp.path().join("src/mid/a")).unwrap();
1482
1483 let mut config = BoundaryConfig {
1484 preset: None,
1485 zones: vec![
1486 BoundaryZone {
1487 name: "zeta".to_string(),
1488 patterns: vec![],
1489 auto_discover: vec!["src/zeta".to_string()],
1490 root: None,
1491 },
1492 BoundaryZone {
1493 name: "alpha".to_string(),
1494 patterns: vec![],
1495 auto_discover: vec!["src/alpha".to_string()],
1496 root: None,
1497 },
1498 BoundaryZone {
1499 name: "mid".to_string(),
1500 patterns: vec![],
1501 auto_discover: vec!["src/mid".to_string()],
1502 root: None,
1503 },
1504 ],
1505 rules: vec![],
1506 };
1507
1508 let groups = config.expand_auto_discover(temp.path());
1509 let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
1510 assert_eq!(names, vec!["zeta", "alpha", "mid"]);
1511 }
1512
1513 #[test]
1514 fn logical_groups_merged_from_records_duplicate_indices() {
1515 let temp = tempfile::tempdir().unwrap();
1516 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1517 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
1518
1519 let mut config = BoundaryConfig {
1520 preset: None,
1521 zones: vec![
1522 BoundaryZone {
1523 name: "features".to_string(),
1524 patterns: vec![],
1525 auto_discover: vec!["src/features".to_string()],
1526 root: None,
1527 },
1528 BoundaryZone {
1529 name: "other".to_string(),
1530 patterns: vec!["src/other/**".to_string()],
1531 auto_discover: vec![],
1532 root: None,
1533 },
1534 BoundaryZone {
1535 name: "features".to_string(),
1536 patterns: vec![],
1537 auto_discover: vec!["src/extra".to_string()],
1538 root: None,
1539 },
1540 ],
1541 rules: vec![],
1542 };
1543 let groups = config.expand_auto_discover(temp.path());
1544 assert_eq!(groups.len(), 1);
1545 assert_eq!(groups[0].merged_from.as_deref(), Some(&[0_usize, 2][..]));
1546 assert_eq!(groups[0].source_zone_index, 0);
1547 }
1548
1549 #[test]
1550 fn logical_groups_merged_from_none_on_single_declaration() {
1551 let temp = tempfile::tempdir().unwrap();
1552 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1553
1554 let mut config = BoundaryConfig {
1555 preset: None,
1556 zones: vec![BoundaryZone {
1557 name: "features".to_string(),
1558 patterns: vec![],
1559 auto_discover: vec!["src/features".to_string()],
1560 root: None,
1561 }],
1562 rules: vec![],
1563 };
1564 let groups = config.expand_auto_discover(temp.path());
1565 assert!(groups[0].merged_from.is_none());
1566 }
1567
1568 #[test]
1569 fn logical_groups_echo_original_zone_root() {
1570 let temp = tempfile::tempdir().unwrap();
1571 std::fs::create_dir_all(temp.path().join("packages/app/src/features/auth")).unwrap();
1572
1573 let mut config = BoundaryConfig {
1574 preset: None,
1575 zones: vec![BoundaryZone {
1576 name: "features".to_string(),
1577 patterns: vec![],
1578 auto_discover: vec!["src/features".to_string()],
1579 root: Some("packages/app/".to_string()),
1580 }],
1581 rules: vec![],
1582 };
1583 let groups = config.expand_auto_discover(temp.path());
1584 assert_eq!(
1585 groups[0].original_zone_root.as_deref(),
1586 Some("packages/app/")
1587 );
1588 }
1589
1590 #[test]
1591 fn logical_groups_original_zone_root_none_when_unset() {
1592 let temp = tempfile::tempdir().unwrap();
1593 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1594
1595 let mut config = BoundaryConfig {
1596 preset: None,
1597 zones: vec![BoundaryZone {
1598 name: "features".to_string(),
1599 patterns: vec![],
1600 auto_discover: vec!["src/features".to_string()],
1601 root: None,
1602 }],
1603 rules: vec![],
1604 };
1605 let groups = config.expand_auto_discover(temp.path());
1606 assert!(groups[0].original_zone_root.is_none());
1607 }
1608
1609 #[test]
1610 fn logical_groups_child_source_indices_populated_for_multi_path() {
1611 let temp = tempfile::tempdir().unwrap();
1612 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1613 std::fs::create_dir_all(temp.path().join("src/modules/billing")).unwrap();
1614
1615 let mut config = BoundaryConfig {
1616 preset: None,
1617 zones: vec![BoundaryZone {
1618 name: "features".to_string(),
1619 patterns: vec![],
1620 auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
1621 root: None,
1622 }],
1623 rules: vec![],
1624 };
1625 let groups = config.expand_auto_discover(temp.path());
1626 assert_eq!(
1627 groups[0].children,
1628 vec!["features/auth", "features/billing"]
1629 );
1630 assert_eq!(groups[0].child_source_indices, vec![0, 1]);
1631 }
1632
1633 #[test]
1634 fn logical_groups_child_source_indices_empty_for_single_path() {
1635 let temp = tempfile::tempdir().unwrap();
1636 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1637 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1638
1639 let mut config = BoundaryConfig {
1640 preset: None,
1641 zones: vec![BoundaryZone {
1642 name: "features".to_string(),
1643 patterns: vec![],
1644 auto_discover: vec!["src/features".to_string()],
1645 root: None,
1646 }],
1647 rules: vec![],
1648 };
1649 let groups = config.expand_auto_discover(temp.path());
1650 assert!(groups[0].child_source_indices.is_empty());
1651 }
1652
1653 #[test]
1654 fn logical_groups_child_source_indices_after_duplicate_merge_shifted() {
1655 let temp = tempfile::tempdir().unwrap();
1656 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1657 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
1658
1659 let mut config = BoundaryConfig {
1660 preset: None,
1661 zones: vec![
1662 BoundaryZone {
1663 name: "features".to_string(),
1664 patterns: vec![],
1665 auto_discover: vec!["src/features".to_string()],
1666 root: None,
1667 },
1668 BoundaryZone {
1669 name: "features".to_string(),
1670 patterns: vec![],
1671 auto_discover: vec!["src/extra".to_string()],
1672 root: None,
1673 },
1674 ],
1675 rules: vec![],
1676 };
1677 let groups = config.expand_auto_discover(temp.path());
1678 assert_eq!(groups.len(), 1);
1679 assert_eq!(groups[0].auto_discover, vec!["src/features", "src/extra"]);
1680 let auth_idx = groups[0]
1681 .children
1682 .iter()
1683 .position(|c| c == "features/auth")
1684 .unwrap();
1685 let billing_idx = groups[0]
1686 .children
1687 .iter()
1688 .position(|c| c == "features/billing")
1689 .unwrap();
1690 assert_eq!(groups[0].child_source_indices[auth_idx], 0);
1691 assert_eq!(groups[0].child_source_indices[billing_idx], 1);
1692 }
1693
1694 #[test]
1695 fn logical_groups_merge_duplicate_parent_zone_declarations() {
1696 let temp = tempfile::tempdir().unwrap();
1697 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1698 std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
1699
1700 let mut config = BoundaryConfig {
1701 preset: None,
1702 zones: vec![
1703 BoundaryZone {
1704 name: "features".to_string(),
1705 patterns: vec![],
1706 auto_discover: vec!["src/features".to_string()],
1707 root: None,
1708 },
1709 BoundaryZone {
1710 name: "features".to_string(),
1711 patterns: vec![],
1712 auto_discover: vec!["src/extra".to_string()],
1713 root: None,
1714 },
1715 ],
1716 rules: vec![],
1717 };
1718
1719 let groups = config.expand_auto_discover(temp.path());
1720 assert_eq!(groups.len(), 1);
1721 assert_eq!(groups[0].name, "features");
1722 assert_eq!(groups[0].auto_discover, vec!["src/features", "src/extra"]);
1723 assert!(groups[0].children.iter().any(|c| c == "features/auth"));
1724 assert!(groups[0].children.iter().any(|c| c == "features/billing"));
1725 assert_eq!(groups[0].source_zone_index, 0);
1726 }
1727
1728 #[test]
1729 fn logical_groups_duplicate_identical_declarations_no_double_count() {
1730 let temp = tempfile::tempdir().unwrap();
1731 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1732 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1733
1734 let mut config = BoundaryConfig {
1735 preset: None,
1736 zones: vec![
1737 BoundaryZone {
1738 name: "features".to_string(),
1739 patterns: vec![],
1740 auto_discover: vec!["src/features".to_string()],
1741 root: None,
1742 },
1743 BoundaryZone {
1744 name: "features".to_string(),
1745 patterns: vec![],
1746 auto_discover: vec!["src/features".to_string()],
1747 root: None,
1748 },
1749 ],
1750 rules: vec![],
1751 };
1752
1753 let groups = config.expand_auto_discover(temp.path());
1754 assert_eq!(groups.len(), 1);
1755 let zone_names: Vec<&str> = config.zones.iter().map(|z| z.name.as_str()).collect();
1756 assert_eq!(zone_names, vec!["features/auth", "features/billing"]);
1757 assert_eq!(
1758 groups[0].children,
1759 vec!["features/auth", "features/billing"]
1760 );
1761 assert_eq!(
1762 groups[0].auto_discover,
1763 vec!["src/features", "src/features"]
1764 );
1765 assert_eq!(groups[0].merged_from.as_deref(), Some(&[0_usize, 1][..]));
1766 }
1767
1768 #[test]
1769 fn logical_groups_empty_when_no_auto_discover_present() {
1770 let temp = tempfile::tempdir().unwrap();
1771 let mut config = BoundaryConfig {
1772 preset: None,
1773 zones: vec![BoundaryZone {
1774 name: "ui".to_string(),
1775 patterns: vec!["src/components/**".to_string()],
1776 auto_discover: vec![],
1777 root: None,
1778 }],
1779 rules: vec![],
1780 };
1781 let groups = config.expand_auto_discover(temp.path());
1782 assert!(groups.is_empty());
1783 }
1784
1785 #[test]
1786 fn logical_groups_propagate_through_resolve() {
1787 let temp = tempfile::tempdir().unwrap();
1788 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1789
1790 let mut config = BoundaryConfig {
1791 preset: None,
1792 zones: vec![BoundaryZone {
1793 name: "features".to_string(),
1794 patterns: vec![],
1795 auto_discover: vec!["src/features".to_string()],
1796 root: None,
1797 }],
1798 rules: vec![],
1799 };
1800 let groups = config.expand_auto_discover(temp.path());
1801 let mut resolved = config.resolve();
1802 resolved.logical_groups = groups;
1803 assert_eq!(resolved.logical_groups.len(), 1);
1804 assert_eq!(resolved.logical_groups[0].name, "features");
1805 assert_eq!(resolved.logical_groups[0].children, vec!["features/auth"]);
1806 }
1807
1808 #[test]
1809 fn validate_zone_references_valid() {
1810 let config = BoundaryConfig {
1811 preset: None,
1812 zones: vec![
1813 BoundaryZone {
1814 name: "ui".to_string(),
1815 patterns: vec![],
1816 auto_discover: vec![],
1817 root: None,
1818 },
1819 BoundaryZone {
1820 name: "db".to_string(),
1821 patterns: vec![],
1822 auto_discover: vec![],
1823 root: None,
1824 },
1825 ],
1826 rules: vec![BoundaryRule {
1827 from: "ui".to_string(),
1828 allow: vec!["db".to_string()],
1829 allow_type_only: vec![],
1830 }],
1831 };
1832 assert!(config.validate_zone_references().is_empty());
1833 }
1834
1835 #[test]
1836 fn validate_zone_references_invalid_from() {
1837 let config = BoundaryConfig {
1838 preset: None,
1839 zones: vec![BoundaryZone {
1840 name: "ui".to_string(),
1841 patterns: vec![],
1842 auto_discover: vec![],
1843 root: None,
1844 }],
1845 rules: vec![BoundaryRule {
1846 from: "nonexistent".to_string(),
1847 allow: vec!["ui".to_string()],
1848 allow_type_only: vec![],
1849 }],
1850 };
1851 let errors = config.validate_zone_references();
1852 assert_eq!(errors.len(), 1);
1853 assert_eq!(errors[0].zone_name, "nonexistent");
1854 assert_eq!(errors[0].kind, ZoneReferenceKind::From);
1855 assert_eq!(errors[0].rule_index, 0);
1856 }
1857
1858 #[test]
1859 fn validate_zone_references_invalid_allow() {
1860 let config = BoundaryConfig {
1861 preset: None,
1862 zones: vec![BoundaryZone {
1863 name: "ui".to_string(),
1864 patterns: vec![],
1865 auto_discover: vec![],
1866 root: None,
1867 }],
1868 rules: vec![BoundaryRule {
1869 from: "ui".to_string(),
1870 allow: vec!["nonexistent".to_string()],
1871 allow_type_only: vec![],
1872 }],
1873 };
1874 let errors = config.validate_zone_references();
1875 assert_eq!(errors.len(), 1);
1876 assert_eq!(errors[0].zone_name, "nonexistent");
1877 assert_eq!(errors[0].kind, ZoneReferenceKind::Allow);
1878 }
1879
1880 #[test]
1881 fn validate_zone_references_invalid_allow_type_only() {
1882 let config = BoundaryConfig {
1883 preset: None,
1884 zones: vec![BoundaryZone {
1885 name: "ui".to_string(),
1886 patterns: vec![],
1887 auto_discover: vec![],
1888 root: None,
1889 }],
1890 rules: vec![BoundaryRule {
1891 from: "ui".to_string(),
1892 allow: vec![],
1893 allow_type_only: vec!["nonexistent_type_zone".to_string()],
1894 }],
1895 };
1896 let errors = config.validate_zone_references();
1897 assert_eq!(errors.len(), 1, "got: {errors:?}");
1898 assert_eq!(errors[0].zone_name, "nonexistent_type_zone");
1899 assert_eq!(errors[0].kind, ZoneReferenceKind::AllowTypeOnly);
1900 }
1901
1902 #[test]
1903 fn resolve_and_classify() {
1904 let config = BoundaryConfig {
1905 preset: None,
1906 zones: vec![
1907 BoundaryZone {
1908 name: "ui".to_string(),
1909 patterns: vec!["src/components/**".to_string()],
1910 auto_discover: vec![],
1911 root: None,
1912 },
1913 BoundaryZone {
1914 name: "db".to_string(),
1915 patterns: vec!["src/db/**".to_string()],
1916 auto_discover: vec![],
1917 root: None,
1918 },
1919 ],
1920 rules: vec![],
1921 };
1922 let resolved = config.resolve();
1923 assert_eq!(
1924 resolved.classify_zone("src/components/Button.tsx"),
1925 Some("ui")
1926 );
1927 assert_eq!(resolved.classify_zone("src/db/queries.ts"), Some("db"));
1928 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
1929 }
1930
1931 #[test]
1932 fn first_match_wins() {
1933 let config = BoundaryConfig {
1934 preset: None,
1935 zones: vec![
1936 BoundaryZone {
1937 name: "specific".to_string(),
1938 patterns: vec!["src/shared/db-utils/**".to_string()],
1939 auto_discover: vec![],
1940 root: None,
1941 },
1942 BoundaryZone {
1943 name: "shared".to_string(),
1944 patterns: vec!["src/shared/**".to_string()],
1945 auto_discover: vec![],
1946 root: None,
1947 },
1948 ],
1949 rules: vec![],
1950 };
1951 let resolved = config.resolve();
1952 assert_eq!(
1953 resolved.classify_zone("src/shared/db-utils/pool.ts"),
1954 Some("specific")
1955 );
1956 assert_eq!(
1957 resolved.classify_zone("src/shared/helpers.ts"),
1958 Some("shared")
1959 );
1960 }
1961
1962 #[test]
1963 fn self_import_always_allowed() {
1964 let config = BoundaryConfig {
1965 preset: None,
1966 zones: vec![BoundaryZone {
1967 name: "ui".to_string(),
1968 patterns: vec![],
1969 auto_discover: vec![],
1970 root: None,
1971 }],
1972 rules: vec![BoundaryRule {
1973 from: "ui".to_string(),
1974 allow: vec![],
1975 allow_type_only: vec![],
1976 }],
1977 };
1978 let resolved = config.resolve();
1979 assert!(resolved.is_import_allowed("ui", "ui"));
1980 }
1981
1982 #[test]
1983 fn unrestricted_zone_allows_all() {
1984 let config = BoundaryConfig {
1985 preset: None,
1986 zones: vec![
1987 BoundaryZone {
1988 name: "shared".to_string(),
1989 patterns: vec![],
1990 auto_discover: vec![],
1991 root: None,
1992 },
1993 BoundaryZone {
1994 name: "db".to_string(),
1995 patterns: vec![],
1996 auto_discover: vec![],
1997 root: None,
1998 },
1999 ],
2000 rules: vec![],
2001 };
2002 let resolved = config.resolve();
2003 assert!(resolved.is_import_allowed("shared", "db"));
2004 }
2005
2006 #[test]
2007 fn restricted_zone_blocks_unlisted() {
2008 let config = BoundaryConfig {
2009 preset: None,
2010 zones: vec![
2011 BoundaryZone {
2012 name: "ui".to_string(),
2013 patterns: vec![],
2014 auto_discover: vec![],
2015 root: None,
2016 },
2017 BoundaryZone {
2018 name: "db".to_string(),
2019 patterns: vec![],
2020 auto_discover: vec![],
2021 root: None,
2022 },
2023 BoundaryZone {
2024 name: "shared".to_string(),
2025 patterns: vec![],
2026 auto_discover: vec![],
2027 root: None,
2028 },
2029 ],
2030 rules: vec![BoundaryRule {
2031 from: "ui".to_string(),
2032 allow: vec!["shared".to_string()],
2033 allow_type_only: vec![],
2034 }],
2035 };
2036 let resolved = config.resolve();
2037 assert!(resolved.is_import_allowed("ui", "shared"));
2038 assert!(!resolved.is_import_allowed("ui", "db"));
2039 }
2040
2041 #[test]
2042 fn empty_allow_blocks_all_except_self() {
2043 let config = BoundaryConfig {
2044 preset: None,
2045 zones: vec![
2046 BoundaryZone {
2047 name: "isolated".to_string(),
2048 patterns: vec![],
2049 auto_discover: vec![],
2050 root: None,
2051 },
2052 BoundaryZone {
2053 name: "other".to_string(),
2054 patterns: vec![],
2055 auto_discover: vec![],
2056 root: None,
2057 },
2058 ],
2059 rules: vec![BoundaryRule {
2060 from: "isolated".to_string(),
2061 allow: vec![],
2062 allow_type_only: vec![],
2063 }],
2064 };
2065 let resolved = config.resolve();
2066 assert!(resolved.is_import_allowed("isolated", "isolated"));
2067 assert!(!resolved.is_import_allowed("isolated", "other"));
2068 }
2069
2070 #[test]
2071 fn zone_root_filters_classification_to_subtree() {
2072 let config = BoundaryConfig {
2073 preset: None,
2074 zones: vec![
2075 BoundaryZone {
2076 name: "ui".to_string(),
2077 patterns: vec!["src/**".to_string()],
2078 auto_discover: vec![],
2079 root: Some("packages/app/".to_string()),
2080 },
2081 BoundaryZone {
2082 name: "domain".to_string(),
2083 patterns: vec!["src/**".to_string()],
2084 auto_discover: vec![],
2085 root: Some("packages/core/".to_string()),
2086 },
2087 ],
2088 rules: vec![],
2089 };
2090 let resolved = config.resolve();
2091 assert_eq!(
2092 resolved.classify_zone("packages/app/src/login.tsx"),
2093 Some("ui")
2094 );
2095 assert_eq!(
2096 resolved.classify_zone("packages/core/src/order.ts"),
2097 Some("domain")
2098 );
2099 assert_eq!(resolved.classify_zone("src/login.tsx"), None);
2100 assert_eq!(resolved.classify_zone("packages/utils/src/x.ts"), None);
2101 }
2102
2103 #[test]
2105 fn zone_root_is_case_sensitive() {
2106 let config = BoundaryConfig {
2107 preset: None,
2108 zones: vec![BoundaryZone {
2109 name: "ui".to_string(),
2110 patterns: vec!["src/**".to_string()],
2111 auto_discover: vec![],
2112 root: Some("packages/app/".to_string()),
2113 }],
2114 rules: vec![],
2115 };
2116 let resolved = config.resolve();
2117 assert_eq!(
2118 resolved.classify_zone("packages/app/src/login.tsx"),
2119 Some("ui"),
2120 "exact-case path classifies"
2121 );
2122 assert_eq!(
2123 resolved.classify_zone("packages/App/src/login.tsx"),
2124 None,
2125 "case-different path does not classify (root is case-sensitive)"
2126 );
2127 assert_eq!(
2128 resolved.classify_zone("Packages/app/src/login.tsx"),
2129 None,
2130 "case-different prefix does not classify"
2131 );
2132 }
2133
2134 #[test]
2135 fn zone_root_normalizes_trailing_slash_and_dot_prefix() {
2136 let config = BoundaryConfig {
2137 preset: None,
2138 zones: vec![
2139 BoundaryZone {
2140 name: "no-slash".to_string(),
2141 patterns: vec!["src/**".to_string()],
2142 auto_discover: vec![],
2143 root: Some("packages/app".to_string()),
2144 },
2145 BoundaryZone {
2146 name: "dot-prefixed".to_string(),
2147 patterns: vec!["src/**".to_string()],
2148 auto_discover: vec![],
2149 root: Some("./packages/lib/".to_string()),
2150 },
2151 ],
2152 rules: vec![],
2153 };
2154 let resolved = config.resolve();
2155 assert_eq!(resolved.zones[0].root.as_deref(), Some("packages/app/"));
2156 assert_eq!(resolved.zones[1].root.as_deref(), Some("packages/lib/"));
2157 assert_eq!(
2158 resolved.classify_zone("packages/app/src/x.ts"),
2159 Some("no-slash")
2160 );
2161 assert_eq!(
2162 resolved.classify_zone("packages/lib/src/x.ts"),
2163 Some("dot-prefixed")
2164 );
2165 }
2166
2167 #[test]
2168 fn validate_root_prefixes_flags_redundant_pattern() {
2169 let config = BoundaryConfig {
2170 preset: None,
2171 zones: vec![BoundaryZone {
2172 name: "ui".to_string(),
2173 patterns: vec!["packages/app/src/**".to_string()],
2174 auto_discover: vec![],
2175 root: Some("packages/app/".to_string()),
2176 }],
2177 rules: vec![],
2178 };
2179 let errors = config.validate_root_prefixes();
2180 assert_eq!(errors.len(), 1, "expected one redundant-prefix error");
2181 assert_eq!(errors[0].zone_name, "ui");
2182 assert_eq!(errors[0].pattern, "packages/app/src/**");
2183 assert_eq!(errors[0].root, "packages/app/");
2184 let rendered = ZoneValidationError::RedundantRootPrefix(errors[0].clone()).to_string();
2185 assert!(
2186 rendered.contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"),
2187 "Display should carry legacy tag: {rendered}"
2188 );
2189 assert!(
2190 rendered.contains("zone 'ui'"),
2191 "Display rendering: {rendered}"
2192 );
2193 assert!(
2194 rendered.contains("packages/app/src/**"),
2195 "Display rendering: {rendered}"
2196 );
2197 }
2198
2199 #[test]
2200 fn validate_root_prefixes_handles_unnormalized_root() {
2201 let config = BoundaryConfig {
2202 preset: None,
2203 zones: vec![BoundaryZone {
2204 name: "ui".to_string(),
2205 patterns: vec!["./packages/app/src/**".to_string()],
2206 auto_discover: vec![],
2207 root: Some("packages/app".to_string()),
2208 }],
2209 rules: vec![],
2210 };
2211 let errors = config.validate_root_prefixes();
2212 assert_eq!(errors.len(), 1);
2213 }
2214
2215 #[test]
2216 fn validate_root_prefixes_empty_when_no_overlap() {
2217 let config = BoundaryConfig {
2218 preset: None,
2219 zones: vec![BoundaryZone {
2220 name: "ui".to_string(),
2221 patterns: vec!["src/**".to_string()],
2222 auto_discover: vec![],
2223 root: Some("packages/app/".to_string()),
2224 }],
2225 rules: vec![],
2226 };
2227 assert!(config.validate_root_prefixes().is_empty());
2228 }
2229
2230 #[test]
2231 fn validate_root_prefixes_skips_zones_without_root() {
2232 let json = r#"{
2233 "zones": [{ "name": "ui", "patterns": ["src/**"] }],
2234 "rules": []
2235 }"#;
2236 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2237 assert!(config.validate_root_prefixes().is_empty());
2238 }
2239
2240 #[test]
2242 fn validate_root_prefixes_skips_empty_root() {
2243 for raw_root in ["", ".", "./"] {
2244 let config = BoundaryConfig {
2245 preset: None,
2246 zones: vec![BoundaryZone {
2247 name: "ui".to_string(),
2248 patterns: vec!["src/**".to_string(), "lib/**".to_string()],
2249 auto_discover: vec![],
2250 root: Some(raw_root.to_string()),
2251 }],
2252 rules: vec![],
2253 };
2254 let errors = config.validate_root_prefixes();
2255 assert!(
2256 errors.is_empty(),
2257 "empty-normalized root {raw_root:?} produced spurious errors: {errors:?}"
2258 );
2259 }
2260 }
2261
2262 #[test]
2263 fn deserialize_zone_with_root() {
2264 let json = r#"{
2265 "zones": [
2266 { "name": "ui", "patterns": ["src/**"], "root": "packages/app/" }
2267 ],
2268 "rules": []
2269 }"#;
2270 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2271 assert_eq!(config.zones[0].root.as_deref(), Some("packages/app/"));
2272 }
2273
2274 #[test]
2275 fn deserialize_preset_json() {
2276 let json = r#"{ "preset": "layered" }"#;
2277 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2278 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
2279 assert!(config.zones.is_empty());
2280 }
2281
2282 #[test]
2283 fn deserialize_preset_hexagonal_json() {
2284 let json = r#"{ "preset": "hexagonal" }"#;
2285 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2286 assert_eq!(config.preset, Some(BoundaryPreset::Hexagonal));
2287 }
2288
2289 #[test]
2290 fn deserialize_preset_feature_sliced_json() {
2291 let json = r#"{ "preset": "feature-sliced" }"#;
2292 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2293 assert_eq!(config.preset, Some(BoundaryPreset::FeatureSliced));
2294 }
2295
2296 #[test]
2297 fn deserialize_preset_toml() {
2298 let toml_str = r#"preset = "layered""#;
2299 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
2300 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
2301 }
2302
2303 #[test]
2304 fn deserialize_invalid_preset_rejected() {
2305 let json = r#"{ "preset": "invalid_preset" }"#;
2306 let result: Result<BoundaryConfig, _> = serde_json::from_str(json);
2307 assert!(result.is_err());
2308 }
2309
2310 #[test]
2311 fn preset_absent_by_default() {
2312 let config = BoundaryConfig::default();
2313 assert!(config.preset.is_none());
2314 assert!(config.is_empty());
2315 }
2316
2317 #[test]
2318 fn preset_makes_config_non_empty() {
2319 let config = BoundaryConfig {
2320 preset: Some(BoundaryPreset::Layered),
2321 zones: vec![],
2322 rules: vec![],
2323 };
2324 assert!(!config.is_empty());
2325 }
2326
2327 #[test]
2328 fn expand_layered_produces_four_zones() {
2329 let mut config = BoundaryConfig {
2330 preset: Some(BoundaryPreset::Layered),
2331 zones: vec![],
2332 rules: vec![],
2333 };
2334 config.expand("src");
2335 assert_eq!(config.zones.len(), 4);
2336 assert_eq!(config.rules.len(), 4);
2337 assert!(config.preset.is_none(), "preset cleared after expand");
2338 assert_eq!(config.zones[0].name, "presentation");
2339 assert_eq!(config.zones[0].patterns, vec!["src/presentation/**"]);
2340 }
2341
2342 #[test]
2343 fn expand_layered_rules_correct() {
2344 let mut config = BoundaryConfig {
2345 preset: Some(BoundaryPreset::Layered),
2346 zones: vec![],
2347 rules: vec![],
2348 };
2349 config.expand("src");
2350 let pres_rule = config
2351 .rules
2352 .iter()
2353 .find(|r| r.from == "presentation")
2354 .unwrap();
2355 assert_eq!(pres_rule.allow, vec!["application"]);
2356 let app_rule = config
2357 .rules
2358 .iter()
2359 .find(|r| r.from == "application")
2360 .unwrap();
2361 assert_eq!(app_rule.allow, vec!["domain"]);
2362 let dom_rule = config.rules.iter().find(|r| r.from == "domain").unwrap();
2363 assert!(dom_rule.allow.is_empty());
2364 let infra_rule = config
2365 .rules
2366 .iter()
2367 .find(|r| r.from == "infrastructure")
2368 .unwrap();
2369 assert_eq!(infra_rule.allow, vec!["domain", "application"]);
2370 }
2371
2372 #[test]
2373 fn expand_hexagonal_produces_three_zones() {
2374 let mut config = BoundaryConfig {
2375 preset: Some(BoundaryPreset::Hexagonal),
2376 zones: vec![],
2377 rules: vec![],
2378 };
2379 config.expand("src");
2380 assert_eq!(config.zones.len(), 3);
2381 assert_eq!(config.rules.len(), 3);
2382 assert_eq!(config.zones[0].name, "adapters");
2383 assert_eq!(config.zones[1].name, "ports");
2384 assert_eq!(config.zones[2].name, "domain");
2385 }
2386
2387 #[test]
2388 fn expand_feature_sliced_produces_six_zones() {
2389 let mut config = BoundaryConfig {
2390 preset: Some(BoundaryPreset::FeatureSliced),
2391 zones: vec![],
2392 rules: vec![],
2393 };
2394 config.expand("src");
2395 assert_eq!(config.zones.len(), 6);
2396 assert_eq!(config.rules.len(), 6);
2397 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
2398 assert_eq!(
2399 app_rule.allow,
2400 vec!["pages", "widgets", "features", "entities", "shared"]
2401 );
2402 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
2403 assert!(shared_rule.allow.is_empty());
2404 let ent_rule = config.rules.iter().find(|r| r.from == "entities").unwrap();
2405 assert_eq!(ent_rule.allow, vec!["shared"]);
2406 }
2407
2408 #[test]
2409 fn expand_bulletproof_produces_four_zones() {
2410 let mut config = BoundaryConfig {
2411 preset: Some(BoundaryPreset::Bulletproof),
2412 zones: vec![],
2413 rules: vec![],
2414 };
2415 config.expand("src");
2416 assert_eq!(config.zones.len(), 4);
2417 assert_eq!(config.rules.len(), 4);
2418 assert_eq!(config.zones[0].name, "app");
2419 assert_eq!(config.zones[1].name, "features");
2420 assert_eq!(config.zones[2].name, "shared");
2421 assert_eq!(config.zones[3].name, "server");
2422 assert!(config.zones[2].patterns.len() > 1);
2423 assert!(
2424 config.zones[2]
2425 .patterns
2426 .contains(&"src/components/**".to_string())
2427 );
2428 assert!(
2429 config.zones[2]
2430 .patterns
2431 .contains(&"src/hooks/**".to_string())
2432 );
2433 assert!(config.zones[2].patterns.contains(&"src/lib/**".to_string()));
2434 assert!(
2435 config.zones[2]
2436 .patterns
2437 .contains(&"src/providers/**".to_string())
2438 );
2439 }
2440
2441 #[test]
2442 fn expand_bulletproof_rules_correct() {
2443 let mut config = BoundaryConfig {
2444 preset: Some(BoundaryPreset::Bulletproof),
2445 zones: vec![],
2446 rules: vec![],
2447 };
2448 config.expand("src");
2449 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
2450 assert_eq!(app_rule.allow, vec!["features", "shared", "server"]);
2451 let feat_rule = config.rules.iter().find(|r| r.from == "features").unwrap();
2452 assert_eq!(feat_rule.allow, vec!["shared", "server"]);
2453 let srv_rule = config.rules.iter().find(|r| r.from == "server").unwrap();
2454 assert_eq!(srv_rule.allow, vec!["shared"]);
2455 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
2456 assert!(shared_rule.allow.is_empty());
2457 }
2458
2459 #[test]
2460 fn expand_bulletproof_then_resolve_classifies() {
2461 let mut config = BoundaryConfig {
2462 preset: Some(BoundaryPreset::Bulletproof),
2463 zones: vec![],
2464 rules: vec![],
2465 };
2466 config.expand("src");
2467 let resolved = config.resolve();
2468 assert_eq!(
2469 resolved.classify_zone("src/app/dashboard/page.tsx"),
2470 Some("app")
2471 );
2472 assert_eq!(
2473 resolved.classify_zone("src/features/auth/hooks/useAuth.ts"),
2474 Some("features"),
2475 "without expand_auto_discover, src/features/... falls back to the parent zone"
2476 );
2477 assert_eq!(
2478 resolved.classify_zone("src/components/Button/Button.tsx"),
2479 Some("shared")
2480 );
2481 assert_eq!(
2482 resolved.classify_zone("src/hooks/useFormatters.ts"),
2483 Some("shared")
2484 );
2485 assert_eq!(
2486 resolved.classify_zone("src/server/db/schema/users.ts"),
2487 Some("server")
2488 );
2489 assert!(resolved.is_import_allowed("features", "shared"));
2490 assert!(resolved.is_import_allowed("features", "server"));
2491 assert!(!resolved.is_import_allowed("features", "app"));
2492 assert!(!resolved.is_import_allowed("shared", "features"));
2493 assert!(!resolved.is_import_allowed("server", "features"));
2494 }
2495
2496 #[test]
2498 fn bulletproof_features_barrel_can_import_children() {
2499 let temp = tempfile::tempdir().unwrap();
2500 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2501 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
2502
2503 let mut config = BoundaryConfig {
2504 preset: Some(BoundaryPreset::Bulletproof),
2505 zones: vec![],
2506 rules: vec![],
2507 };
2508 config.expand("src");
2509 config.expand_auto_discover(temp.path());
2510 let resolved = config.resolve();
2511
2512 assert_eq!(
2513 resolved.classify_zone("src/features/index.ts"),
2514 Some("features"),
2515 "src/features/index.ts barrel should classify as the parent features zone"
2516 );
2517 assert_eq!(
2518 resolved.classify_zone("src/features/auth/login.ts"),
2519 Some("features/auth")
2520 );
2521 assert_eq!(
2522 resolved.classify_zone("src/features/billing/invoice.ts"),
2523 Some("features/billing")
2524 );
2525 assert!(resolved.is_import_allowed("features", "features/auth"));
2526 assert!(resolved.is_import_allowed("features", "features/billing"));
2527 assert!(!resolved.is_import_allowed("features/auth", "features/billing"));
2528 }
2529
2530 #[test]
2531 fn expand_uses_custom_source_root() {
2532 let mut config = BoundaryConfig {
2533 preset: Some(BoundaryPreset::Hexagonal),
2534 zones: vec![],
2535 rules: vec![],
2536 };
2537 config.expand("lib");
2538 assert_eq!(config.zones[0].patterns, vec!["lib/adapters/**"]);
2539 assert_eq!(config.zones[2].patterns, vec!["lib/domain/**"]);
2540 }
2541
2542 #[test]
2543 fn user_zone_replaces_preset_zone() {
2544 let mut config = BoundaryConfig {
2545 preset: Some(BoundaryPreset::Hexagonal),
2546 zones: vec![BoundaryZone {
2547 name: "domain".to_string(),
2548 patterns: vec!["src/core/**".to_string()],
2549 auto_discover: vec![],
2550 root: None,
2551 }],
2552 rules: vec![],
2553 };
2554 config.expand("src");
2555 assert_eq!(config.zones.len(), 3);
2556 let domain = config.zones.iter().find(|z| z.name == "domain").unwrap();
2557 assert_eq!(domain.patterns, vec!["src/core/**"]);
2558 }
2559
2560 #[test]
2561 fn user_zone_adds_to_preset() {
2562 let mut config = BoundaryConfig {
2563 preset: Some(BoundaryPreset::Hexagonal),
2564 zones: vec![BoundaryZone {
2565 name: "shared".to_string(),
2566 patterns: vec!["src/shared/**".to_string()],
2567 auto_discover: vec![],
2568 root: None,
2569 }],
2570 rules: vec![],
2571 };
2572 config.expand("src");
2573 assert_eq!(config.zones.len(), 4);
2574 assert!(config.zones.iter().any(|z| z.name == "shared"));
2575 }
2576
2577 #[test]
2578 fn user_rule_replaces_preset_rule() {
2579 let mut config = BoundaryConfig {
2580 preset: Some(BoundaryPreset::Hexagonal),
2581 zones: vec![],
2582 rules: vec![BoundaryRule {
2583 from: "adapters".to_string(),
2584 allow: vec!["ports".to_string(), "domain".to_string()],
2585 allow_type_only: vec![],
2586 }],
2587 };
2588 config.expand("src");
2589 let adapter_rule = config.rules.iter().find(|r| r.from == "adapters").unwrap();
2590 assert_eq!(adapter_rule.allow, vec!["ports", "domain"]);
2591 assert_eq!(
2592 config.rules.iter().filter(|r| r.from == "adapters").count(),
2593 1
2594 );
2595 }
2596
2597 #[test]
2598 fn expand_without_preset_is_noop() {
2599 let mut config = BoundaryConfig {
2600 preset: None,
2601 zones: vec![BoundaryZone {
2602 name: "ui".to_string(),
2603 patterns: vec!["src/ui/**".to_string()],
2604 auto_discover: vec![],
2605 root: None,
2606 }],
2607 rules: vec![],
2608 };
2609 config.expand("src");
2610 assert_eq!(config.zones.len(), 1);
2611 assert_eq!(config.zones[0].name, "ui");
2612 }
2613
2614 #[test]
2615 fn expand_then_validate_succeeds() {
2616 let mut config = BoundaryConfig {
2617 preset: Some(BoundaryPreset::Layered),
2618 zones: vec![],
2619 rules: vec![],
2620 };
2621 config.expand("src");
2622 assert!(config.validate_zone_references().is_empty());
2623 }
2624
2625 #[test]
2626 fn expand_then_resolve_classifies() {
2627 let mut config = BoundaryConfig {
2628 preset: Some(BoundaryPreset::Hexagonal),
2629 zones: vec![],
2630 rules: vec![],
2631 };
2632 config.expand("src");
2633 let resolved = config.resolve();
2634 assert_eq!(
2635 resolved.classify_zone("src/adapters/http/handler.ts"),
2636 Some("adapters")
2637 );
2638 assert_eq!(resolved.classify_zone("src/domain/user.ts"), Some("domain"));
2639 assert!(!resolved.is_import_allowed("adapters", "domain"));
2640 assert!(resolved.is_import_allowed("adapters", "ports"));
2641 }
2642
2643 #[test]
2644 fn preset_name_returns_correct_string() {
2645 let config = BoundaryConfig {
2646 preset: Some(BoundaryPreset::FeatureSliced),
2647 zones: vec![],
2648 rules: vec![],
2649 };
2650 assert_eq!(config.preset_name(), Some("feature-sliced"));
2651
2652 let empty = BoundaryConfig::default();
2653 assert_eq!(empty.preset_name(), None);
2654 }
2655
2656 #[test]
2657 fn preset_name_all_variants() {
2658 let cases = [
2659 (BoundaryPreset::Layered, "layered"),
2660 (BoundaryPreset::Hexagonal, "hexagonal"),
2661 (BoundaryPreset::FeatureSliced, "feature-sliced"),
2662 (BoundaryPreset::Bulletproof, "bulletproof"),
2663 ];
2664 for (preset, expected_name) in cases {
2665 let config = BoundaryConfig {
2666 preset: Some(preset),
2667 zones: vec![],
2668 rules: vec![],
2669 };
2670 assert_eq!(
2671 config.preset_name(),
2672 Some(expected_name),
2673 "preset_name() mismatch for variant"
2674 );
2675 }
2676 }
2677
2678 #[test]
2679 fn resolved_boundary_config_empty() {
2680 let resolved = ResolvedBoundaryConfig::default();
2681 assert!(resolved.is_empty());
2682 }
2683
2684 #[test]
2685 fn resolved_boundary_config_with_zones_not_empty() {
2686 let config = BoundaryConfig {
2687 preset: None,
2688 zones: vec![BoundaryZone {
2689 name: "ui".to_string(),
2690 patterns: vec!["src/ui/**".to_string()],
2691 auto_discover: vec![],
2692 root: None,
2693 }],
2694 rules: vec![],
2695 };
2696 let resolved = config.resolve();
2697 assert!(!resolved.is_empty());
2698 }
2699
2700 #[test]
2701 fn resolved_boundary_config_with_only_logical_groups_not_empty() {
2702 let resolved = ResolvedBoundaryConfig {
2703 zones: vec![],
2704 rules: vec![],
2705 logical_groups: vec![LogicalGroup {
2706 name: "features".to_string(),
2707 children: vec![],
2708 auto_discover: vec!["src/features".to_string()],
2709 authored_rule: None,
2710 fallback_zone: None,
2711 source_zone_index: 0,
2712 status: LogicalGroupStatus::Empty,
2713 merged_from: None,
2714 original_zone_root: None,
2715 child_source_indices: vec![],
2716 }],
2717 };
2718 assert!(!resolved.is_empty());
2719 }
2720
2721 #[test]
2722 fn boundary_config_with_only_rules_is_empty() {
2723 let config = BoundaryConfig {
2724 preset: None,
2725 zones: vec![],
2726 rules: vec![BoundaryRule {
2727 from: "ui".to_string(),
2728 allow: vec!["db".to_string()],
2729 allow_type_only: vec![],
2730 }],
2731 };
2732 assert!(config.is_empty());
2733 }
2734
2735 #[test]
2736 fn boundary_config_with_zones_not_empty() {
2737 let config = BoundaryConfig {
2738 preset: None,
2739 zones: vec![BoundaryZone {
2740 name: "ui".to_string(),
2741 patterns: vec![],
2742 auto_discover: vec![],
2743 root: None,
2744 }],
2745 rules: vec![],
2746 };
2747 assert!(!config.is_empty());
2748 }
2749
2750 #[test]
2751 fn zone_with_multiple_patterns_matches_any() {
2752 let config = BoundaryConfig {
2753 preset: None,
2754 zones: vec![BoundaryZone {
2755 name: "ui".to_string(),
2756 patterns: vec![
2757 "src/components/**".to_string(),
2758 "src/pages/**".to_string(),
2759 "src/views/**".to_string(),
2760 ],
2761 auto_discover: vec![],
2762 root: None,
2763 }],
2764 rules: vec![],
2765 };
2766 let resolved = config.resolve();
2767 assert_eq!(
2768 resolved.classify_zone("src/components/Button.tsx"),
2769 Some("ui")
2770 );
2771 assert_eq!(resolved.classify_zone("src/pages/Home.tsx"), Some("ui"));
2772 assert_eq!(
2773 resolved.classify_zone("src/views/Dashboard.tsx"),
2774 Some("ui")
2775 );
2776 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
2777 }
2778
2779 #[test]
2780 fn validate_zone_references_multiple_errors() {
2781 let config = BoundaryConfig {
2782 preset: None,
2783 zones: vec![BoundaryZone {
2784 name: "ui".to_string(),
2785 patterns: vec![],
2786 auto_discover: vec![],
2787 root: None,
2788 }],
2789 rules: vec![
2790 BoundaryRule {
2791 from: "nonexistent_from".to_string(),
2792 allow: vec!["nonexistent_allow".to_string()],
2793 allow_type_only: vec![],
2794 },
2795 BoundaryRule {
2796 from: "ui".to_string(),
2797 allow: vec!["also_nonexistent".to_string()],
2798 allow_type_only: vec![],
2799 },
2800 ],
2801 };
2802 let errors = config.validate_zone_references();
2803 assert_eq!(errors.len(), 3);
2804 }
2805
2806 #[test]
2807 fn expand_feature_sliced_with_custom_root() {
2808 let mut config = BoundaryConfig {
2809 preset: Some(BoundaryPreset::FeatureSliced),
2810 zones: vec![],
2811 rules: vec![],
2812 };
2813 config.expand("lib");
2814 assert_eq!(config.zones[0].patterns, vec!["lib/app/**"]);
2815 assert_eq!(config.zones[5].patterns, vec!["lib/shared/**"]);
2816 }
2817
2818 #[test]
2819 fn zone_not_in_rules_is_unrestricted() {
2820 let config = BoundaryConfig {
2821 preset: None,
2822 zones: vec![
2823 BoundaryZone {
2824 name: "a".to_string(),
2825 patterns: vec![],
2826 auto_discover: vec![],
2827 root: None,
2828 },
2829 BoundaryZone {
2830 name: "b".to_string(),
2831 patterns: vec![],
2832 auto_discover: vec![],
2833 root: None,
2834 },
2835 BoundaryZone {
2836 name: "c".to_string(),
2837 patterns: vec![],
2838 auto_discover: vec![],
2839 root: None,
2840 },
2841 ],
2842 rules: vec![BoundaryRule {
2843 from: "a".to_string(),
2844 allow: vec!["b".to_string()],
2845 allow_type_only: vec![],
2846 }],
2847 };
2848 let resolved = config.resolve();
2849 assert!(resolved.is_import_allowed("a", "b"));
2850 assert!(!resolved.is_import_allowed("a", "c"));
2851 assert!(resolved.is_import_allowed("b", "a"));
2852 assert!(resolved.is_import_allowed("b", "c"));
2853 assert!(resolved.is_import_allowed("c", "a"));
2854 }
2855
2856 #[test]
2857 fn boundary_preset_json_roundtrip() {
2858 let presets = [
2859 BoundaryPreset::Layered,
2860 BoundaryPreset::Hexagonal,
2861 BoundaryPreset::FeatureSliced,
2862 BoundaryPreset::Bulletproof,
2863 ];
2864 for preset in presets {
2865 let json = serde_json::to_string(&preset).unwrap();
2866 let restored: BoundaryPreset = serde_json::from_str(&json).unwrap();
2867 assert_eq!(restored, preset);
2868 }
2869 }
2870
2871 #[test]
2872 fn deserialize_preset_bulletproof_json() {
2873 let json = r#"{ "preset": "bulletproof" }"#;
2874 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2875 assert_eq!(config.preset, Some(BoundaryPreset::Bulletproof));
2876 }
2877
2878 #[test]
2879 #[should_panic(expected = "validated at config load time")]
2880 fn resolve_panics_on_unvalidated_invalid_zone_glob() {
2881 let config = BoundaryConfig {
2882 preset: None,
2883 zones: vec![BoundaryZone {
2884 name: "broken".to_string(),
2885 patterns: vec!["[invalid".to_string()],
2886 auto_discover: vec![],
2887 root: None,
2888 }],
2889 rules: vec![],
2890 };
2891 let _ = config.resolve();
2892 }
2893}