1use std::path::Path;
4use std::sync::{Mutex, OnceLock};
5
6use globset::Glob;
7use rustc_hash::FxHashSet;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11static AUTO_DISCOVER_PATTERNS_WARN_SEEN: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
17
18fn record_auto_discover_patterns_warn_seen(zone_name: &str) -> bool {
22 let seen = AUTO_DISCOVER_PATTERNS_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
23 seen.lock()
24 .map_or(true, |mut set| set.insert(zone_name.to_owned()))
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
43#[serde(rename_all = "kebab-case")]
44pub enum BoundaryPreset {
45 Layered,
48 Hexagonal,
50 FeatureSliced,
53 Bulletproof,
66}
67
68impl BoundaryPreset {
69 #[must_use]
74 pub fn default_config(&self, source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
75 match self {
76 Self::Layered => Self::layered_config(source_root),
77 Self::Hexagonal => Self::hexagonal_config(source_root),
78 Self::FeatureSliced => Self::feature_sliced_config(source_root),
79 Self::Bulletproof => Self::bulletproof_config(source_root),
80 }
81 }
82
83 fn zone(name: &str, source_root: &str) -> BoundaryZone {
84 BoundaryZone {
85 name: name.to_owned(),
86 patterns: vec![format!("{source_root}/{name}/**")],
87 auto_discover: vec![],
88 root: None,
89 }
90 }
91
92 fn rule(from: &str, allow: &[&str]) -> BoundaryRule {
93 BoundaryRule {
94 from: from.to_owned(),
95 allow: allow.iter().map(|s| (*s).to_owned()).collect(),
96 }
97 }
98
99 fn layered_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
100 let zones = vec![
101 Self::zone("presentation", source_root),
102 Self::zone("application", source_root),
103 Self::zone("domain", source_root),
104 Self::zone("infrastructure", source_root),
105 ];
106 let rules = vec![
107 Self::rule("presentation", &["application"]),
108 Self::rule("application", &["domain"]),
109 Self::rule("domain", &[]),
110 Self::rule("infrastructure", &["domain", "application"]),
111 ];
112 (zones, rules)
113 }
114
115 fn hexagonal_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
116 let zones = vec![
117 Self::zone("adapters", source_root),
118 Self::zone("ports", source_root),
119 Self::zone("domain", source_root),
120 ];
121 let rules = vec![
122 Self::rule("adapters", &["ports"]),
123 Self::rule("ports", &["domain"]),
124 Self::rule("domain", &[]),
125 ];
126 (zones, rules)
127 }
128
129 fn feature_sliced_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
130 let layer_names = ["app", "pages", "widgets", "features", "entities", "shared"];
131 let zones = layer_names
132 .iter()
133 .map(|name| Self::zone(name, source_root))
134 .collect();
135 let rules = layer_names
136 .iter()
137 .enumerate()
138 .map(|(i, name)| {
139 let below: Vec<&str> = layer_names[i + 1..].to_vec();
140 Self::rule(name, &below)
141 })
142 .collect();
143 (zones, rules)
144 }
145
146 fn bulletproof_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
147 let zones = vec![
148 Self::zone("app", source_root),
149 BoundaryZone {
150 name: "features".to_owned(),
157 patterns: vec![],
158 auto_discover: vec![format!("{source_root}/features")],
159 root: None,
160 },
161 BoundaryZone {
162 name: "shared".to_owned(),
163 patterns: [
164 "components",
165 "hooks",
166 "lib",
167 "utils",
168 "utilities",
169 "providers",
170 "shared",
171 "types",
172 "styles",
173 "i18n",
174 ]
175 .iter()
176 .map(|dir| format!("{source_root}/{dir}/**"))
177 .collect(),
178 auto_discover: vec![],
179 root: None,
180 },
181 Self::zone("server", source_root),
182 ];
183 let rules = vec![
184 Self::rule("app", &["features", "shared", "server"]),
185 Self::rule("features", &["shared", "server"]),
186 Self::rule("server", &["shared"]),
187 Self::rule("shared", &[]),
188 ];
189 (zones, rules)
190 }
191}
192
193#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
229#[serde(rename_all = "camelCase")]
230pub struct BoundaryConfig {
231 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub preset: Option<BoundaryPreset>,
240 #[serde(default)]
242 pub zones: Vec<BoundaryZone>,
243 #[serde(default)]
246 pub rules: Vec<BoundaryRule>,
247}
248
249#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
251#[serde(rename_all = "camelCase")]
252pub struct BoundaryZone {
253 pub name: String,
255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub patterns: Vec<String>,
259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
268 pub auto_discover: Vec<String>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub root: Option<String>,
290}
291
292#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
294#[serde(rename_all = "camelCase")]
295pub struct BoundaryRule {
296 pub from: String,
298 #[serde(default)]
301 pub allow: Vec<String>,
302}
303
304#[derive(Debug, Default)]
306pub struct ResolvedBoundaryConfig {
307 pub zones: Vec<ResolvedZone>,
309 pub rules: Vec<ResolvedBoundaryRule>,
311}
312
313#[derive(Debug)]
315pub struct ResolvedZone {
316 pub name: String,
318 pub matchers: Vec<globset::GlobMatcher>,
322 pub root: Option<String>,
328}
329
330#[derive(Debug)]
332pub struct ResolvedBoundaryRule {
333 pub from_zone: String,
335 pub allowed_zones: Vec<String>,
337}
338
339impl BoundaryConfig {
340 #[must_use]
342 pub fn is_empty(&self) -> bool {
343 self.preset.is_none() && self.zones.is_empty()
344 }
345
346 pub fn expand(&mut self, source_root: &str) {
356 let Some(preset) = self.preset.take() else {
357 return;
358 };
359
360 let (preset_zones, preset_rules) = preset.default_config(source_root);
361
362 let user_zone_names: rustc_hash::FxHashSet<&str> =
364 self.zones.iter().map(|z| z.name.as_str()).collect();
365
366 let mut merged_zones: Vec<BoundaryZone> = preset_zones
368 .into_iter()
369 .filter(|pz| {
370 if user_zone_names.contains(pz.name.as_str()) {
371 tracing::info!(
372 "boundary preset: user zone '{}' replaces preset zone",
373 pz.name
374 );
375 false
376 } else {
377 true
378 }
379 })
380 .collect();
381 merged_zones.append(&mut self.zones);
383 self.zones = merged_zones;
384
385 let user_rule_sources: rustc_hash::FxHashSet<&str> =
387 self.rules.iter().map(|r| r.from.as_str()).collect();
388
389 let mut merged_rules: Vec<BoundaryRule> = preset_rules
390 .into_iter()
391 .filter(|pr| {
392 if user_rule_sources.contains(pr.from.as_str()) {
393 tracing::info!(
394 "boundary preset: user rule for '{}' replaces preset rule",
395 pr.from
396 );
397 false
398 } else {
399 true
400 }
401 })
402 .collect();
403 merged_rules.append(&mut self.rules);
404 self.rules = merged_rules;
405 }
406
407 pub fn expand_auto_discover(&mut self, project_root: &Path) {
416 if self.zones.iter().all(|zone| zone.auto_discover.is_empty()) {
417 return;
418 }
419
420 let original_zones = std::mem::take(&mut self.zones);
421 let mut expanded_zones = Vec::new();
422 let mut group_expansions: rustc_hash::FxHashMap<String, Vec<String>> =
423 rustc_hash::FxHashMap::default();
424
425 for mut zone in original_zones {
426 if zone.auto_discover.is_empty() {
427 expanded_zones.push(zone);
428 continue;
429 }
430
431 let group_name = zone.name.clone();
432 let discovered_zones = discover_child_zones(project_root, &zone);
433 let mut expanded_names: Vec<String> = discovered_zones
434 .iter()
435 .map(|child| child.name.clone())
436 .collect();
437 expanded_zones.extend(discovered_zones);
438
439 if !zone.patterns.is_empty() {
440 if record_auto_discover_patterns_warn_seen(&group_name) {
448 tracing::warn!(
449 "boundary zone '{group_name}' sets BOTH `patterns` and `autoDiscover`. \
450 Top-level files matching the parent pattern fall back to zone '{group_name}' \
451 and may produce false-positive cross-zone violations when they re-export \
452 auto-discovered children (e.g. a `{group_name}/index.ts` barrel). \
453 Drop `patterns` to leave top-level files unclassified, or define explicit \
454 allow rules that include the discovered child zones."
455 );
456 }
457 expanded_names.push(group_name.clone());
458 zone.auto_discover.clear();
459 expanded_zones.push(zone);
460 }
461
462 if !expanded_names.is_empty() {
463 group_expansions
464 .entry(group_name)
465 .or_default()
466 .extend(expanded_names);
467 }
468 }
469
470 self.zones = expanded_zones;
471 if group_expansions.is_empty() {
472 return;
473 }
474
475 let original_rules = std::mem::take(&mut self.rules);
476 let mut generated_rules = Vec::new();
477 let mut explicit_rules = Vec::new();
478 for rule in original_rules {
479 let allow = expand_rule_allow(&rule.allow, &group_expansions);
480
481 if let Some(from_zones) = group_expansions.get(&rule.from) {
482 for from in from_zones {
483 let expanded_rule = BoundaryRule {
484 from: from.clone(),
485 allow: allow.clone(),
486 };
487 if from == &rule.from {
488 explicit_rules.push(expanded_rule);
489 } else {
490 generated_rules.push(expanded_rule);
491 }
492 }
493 } else {
494 explicit_rules.push(BoundaryRule {
495 from: rule.from,
496 allow,
497 });
498 }
499 }
500
501 let mut expanded_rules = dedupe_rules_keep_last(generated_rules);
502 expanded_rules.extend(dedupe_rules_keep_last(explicit_rules));
503 self.rules = dedupe_rules_keep_last(expanded_rules);
504 }
505
506 #[must_use]
508 pub fn preset_name(&self) -> Option<&str> {
509 self.preset.as_ref().map(|p| match p {
510 BoundaryPreset::Layered => "layered",
511 BoundaryPreset::Hexagonal => "hexagonal",
512 BoundaryPreset::FeatureSliced => "feature-sliced",
513 BoundaryPreset::Bulletproof => "bulletproof",
514 })
515 }
516
517 #[must_use]
523 pub fn validate_root_prefixes(&self) -> Vec<String> {
524 let mut errors = Vec::new();
525 for zone in &self.zones {
526 let Some(raw_root) = zone.root.as_deref() else {
527 continue;
528 };
529 let normalized = normalize_zone_root(raw_root);
530 if normalized.is_empty() {
535 continue;
536 }
537 for pattern in &zone.patterns {
538 let normalized_pattern = pattern.replace('\\', "/");
539 let stripped = normalized_pattern
540 .strip_prefix("./")
541 .unwrap_or(&normalized_pattern);
542 if stripped.starts_with(&normalized) {
543 errors.push(format!(
544 "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.",
545 zone.name, pattern, normalized
546 ));
547 }
548 }
549 }
550 errors
551 }
552
553 #[must_use]
556 pub fn validate_zone_references(&self) -> Vec<(usize, &str)> {
557 let zone_names: rustc_hash::FxHashSet<&str> =
558 self.zones.iter().map(|z| z.name.as_str()).collect();
559
560 let mut errors = Vec::new();
561 for (i, rule) in self.rules.iter().enumerate() {
562 if !zone_names.contains(rule.from.as_str()) {
563 errors.push((i, rule.from.as_str()));
564 }
565 for allowed in &rule.allow {
566 if !zone_names.contains(allowed.as_str()) {
567 errors.push((i, allowed.as_str()));
568 }
569 }
570 }
571 errors
572 }
573
574 #[must_use]
577 pub fn resolve(&self) -> ResolvedBoundaryConfig {
578 let zones = self
579 .zones
580 .iter()
581 .map(|zone| {
582 let matchers = zone
583 .patterns
584 .iter()
585 .filter_map(|pattern| match Glob::new(pattern) {
586 Ok(glob) => Some(glob.compile_matcher()),
587 Err(e) => {
588 tracing::warn!(
589 "invalid boundary zone glob pattern '{}' in zone '{}': {e}",
590 pattern,
591 zone.name
592 );
593 None
594 }
595 })
596 .collect();
597 let root = zone.root.as_deref().map(normalize_zone_root);
598 ResolvedZone {
599 name: zone.name.clone(),
600 matchers,
601 root,
602 }
603 })
604 .collect();
605
606 let rules = self
607 .rules
608 .iter()
609 .map(|rule| ResolvedBoundaryRule {
610 from_zone: rule.from.clone(),
611 allowed_zones: rule.allow.clone(),
612 })
613 .collect();
614
615 ResolvedBoundaryConfig { zones, rules }
616 }
617}
618
619fn normalize_zone_root(raw: &str) -> String {
624 let with_slashes = raw.replace('\\', "/");
625 let trimmed = with_slashes.trim_start_matches("./");
626 let no_dot = if trimmed == "." { "" } else { trimmed };
627 if no_dot.is_empty() {
628 String::new()
629 } else if no_dot.ends_with('/') {
630 no_dot.to_owned()
631 } else {
632 format!("{no_dot}/")
633 }
634}
635
636fn normalize_auto_discover_dir(raw: &str) -> Option<String> {
637 let with_slashes = raw.replace('\\', "/");
638 let trimmed = with_slashes.trim_start_matches("./").trim_end_matches('/');
639 if trimmed.starts_with('/') || trimmed.split('/').any(|part| part == "..") {
640 None
641 } else if trimmed == "." {
642 Some(String::new())
643 } else {
644 Some(trimmed.to_owned())
645 }
646}
647
648fn join_relative_path(prefix: &str, suffix: &str) -> String {
649 match (prefix.is_empty(), suffix.is_empty()) {
650 (true, true) => String::new(),
651 (true, false) => suffix.to_owned(),
652 (false, true) => prefix.trim_end_matches('/').to_owned(),
653 (false, false) => format!("{}/{}", prefix.trim_end_matches('/'), suffix),
654 }
655}
656
657fn discover_child_zones(project_root: &Path, zone: &BoundaryZone) -> Vec<BoundaryZone> {
658 let mut zones_by_name: rustc_hash::FxHashMap<String, BoundaryZone> =
659 rustc_hash::FxHashMap::default();
660 let normalized_root = zone
661 .root
662 .as_deref()
663 .map(normalize_zone_root)
664 .unwrap_or_default();
665
666 for raw_dir in &zone.auto_discover {
667 let Some(discover_dir) = normalize_auto_discover_dir(raw_dir) else {
668 tracing::warn!(
669 "invalid boundary autoDiscover path '{}' in zone '{}': paths must be project-relative and must not contain '..'",
670 raw_dir,
671 zone.name
672 );
673 continue;
674 };
675
676 let fs_relative = join_relative_path(&normalized_root, &discover_dir);
677 let absolute_dir = if fs_relative.is_empty() {
678 project_root.to_path_buf()
679 } else {
680 project_root.join(&fs_relative)
681 };
682 let Ok(entries) = std::fs::read_dir(&absolute_dir) else {
683 tracing::warn!(
684 "boundary zone '{}' autoDiscover path '{}' did not resolve to a readable directory",
685 zone.name,
686 raw_dir
687 );
688 continue;
689 };
690
691 let mut children: Vec<_> = entries
692 .filter_map(Result::ok)
693 .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_dir()))
694 .collect();
695 children.sort_by_key(|entry| entry.file_name());
696
697 for child in children {
698 let child_name = child.file_name().to_string_lossy().to_string();
699 if child_name.is_empty() {
700 continue;
701 }
702
703 let zone_name = format!("{}/{}", zone.name, child_name);
704 let child_pattern = format!("{}/**", join_relative_path(&discover_dir, &child_name));
705 let entry = zones_by_name
706 .entry(zone_name.clone())
707 .or_insert_with(|| BoundaryZone {
708 name: zone_name,
709 patterns: vec![],
710 auto_discover: vec![],
711 root: zone.root.clone(),
712 });
713 if !entry
714 .patterns
715 .iter()
716 .any(|pattern| pattern == &child_pattern)
717 {
718 entry.patterns.push(child_pattern);
719 }
720 }
721 }
722
723 let mut zones: Vec<_> = zones_by_name.into_values().collect();
724 zones.sort_by(|a, b| a.name.cmp(&b.name));
725 zones
726}
727
728fn expand_rule_allow(
729 allow: &[String],
730 group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
731) -> Vec<String> {
732 let mut expanded = Vec::new();
733 for zone in allow {
734 if let Some(expansion) = group_expansions.get(zone) {
735 expanded.extend(expansion.iter().cloned());
736 } else {
737 expanded.push(zone.clone());
738 }
739 }
740 dedupe_preserving_order(expanded)
741}
742
743fn dedupe_preserving_order(values: Vec<String>) -> Vec<String> {
744 let mut seen = rustc_hash::FxHashSet::default();
745 values
746 .into_iter()
747 .filter(|value| seen.insert(value.clone()))
748 .collect()
749}
750
751fn dedupe_rules_keep_last(rules: Vec<BoundaryRule>) -> Vec<BoundaryRule> {
752 let mut seen = rustc_hash::FxHashSet::default();
753 let mut deduped: Vec<_> = rules
754 .into_iter()
755 .rev()
756 .filter(|rule| seen.insert(rule.from.clone()))
757 .collect();
758 deduped.reverse();
759 deduped
760}
761
762impl ResolvedBoundaryConfig {
763 #[must_use]
765 pub fn is_empty(&self) -> bool {
766 self.zones.is_empty()
767 }
768
769 #[must_use]
777 pub fn classify_zone(&self, relative_path: &str) -> Option<&str> {
778 for zone in &self.zones {
779 let candidate: &str = match zone.root.as_deref() {
780 Some(root) if !root.is_empty() => {
781 let Some(stripped) = relative_path.strip_prefix(root) else {
782 continue;
783 };
784 stripped
785 }
786 _ => relative_path,
787 };
788 if zone.matchers.iter().any(|m| m.is_match(candidate)) {
789 return Some(&zone.name);
790 }
791 }
792 None
793 }
794
795 #[must_use]
798 pub fn is_import_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
799 if from_zone == to_zone {
801 return true;
802 }
803
804 let rule = self.rules.iter().find(|r| r.from_zone == from_zone);
806
807 match rule {
808 None => true,
810 Some(r) => r.allowed_zones.iter().any(|z| z == to_zone),
812 }
813 }
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819
820 #[test]
821 fn empty_config() {
822 let config = BoundaryConfig::default();
823 assert!(config.is_empty());
824 assert!(config.validate_zone_references().is_empty());
825 }
826
827 #[test]
828 fn deserialize_json() {
829 let json = r#"{
830 "zones": [
831 { "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
832 { "name": "db", "patterns": ["src/db/**"] },
833 { "name": "shared", "patterns": ["src/shared/**"] }
834 ],
835 "rules": [
836 { "from": "ui", "allow": ["shared"] },
837 { "from": "db", "allow": ["shared"] }
838 ]
839 }"#;
840 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
841 assert_eq!(config.zones.len(), 3);
842 assert_eq!(config.rules.len(), 2);
843 assert_eq!(config.zones[0].name, "ui");
844 assert_eq!(
845 config.zones[0].patterns,
846 vec!["src/components/**", "src/pages/**"]
847 );
848 assert_eq!(config.rules[0].from, "ui");
849 assert_eq!(config.rules[0].allow, vec!["shared"]);
850 }
851
852 #[test]
853 fn deserialize_toml() {
854 let toml_str = r#"
855[[zones]]
856name = "ui"
857patterns = ["src/components/**"]
858
859[[zones]]
860name = "db"
861patterns = ["src/db/**"]
862
863[[rules]]
864from = "ui"
865allow = ["db"]
866"#;
867 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
868 assert_eq!(config.zones.len(), 2);
869 assert_eq!(config.rules.len(), 1);
870 }
871
872 #[test]
873 fn auto_discover_expands_child_zones_and_parent_rules() {
874 let temp = tempfile::tempdir().unwrap();
875 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
876 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
877
878 let mut config = BoundaryConfig {
879 preset: None,
880 zones: vec![
881 BoundaryZone {
882 name: "app".to_string(),
883 patterns: vec!["src/app/**".to_string()],
884 auto_discover: vec![],
885 root: None,
886 },
887 BoundaryZone {
888 name: "features".to_string(),
889 patterns: vec![],
890 auto_discover: vec!["src/features".to_string()],
891 root: None,
892 },
893 ],
894 rules: vec![
895 BoundaryRule {
896 from: "app".to_string(),
897 allow: vec!["features".to_string()],
898 },
899 BoundaryRule {
900 from: "features".to_string(),
901 allow: vec![],
902 },
903 ],
904 };
905
906 config.expand_auto_discover(temp.path());
907
908 let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
909 assert_eq!(zone_names, vec!["app", "features/auth", "features/billing"]);
910 assert_eq!(
911 config.zones[1].patterns,
912 vec!["src/features/auth/**".to_string()]
913 );
914 assert_eq!(
915 config.zones[2].patterns,
916 vec!["src/features/billing/**".to_string()]
917 );
918 let app_rule = config
919 .rules
920 .iter()
921 .find(|rule| rule.from == "app")
922 .expect("app rule should be preserved");
923 assert_eq!(
924 app_rule.allow,
925 vec!["features/auth".to_string(), "features/billing".to_string()]
926 );
927 assert!(
928 config
929 .rules
930 .iter()
931 .any(|rule| rule.from == "features/auth" && rule.allow.is_empty())
932 );
933 assert!(
934 config
935 .rules
936 .iter()
937 .any(|rule| rule.from == "features/billing" && rule.allow.is_empty())
938 );
939 assert!(config.validate_zone_references().is_empty());
940 }
941
942 #[test]
943 fn auto_discover_explicit_child_rule_wins_over_generated_parent_rule() {
944 let temp = tempfile::tempdir().unwrap();
945 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
946 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
947
948 for explicit_child_first in [true, false] {
949 let explicit_child_rule = BoundaryRule {
950 from: "features/auth".to_string(),
951 allow: vec!["shared".to_string(), "features/billing".to_string()],
952 };
953 let parent_rule = BoundaryRule {
954 from: "features".to_string(),
955 allow: vec!["shared".to_string()],
956 };
957 let rules = if explicit_child_first {
958 vec![explicit_child_rule, parent_rule]
959 } else {
960 vec![parent_rule, explicit_child_rule]
961 };
962
963 let mut config = BoundaryConfig {
964 preset: None,
965 zones: vec![
966 BoundaryZone {
967 name: "features".to_string(),
968 patterns: vec![],
969 auto_discover: vec!["src/features".to_string()],
970 root: None,
971 },
972 BoundaryZone {
973 name: "shared".to_string(),
974 patterns: vec!["src/shared/**".to_string()],
975 auto_discover: vec![],
976 root: None,
977 },
978 ],
979 rules,
980 };
981
982 config.expand_auto_discover(temp.path());
983
984 let auth_rule = config
985 .rules
986 .iter()
987 .find(|rule| rule.from == "features/auth")
988 .expect("explicit child rule should remain");
989 assert_eq!(
990 auth_rule.allow,
991 vec!["shared".to_string(), "features/billing".to_string()],
992 "explicit child rule should win regardless of rule order"
993 );
994
995 let billing_rule = config
996 .rules
997 .iter()
998 .find(|rule| rule.from == "features/billing")
999 .expect("parent rule should still generate sibling child rule");
1000 assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1001 assert!(config.validate_zone_references().is_empty());
1002 }
1003 }
1004
1005 #[test]
1006 fn validate_zone_references_valid() {
1007 let config = BoundaryConfig {
1008 preset: None,
1009 zones: vec![
1010 BoundaryZone {
1011 name: "ui".to_string(),
1012 patterns: vec![],
1013 auto_discover: vec![],
1014 root: None,
1015 },
1016 BoundaryZone {
1017 name: "db".to_string(),
1018 patterns: vec![],
1019 auto_discover: vec![],
1020 root: None,
1021 },
1022 ],
1023 rules: vec![BoundaryRule {
1024 from: "ui".to_string(),
1025 allow: vec!["db".to_string()],
1026 }],
1027 };
1028 assert!(config.validate_zone_references().is_empty());
1029 }
1030
1031 #[test]
1032 fn validate_zone_references_invalid_from() {
1033 let config = BoundaryConfig {
1034 preset: None,
1035 zones: vec![BoundaryZone {
1036 name: "ui".to_string(),
1037 patterns: vec![],
1038 auto_discover: vec![],
1039 root: None,
1040 }],
1041 rules: vec![BoundaryRule {
1042 from: "nonexistent".to_string(),
1043 allow: vec!["ui".to_string()],
1044 }],
1045 };
1046 let errors = config.validate_zone_references();
1047 assert_eq!(errors.len(), 1);
1048 assert_eq!(errors[0].1, "nonexistent");
1049 }
1050
1051 #[test]
1052 fn validate_zone_references_invalid_allow() {
1053 let config = BoundaryConfig {
1054 preset: None,
1055 zones: vec![BoundaryZone {
1056 name: "ui".to_string(),
1057 patterns: vec![],
1058 auto_discover: vec![],
1059 root: None,
1060 }],
1061 rules: vec![BoundaryRule {
1062 from: "ui".to_string(),
1063 allow: vec!["nonexistent".to_string()],
1064 }],
1065 };
1066 let errors = config.validate_zone_references();
1067 assert_eq!(errors.len(), 1);
1068 assert_eq!(errors[0].1, "nonexistent");
1069 }
1070
1071 #[test]
1072 fn resolve_and_classify() {
1073 let config = BoundaryConfig {
1074 preset: None,
1075 zones: vec![
1076 BoundaryZone {
1077 name: "ui".to_string(),
1078 patterns: vec!["src/components/**".to_string()],
1079 auto_discover: vec![],
1080 root: None,
1081 },
1082 BoundaryZone {
1083 name: "db".to_string(),
1084 patterns: vec!["src/db/**".to_string()],
1085 auto_discover: vec![],
1086 root: None,
1087 },
1088 ],
1089 rules: vec![],
1090 };
1091 let resolved = config.resolve();
1092 assert_eq!(
1093 resolved.classify_zone("src/components/Button.tsx"),
1094 Some("ui")
1095 );
1096 assert_eq!(resolved.classify_zone("src/db/queries.ts"), Some("db"));
1097 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
1098 }
1099
1100 #[test]
1101 fn first_match_wins() {
1102 let config = BoundaryConfig {
1103 preset: None,
1104 zones: vec![
1105 BoundaryZone {
1106 name: "specific".to_string(),
1107 patterns: vec!["src/shared/db-utils/**".to_string()],
1108 auto_discover: vec![],
1109 root: None,
1110 },
1111 BoundaryZone {
1112 name: "shared".to_string(),
1113 patterns: vec!["src/shared/**".to_string()],
1114 auto_discover: vec![],
1115 root: None,
1116 },
1117 ],
1118 rules: vec![],
1119 };
1120 let resolved = config.resolve();
1121 assert_eq!(
1122 resolved.classify_zone("src/shared/db-utils/pool.ts"),
1123 Some("specific")
1124 );
1125 assert_eq!(
1126 resolved.classify_zone("src/shared/helpers.ts"),
1127 Some("shared")
1128 );
1129 }
1130
1131 #[test]
1132 fn self_import_always_allowed() {
1133 let config = BoundaryConfig {
1134 preset: None,
1135 zones: vec![BoundaryZone {
1136 name: "ui".to_string(),
1137 patterns: vec![],
1138 auto_discover: vec![],
1139 root: None,
1140 }],
1141 rules: vec![BoundaryRule {
1142 from: "ui".to_string(),
1143 allow: vec![],
1144 }],
1145 };
1146 let resolved = config.resolve();
1147 assert!(resolved.is_import_allowed("ui", "ui"));
1148 }
1149
1150 #[test]
1151 fn unrestricted_zone_allows_all() {
1152 let config = BoundaryConfig {
1153 preset: None,
1154 zones: vec![
1155 BoundaryZone {
1156 name: "shared".to_string(),
1157 patterns: vec![],
1158 auto_discover: vec![],
1159 root: None,
1160 },
1161 BoundaryZone {
1162 name: "db".to_string(),
1163 patterns: vec![],
1164 auto_discover: vec![],
1165 root: None,
1166 },
1167 ],
1168 rules: vec![],
1169 };
1170 let resolved = config.resolve();
1171 assert!(resolved.is_import_allowed("shared", "db"));
1172 }
1173
1174 #[test]
1175 fn restricted_zone_blocks_unlisted() {
1176 let config = BoundaryConfig {
1177 preset: None,
1178 zones: vec![
1179 BoundaryZone {
1180 name: "ui".to_string(),
1181 patterns: vec![],
1182 auto_discover: vec![],
1183 root: None,
1184 },
1185 BoundaryZone {
1186 name: "db".to_string(),
1187 patterns: vec![],
1188 auto_discover: vec![],
1189 root: None,
1190 },
1191 BoundaryZone {
1192 name: "shared".to_string(),
1193 patterns: vec![],
1194 auto_discover: vec![],
1195 root: None,
1196 },
1197 ],
1198 rules: vec![BoundaryRule {
1199 from: "ui".to_string(),
1200 allow: vec!["shared".to_string()],
1201 }],
1202 };
1203 let resolved = config.resolve();
1204 assert!(resolved.is_import_allowed("ui", "shared"));
1205 assert!(!resolved.is_import_allowed("ui", "db"));
1206 }
1207
1208 #[test]
1209 fn empty_allow_blocks_all_except_self() {
1210 let config = BoundaryConfig {
1211 preset: None,
1212 zones: vec![
1213 BoundaryZone {
1214 name: "isolated".to_string(),
1215 patterns: vec![],
1216 auto_discover: vec![],
1217 root: None,
1218 },
1219 BoundaryZone {
1220 name: "other".to_string(),
1221 patterns: vec![],
1222 auto_discover: vec![],
1223 root: None,
1224 },
1225 ],
1226 rules: vec![BoundaryRule {
1227 from: "isolated".to_string(),
1228 allow: vec![],
1229 }],
1230 };
1231 let resolved = config.resolve();
1232 assert!(resolved.is_import_allowed("isolated", "isolated"));
1233 assert!(!resolved.is_import_allowed("isolated", "other"));
1234 }
1235
1236 #[test]
1237 fn zone_root_filters_classification_to_subtree() {
1238 let config = BoundaryConfig {
1239 preset: None,
1240 zones: vec![
1241 BoundaryZone {
1242 name: "ui".to_string(),
1243 patterns: vec!["src/**".to_string()],
1244 auto_discover: vec![],
1245 root: Some("packages/app/".to_string()),
1246 },
1247 BoundaryZone {
1248 name: "domain".to_string(),
1249 patterns: vec!["src/**".to_string()],
1250 auto_discover: vec![],
1251 root: Some("packages/core/".to_string()),
1252 },
1253 ],
1254 rules: vec![],
1255 };
1256 let resolved = config.resolve();
1257 assert_eq!(
1259 resolved.classify_zone("packages/app/src/login.tsx"),
1260 Some("ui")
1261 );
1262 assert_eq!(
1264 resolved.classify_zone("packages/core/src/order.ts"),
1265 Some("domain")
1266 );
1267 assert_eq!(resolved.classify_zone("src/login.tsx"), None);
1269 assert_eq!(resolved.classify_zone("packages/utils/src/x.ts"), None);
1270 }
1271
1272 #[test]
1279 fn zone_root_is_case_sensitive() {
1280 let config = BoundaryConfig {
1281 preset: None,
1282 zones: vec![BoundaryZone {
1283 name: "ui".to_string(),
1284 patterns: vec!["src/**".to_string()],
1285 auto_discover: vec![],
1286 root: Some("packages/app/".to_string()),
1287 }],
1288 rules: vec![],
1289 };
1290 let resolved = config.resolve();
1291 assert_eq!(
1292 resolved.classify_zone("packages/app/src/login.tsx"),
1293 Some("ui"),
1294 "exact-case path classifies"
1295 );
1296 assert_eq!(
1297 resolved.classify_zone("packages/App/src/login.tsx"),
1298 None,
1299 "case-different path does not classify (root is case-sensitive)"
1300 );
1301 assert_eq!(
1302 resolved.classify_zone("Packages/app/src/login.tsx"),
1303 None,
1304 "case-different prefix does not classify"
1305 );
1306 }
1307
1308 #[test]
1309 fn zone_root_normalizes_trailing_slash_and_dot_prefix() {
1310 let config = BoundaryConfig {
1311 preset: None,
1312 zones: vec![
1313 BoundaryZone {
1314 name: "no-slash".to_string(),
1315 patterns: vec!["src/**".to_string()],
1316 auto_discover: vec![],
1317 root: Some("packages/app".to_string()),
1318 },
1319 BoundaryZone {
1320 name: "dot-prefixed".to_string(),
1321 patterns: vec!["src/**".to_string()],
1322 auto_discover: vec![],
1323 root: Some("./packages/lib/".to_string()),
1324 },
1325 ],
1326 rules: vec![],
1327 };
1328 let resolved = config.resolve();
1329 assert_eq!(resolved.zones[0].root.as_deref(), Some("packages/app/"));
1330 assert_eq!(resolved.zones[1].root.as_deref(), Some("packages/lib/"));
1331 assert_eq!(
1332 resolved.classify_zone("packages/app/src/x.ts"),
1333 Some("no-slash")
1334 );
1335 assert_eq!(
1336 resolved.classify_zone("packages/lib/src/x.ts"),
1337 Some("dot-prefixed")
1338 );
1339 }
1340
1341 #[test]
1342 fn validate_root_prefixes_flags_redundant_pattern() {
1343 let config = BoundaryConfig {
1344 preset: None,
1345 zones: vec![BoundaryZone {
1346 name: "ui".to_string(),
1347 patterns: vec!["packages/app/src/**".to_string()],
1348 auto_discover: vec![],
1349 root: Some("packages/app/".to_string()),
1350 }],
1351 rules: vec![],
1352 };
1353 let errors = config.validate_root_prefixes();
1354 assert_eq!(errors.len(), 1, "expected one redundant-prefix error");
1355 assert!(
1356 errors[0].contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"),
1357 "error should be tagged: {}",
1358 errors[0]
1359 );
1360 assert!(
1361 errors[0].contains("zone 'ui'"),
1362 "error should name the zone: {}",
1363 errors[0]
1364 );
1365 assert!(
1366 errors[0].contains("packages/app/src/**"),
1367 "error should quote the pattern: {}",
1368 errors[0]
1369 );
1370 }
1371
1372 #[test]
1373 fn validate_root_prefixes_handles_unnormalized_root() {
1374 let config = BoundaryConfig {
1377 preset: None,
1378 zones: vec![BoundaryZone {
1379 name: "ui".to_string(),
1380 patterns: vec!["./packages/app/src/**".to_string()],
1381 auto_discover: vec![],
1382 root: Some("packages/app".to_string()),
1383 }],
1384 rules: vec![],
1385 };
1386 let errors = config.validate_root_prefixes();
1387 assert_eq!(errors.len(), 1);
1388 }
1389
1390 #[test]
1391 fn validate_root_prefixes_empty_when_no_overlap() {
1392 let config = BoundaryConfig {
1393 preset: None,
1394 zones: vec![BoundaryZone {
1395 name: "ui".to_string(),
1396 patterns: vec!["src/**".to_string()],
1397 auto_discover: vec![],
1398 root: Some("packages/app/".to_string()),
1399 }],
1400 rules: vec![],
1401 };
1402 assert!(config.validate_root_prefixes().is_empty());
1403 }
1404
1405 #[test]
1406 fn validate_root_prefixes_skips_zones_without_root() {
1407 let json = r#"{
1408 "zones": [{ "name": "ui", "patterns": ["src/**"] }],
1409 "rules": []
1410 }"#;
1411 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1412 assert!(config.validate_root_prefixes().is_empty());
1413 }
1414
1415 #[test]
1421 fn validate_root_prefixes_skips_empty_root() {
1422 for raw_root in ["", ".", "./"] {
1423 let config = BoundaryConfig {
1424 preset: None,
1425 zones: vec![BoundaryZone {
1426 name: "ui".to_string(),
1427 patterns: vec!["src/**".to_string(), "lib/**".to_string()],
1428 auto_discover: vec![],
1429 root: Some(raw_root.to_string()),
1430 }],
1431 rules: vec![],
1432 };
1433 let errors = config.validate_root_prefixes();
1434 assert!(
1435 errors.is_empty(),
1436 "empty-normalized root {raw_root:?} produced spurious errors: {errors:?}"
1437 );
1438 }
1439 }
1440
1441 #[test]
1442 fn deserialize_zone_with_root() {
1443 let json = r#"{
1444 "zones": [
1445 { "name": "ui", "patterns": ["src/**"], "root": "packages/app/" }
1446 ],
1447 "rules": []
1448 }"#;
1449 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1450 assert_eq!(config.zones[0].root.as_deref(), Some("packages/app/"));
1451 }
1452
1453 #[test]
1456 fn deserialize_preset_json() {
1457 let json = r#"{ "preset": "layered" }"#;
1458 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1459 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
1460 assert!(config.zones.is_empty());
1461 }
1462
1463 #[test]
1464 fn deserialize_preset_hexagonal_json() {
1465 let json = r#"{ "preset": "hexagonal" }"#;
1466 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1467 assert_eq!(config.preset, Some(BoundaryPreset::Hexagonal));
1468 }
1469
1470 #[test]
1471 fn deserialize_preset_feature_sliced_json() {
1472 let json = r#"{ "preset": "feature-sliced" }"#;
1473 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1474 assert_eq!(config.preset, Some(BoundaryPreset::FeatureSliced));
1475 }
1476
1477 #[test]
1478 fn deserialize_preset_toml() {
1479 let toml_str = r#"preset = "layered""#;
1480 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
1481 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
1482 }
1483
1484 #[test]
1485 fn deserialize_invalid_preset_rejected() {
1486 let json = r#"{ "preset": "invalid_preset" }"#;
1487 let result: Result<BoundaryConfig, _> = serde_json::from_str(json);
1488 assert!(result.is_err());
1489 }
1490
1491 #[test]
1492 fn preset_absent_by_default() {
1493 let config = BoundaryConfig::default();
1494 assert!(config.preset.is_none());
1495 assert!(config.is_empty());
1496 }
1497
1498 #[test]
1499 fn preset_makes_config_non_empty() {
1500 let config = BoundaryConfig {
1501 preset: Some(BoundaryPreset::Layered),
1502 zones: vec![],
1503 rules: vec![],
1504 };
1505 assert!(!config.is_empty());
1506 }
1507
1508 #[test]
1511 fn expand_layered_produces_four_zones() {
1512 let mut config = BoundaryConfig {
1513 preset: Some(BoundaryPreset::Layered),
1514 zones: vec![],
1515 rules: vec![],
1516 };
1517 config.expand("src");
1518 assert_eq!(config.zones.len(), 4);
1519 assert_eq!(config.rules.len(), 4);
1520 assert!(config.preset.is_none(), "preset cleared after expand");
1521 assert_eq!(config.zones[0].name, "presentation");
1522 assert_eq!(config.zones[0].patterns, vec!["src/presentation/**"]);
1523 }
1524
1525 #[test]
1526 fn expand_layered_rules_correct() {
1527 let mut config = BoundaryConfig {
1528 preset: Some(BoundaryPreset::Layered),
1529 zones: vec![],
1530 rules: vec![],
1531 };
1532 config.expand("src");
1533 let pres_rule = config
1535 .rules
1536 .iter()
1537 .find(|r| r.from == "presentation")
1538 .unwrap();
1539 assert_eq!(pres_rule.allow, vec!["application"]);
1540 let app_rule = config
1542 .rules
1543 .iter()
1544 .find(|r| r.from == "application")
1545 .unwrap();
1546 assert_eq!(app_rule.allow, vec!["domain"]);
1547 let dom_rule = config.rules.iter().find(|r| r.from == "domain").unwrap();
1549 assert!(dom_rule.allow.is_empty());
1550 let infra_rule = config
1552 .rules
1553 .iter()
1554 .find(|r| r.from == "infrastructure")
1555 .unwrap();
1556 assert_eq!(infra_rule.allow, vec!["domain", "application"]);
1557 }
1558
1559 #[test]
1560 fn expand_hexagonal_produces_three_zones() {
1561 let mut config = BoundaryConfig {
1562 preset: Some(BoundaryPreset::Hexagonal),
1563 zones: vec![],
1564 rules: vec![],
1565 };
1566 config.expand("src");
1567 assert_eq!(config.zones.len(), 3);
1568 assert_eq!(config.rules.len(), 3);
1569 assert_eq!(config.zones[0].name, "adapters");
1570 assert_eq!(config.zones[1].name, "ports");
1571 assert_eq!(config.zones[2].name, "domain");
1572 }
1573
1574 #[test]
1575 fn expand_feature_sliced_produces_six_zones() {
1576 let mut config = BoundaryConfig {
1577 preset: Some(BoundaryPreset::FeatureSliced),
1578 zones: vec![],
1579 rules: vec![],
1580 };
1581 config.expand("src");
1582 assert_eq!(config.zones.len(), 6);
1583 assert_eq!(config.rules.len(), 6);
1584 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
1586 assert_eq!(
1587 app_rule.allow,
1588 vec!["pages", "widgets", "features", "entities", "shared"]
1589 );
1590 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
1592 assert!(shared_rule.allow.is_empty());
1593 let ent_rule = config.rules.iter().find(|r| r.from == "entities").unwrap();
1595 assert_eq!(ent_rule.allow, vec!["shared"]);
1596 }
1597
1598 #[test]
1599 fn expand_bulletproof_produces_four_zones() {
1600 let mut config = BoundaryConfig {
1601 preset: Some(BoundaryPreset::Bulletproof),
1602 zones: vec![],
1603 rules: vec![],
1604 };
1605 config.expand("src");
1606 assert_eq!(config.zones.len(), 4);
1607 assert_eq!(config.rules.len(), 4);
1608 assert_eq!(config.zones[0].name, "app");
1609 assert_eq!(config.zones[1].name, "features");
1610 assert_eq!(config.zones[2].name, "shared");
1611 assert_eq!(config.zones[3].name, "server");
1612 assert!(config.zones[2].patterns.len() > 1);
1614 assert!(
1615 config.zones[2]
1616 .patterns
1617 .contains(&"src/components/**".to_string())
1618 );
1619 assert!(
1620 config.zones[2]
1621 .patterns
1622 .contains(&"src/hooks/**".to_string())
1623 );
1624 assert!(config.zones[2].patterns.contains(&"src/lib/**".to_string()));
1625 assert!(
1626 config.zones[2]
1627 .patterns
1628 .contains(&"src/providers/**".to_string())
1629 );
1630 }
1631
1632 #[test]
1633 fn expand_bulletproof_rules_correct() {
1634 let mut config = BoundaryConfig {
1635 preset: Some(BoundaryPreset::Bulletproof),
1636 zones: vec![],
1637 rules: vec![],
1638 };
1639 config.expand("src");
1640 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
1642 assert_eq!(app_rule.allow, vec!["features", "shared", "server"]);
1643 let feat_rule = config.rules.iter().find(|r| r.from == "features").unwrap();
1645 assert_eq!(feat_rule.allow, vec!["shared", "server"]);
1646 let srv_rule = config.rules.iter().find(|r| r.from == "server").unwrap();
1648 assert_eq!(srv_rule.allow, vec!["shared"]);
1649 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
1651 assert!(shared_rule.allow.is_empty());
1652 }
1653
1654 #[test]
1655 fn expand_bulletproof_then_resolve_classifies() {
1656 let mut config = BoundaryConfig {
1661 preset: Some(BoundaryPreset::Bulletproof),
1662 zones: vec![],
1663 rules: vec![],
1664 };
1665 config.expand("src");
1666 let resolved = config.resolve();
1667 assert_eq!(
1668 resolved.classify_zone("src/app/dashboard/page.tsx"),
1669 Some("app")
1670 );
1671 assert_eq!(
1672 resolved.classify_zone("src/features/auth/hooks/useAuth.ts"),
1673 None,
1674 "without expand_auto_discover, src/features/... is unclassified"
1675 );
1676 assert_eq!(
1677 resolved.classify_zone("src/components/Button/Button.tsx"),
1678 Some("shared")
1679 );
1680 assert_eq!(
1681 resolved.classify_zone("src/hooks/useFormatters.ts"),
1682 Some("shared")
1683 );
1684 assert_eq!(
1685 resolved.classify_zone("src/server/db/schema/users.ts"),
1686 Some("server")
1687 );
1688 assert!(resolved.is_import_allowed("features", "shared"));
1690 assert!(resolved.is_import_allowed("features", "server"));
1691 assert!(!resolved.is_import_allowed("features", "app"));
1692 assert!(!resolved.is_import_allowed("shared", "features"));
1693 assert!(!resolved.is_import_allowed("server", "features"));
1694 }
1695
1696 #[test]
1703 fn bulletproof_features_barrel_is_unclassified() {
1704 let temp = tempfile::tempdir().unwrap();
1705 std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1706 std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1707
1708 let mut config = BoundaryConfig {
1709 preset: Some(BoundaryPreset::Bulletproof),
1710 zones: vec![],
1711 rules: vec![],
1712 };
1713 config.expand("src");
1714 config.expand_auto_discover(temp.path());
1715 let resolved = config.resolve();
1716
1717 assert_eq!(
1719 resolved.classify_zone("src/features/index.ts"),
1720 None,
1721 "src/features/index.ts barrel must be unclassified to allow re-exporting children"
1722 );
1723 assert_eq!(
1725 resolved.classify_zone("src/features/auth/login.ts"),
1726 Some("features/auth")
1727 );
1728 assert_eq!(
1729 resolved.classify_zone("src/features/billing/invoice.ts"),
1730 Some("features/billing")
1731 );
1732 assert!(!resolved.is_import_allowed("features/auth", "features/billing"));
1734 }
1735
1736 #[test]
1737 fn expand_uses_custom_source_root() {
1738 let mut config = BoundaryConfig {
1739 preset: Some(BoundaryPreset::Hexagonal),
1740 zones: vec![],
1741 rules: vec![],
1742 };
1743 config.expand("lib");
1744 assert_eq!(config.zones[0].patterns, vec!["lib/adapters/**"]);
1745 assert_eq!(config.zones[2].patterns, vec!["lib/domain/**"]);
1746 }
1747
1748 #[test]
1751 fn user_zone_replaces_preset_zone() {
1752 let mut config = BoundaryConfig {
1753 preset: Some(BoundaryPreset::Hexagonal),
1754 zones: vec![BoundaryZone {
1755 name: "domain".to_string(),
1756 patterns: vec!["src/core/**".to_string()],
1757 auto_discover: vec![],
1758 root: None,
1759 }],
1760 rules: vec![],
1761 };
1762 config.expand("src");
1763 assert_eq!(config.zones.len(), 3);
1765 let domain = config.zones.iter().find(|z| z.name == "domain").unwrap();
1766 assert_eq!(domain.patterns, vec!["src/core/**"]);
1767 }
1768
1769 #[test]
1770 fn user_zone_adds_to_preset() {
1771 let mut config = BoundaryConfig {
1772 preset: Some(BoundaryPreset::Hexagonal),
1773 zones: vec![BoundaryZone {
1774 name: "shared".to_string(),
1775 patterns: vec!["src/shared/**".to_string()],
1776 auto_discover: vec![],
1777 root: None,
1778 }],
1779 rules: vec![],
1780 };
1781 config.expand("src");
1782 assert_eq!(config.zones.len(), 4); assert!(config.zones.iter().any(|z| z.name == "shared"));
1784 }
1785
1786 #[test]
1787 fn user_rule_replaces_preset_rule() {
1788 let mut config = BoundaryConfig {
1789 preset: Some(BoundaryPreset::Hexagonal),
1790 zones: vec![],
1791 rules: vec![BoundaryRule {
1792 from: "adapters".to_string(),
1793 allow: vec!["ports".to_string(), "domain".to_string()],
1794 }],
1795 };
1796 config.expand("src");
1797 let adapter_rule = config.rules.iter().find(|r| r.from == "adapters").unwrap();
1798 assert_eq!(adapter_rule.allow, vec!["ports", "domain"]);
1800 assert_eq!(
1802 config.rules.iter().filter(|r| r.from == "adapters").count(),
1803 1
1804 );
1805 }
1806
1807 #[test]
1808 fn expand_without_preset_is_noop() {
1809 let mut config = BoundaryConfig {
1810 preset: None,
1811 zones: vec![BoundaryZone {
1812 name: "ui".to_string(),
1813 patterns: vec!["src/ui/**".to_string()],
1814 auto_discover: vec![],
1815 root: None,
1816 }],
1817 rules: vec![],
1818 };
1819 config.expand("src");
1820 assert_eq!(config.zones.len(), 1);
1821 assert_eq!(config.zones[0].name, "ui");
1822 }
1823
1824 #[test]
1825 fn expand_then_validate_succeeds() {
1826 let mut config = BoundaryConfig {
1827 preset: Some(BoundaryPreset::Layered),
1828 zones: vec![],
1829 rules: vec![],
1830 };
1831 config.expand("src");
1832 assert!(config.validate_zone_references().is_empty());
1833 }
1834
1835 #[test]
1836 fn expand_then_resolve_classifies() {
1837 let mut config = BoundaryConfig {
1838 preset: Some(BoundaryPreset::Hexagonal),
1839 zones: vec![],
1840 rules: vec![],
1841 };
1842 config.expand("src");
1843 let resolved = config.resolve();
1844 assert_eq!(
1845 resolved.classify_zone("src/adapters/http/handler.ts"),
1846 Some("adapters")
1847 );
1848 assert_eq!(resolved.classify_zone("src/domain/user.ts"), Some("domain"));
1849 assert!(!resolved.is_import_allowed("adapters", "domain"));
1850 assert!(resolved.is_import_allowed("adapters", "ports"));
1851 }
1852
1853 #[test]
1854 fn preset_name_returns_correct_string() {
1855 let config = BoundaryConfig {
1856 preset: Some(BoundaryPreset::FeatureSliced),
1857 zones: vec![],
1858 rules: vec![],
1859 };
1860 assert_eq!(config.preset_name(), Some("feature-sliced"));
1861
1862 let empty = BoundaryConfig::default();
1863 assert_eq!(empty.preset_name(), None);
1864 }
1865
1866 #[test]
1867 fn preset_name_all_variants() {
1868 let cases = [
1869 (BoundaryPreset::Layered, "layered"),
1870 (BoundaryPreset::Hexagonal, "hexagonal"),
1871 (BoundaryPreset::FeatureSliced, "feature-sliced"),
1872 (BoundaryPreset::Bulletproof, "bulletproof"),
1873 ];
1874 for (preset, expected_name) in cases {
1875 let config = BoundaryConfig {
1876 preset: Some(preset),
1877 zones: vec![],
1878 rules: vec![],
1879 };
1880 assert_eq!(
1881 config.preset_name(),
1882 Some(expected_name),
1883 "preset_name() mismatch for variant"
1884 );
1885 }
1886 }
1887
1888 #[test]
1891 fn resolved_boundary_config_empty() {
1892 let resolved = ResolvedBoundaryConfig::default();
1893 assert!(resolved.is_empty());
1894 }
1895
1896 #[test]
1897 fn resolved_boundary_config_with_zones_not_empty() {
1898 let config = BoundaryConfig {
1899 preset: None,
1900 zones: vec![BoundaryZone {
1901 name: "ui".to_string(),
1902 patterns: vec!["src/ui/**".to_string()],
1903 auto_discover: vec![],
1904 root: None,
1905 }],
1906 rules: vec![],
1907 };
1908 let resolved = config.resolve();
1909 assert!(!resolved.is_empty());
1910 }
1911
1912 #[test]
1915 fn boundary_config_with_only_rules_is_empty() {
1916 let config = BoundaryConfig {
1919 preset: None,
1920 zones: vec![],
1921 rules: vec![BoundaryRule {
1922 from: "ui".to_string(),
1923 allow: vec!["db".to_string()],
1924 }],
1925 };
1926 assert!(config.is_empty());
1927 }
1928
1929 #[test]
1930 fn boundary_config_with_zones_not_empty() {
1931 let config = BoundaryConfig {
1932 preset: None,
1933 zones: vec![BoundaryZone {
1934 name: "ui".to_string(),
1935 patterns: vec![],
1936 auto_discover: vec![],
1937 root: None,
1938 }],
1939 rules: vec![],
1940 };
1941 assert!(!config.is_empty());
1942 }
1943
1944 #[test]
1947 fn zone_with_multiple_patterns_matches_any() {
1948 let config = BoundaryConfig {
1949 preset: None,
1950 zones: vec![BoundaryZone {
1951 name: "ui".to_string(),
1952 patterns: vec![
1953 "src/components/**".to_string(),
1954 "src/pages/**".to_string(),
1955 "src/views/**".to_string(),
1956 ],
1957 auto_discover: vec![],
1958 root: None,
1959 }],
1960 rules: vec![],
1961 };
1962 let resolved = config.resolve();
1963 assert_eq!(
1964 resolved.classify_zone("src/components/Button.tsx"),
1965 Some("ui")
1966 );
1967 assert_eq!(resolved.classify_zone("src/pages/Home.tsx"), Some("ui"));
1968 assert_eq!(
1969 resolved.classify_zone("src/views/Dashboard.tsx"),
1970 Some("ui")
1971 );
1972 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
1973 }
1974
1975 #[test]
1978 fn validate_zone_references_multiple_errors() {
1979 let config = BoundaryConfig {
1980 preset: None,
1981 zones: vec![BoundaryZone {
1982 name: "ui".to_string(),
1983 patterns: vec![],
1984 auto_discover: vec![],
1985 root: None,
1986 }],
1987 rules: vec![
1988 BoundaryRule {
1989 from: "nonexistent_from".to_string(),
1990 allow: vec!["nonexistent_allow".to_string()],
1991 },
1992 BoundaryRule {
1993 from: "ui".to_string(),
1994 allow: vec!["also_nonexistent".to_string()],
1995 },
1996 ],
1997 };
1998 let errors = config.validate_zone_references();
1999 assert_eq!(errors.len(), 3);
2002 }
2003
2004 #[test]
2007 fn expand_feature_sliced_with_custom_root() {
2008 let mut config = BoundaryConfig {
2009 preset: Some(BoundaryPreset::FeatureSliced),
2010 zones: vec![],
2011 rules: vec![],
2012 };
2013 config.expand("lib");
2014 assert_eq!(config.zones[0].patterns, vec!["lib/app/**"]);
2015 assert_eq!(config.zones[5].patterns, vec!["lib/shared/**"]);
2016 }
2017
2018 #[test]
2021 fn zone_not_in_rules_is_unrestricted() {
2022 let config = BoundaryConfig {
2023 preset: None,
2024 zones: vec![
2025 BoundaryZone {
2026 name: "a".to_string(),
2027 patterns: vec![],
2028 auto_discover: vec![],
2029 root: None,
2030 },
2031 BoundaryZone {
2032 name: "b".to_string(),
2033 patterns: vec![],
2034 auto_discover: vec![],
2035 root: None,
2036 },
2037 BoundaryZone {
2038 name: "c".to_string(),
2039 patterns: vec![],
2040 auto_discover: vec![],
2041 root: None,
2042 },
2043 ],
2044 rules: vec![BoundaryRule {
2045 from: "a".to_string(),
2046 allow: vec!["b".to_string()],
2047 }],
2048 };
2049 let resolved = config.resolve();
2050 assert!(resolved.is_import_allowed("a", "b"));
2052 assert!(!resolved.is_import_allowed("a", "c"));
2053 assert!(resolved.is_import_allowed("b", "a"));
2055 assert!(resolved.is_import_allowed("b", "c"));
2056 assert!(resolved.is_import_allowed("c", "a"));
2058 }
2059
2060 #[test]
2063 fn boundary_preset_json_roundtrip() {
2064 let presets = [
2065 BoundaryPreset::Layered,
2066 BoundaryPreset::Hexagonal,
2067 BoundaryPreset::FeatureSliced,
2068 BoundaryPreset::Bulletproof,
2069 ];
2070 for preset in presets {
2071 let json = serde_json::to_string(&preset).unwrap();
2072 let restored: BoundaryPreset = serde_json::from_str(&json).unwrap();
2073 assert_eq!(restored, preset);
2074 }
2075 }
2076
2077 #[test]
2078 fn deserialize_preset_bulletproof_json() {
2079 let json = r#"{ "preset": "bulletproof" }"#;
2080 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2081 assert_eq!(config.preset, Some(BoundaryPreset::Bulletproof));
2082 }
2083
2084 #[test]
2087 fn resolve_skips_invalid_zone_glob() {
2088 let config = BoundaryConfig {
2089 preset: None,
2090 zones: vec![BoundaryZone {
2091 name: "broken".to_string(),
2092 patterns: vec!["[invalid".to_string()],
2093 auto_discover: vec![],
2094 root: None,
2095 }],
2096 rules: vec![],
2097 };
2098 let resolved = config.resolve();
2099 assert!(!resolved.is_empty());
2101 assert_eq!(resolved.classify_zone("anything.ts"), None);
2102 }
2103}