1use globset::Glob;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
23#[serde(rename_all = "kebab-case")]
24pub enum BoundaryPreset {
25 Layered,
28 Hexagonal,
30 FeatureSliced,
33 Bulletproof,
37}
38
39impl BoundaryPreset {
40 #[must_use]
45 pub fn default_config(&self, source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
46 match self {
47 Self::Layered => Self::layered_config(source_root),
48 Self::Hexagonal => Self::hexagonal_config(source_root),
49 Self::FeatureSliced => Self::feature_sliced_config(source_root),
50 Self::Bulletproof => Self::bulletproof_config(source_root),
51 }
52 }
53
54 fn zone(name: &str, source_root: &str) -> BoundaryZone {
55 BoundaryZone {
56 name: name.to_owned(),
57 patterns: vec![format!("{source_root}/{name}/**")],
58 root: None,
59 }
60 }
61
62 fn rule(from: &str, allow: &[&str]) -> BoundaryRule {
63 BoundaryRule {
64 from: from.to_owned(),
65 allow: allow.iter().map(|s| (*s).to_owned()).collect(),
66 }
67 }
68
69 fn layered_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
70 let zones = vec![
71 Self::zone("presentation", source_root),
72 Self::zone("application", source_root),
73 Self::zone("domain", source_root),
74 Self::zone("infrastructure", source_root),
75 ];
76 let rules = vec![
77 Self::rule("presentation", &["application"]),
78 Self::rule("application", &["domain"]),
79 Self::rule("domain", &[]),
80 Self::rule("infrastructure", &["domain", "application"]),
81 ];
82 (zones, rules)
83 }
84
85 fn hexagonal_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
86 let zones = vec![
87 Self::zone("adapters", source_root),
88 Self::zone("ports", source_root),
89 Self::zone("domain", source_root),
90 ];
91 let rules = vec![
92 Self::rule("adapters", &["ports"]),
93 Self::rule("ports", &["domain"]),
94 Self::rule("domain", &[]),
95 ];
96 (zones, rules)
97 }
98
99 fn feature_sliced_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
100 let layer_names = ["app", "pages", "widgets", "features", "entities", "shared"];
101 let zones = layer_names
102 .iter()
103 .map(|name| Self::zone(name, source_root))
104 .collect();
105 let rules = layer_names
106 .iter()
107 .enumerate()
108 .map(|(i, name)| {
109 let below: Vec<&str> = layer_names[i + 1..].to_vec();
110 Self::rule(name, &below)
111 })
112 .collect();
113 (zones, rules)
114 }
115
116 fn bulletproof_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
117 let zones = vec![
118 Self::zone("app", source_root),
119 Self::zone("features", source_root),
120 BoundaryZone {
121 name: "shared".to_owned(),
122 patterns: [
123 "components",
124 "hooks",
125 "lib",
126 "utils",
127 "utilities",
128 "providers",
129 "shared",
130 "types",
131 "styles",
132 "i18n",
133 ]
134 .iter()
135 .map(|dir| format!("{source_root}/{dir}/**"))
136 .collect(),
137 root: None,
138 },
139 Self::zone("server", source_root),
140 ];
141 let rules = vec![
142 Self::rule("app", &["features", "shared", "server"]),
143 Self::rule("features", &["shared", "server"]),
144 Self::rule("server", &["shared"]),
145 Self::rule("shared", &[]),
146 ];
147 (zones, rules)
148 }
149}
150
151#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
187#[serde(rename_all = "camelCase")]
188pub struct BoundaryConfig {
189 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub preset: Option<BoundaryPreset>,
198 #[serde(default)]
200 pub zones: Vec<BoundaryZone>,
201 #[serde(default)]
204 pub rules: Vec<BoundaryRule>,
205}
206
207#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
209#[serde(rename_all = "camelCase")]
210pub struct BoundaryZone {
211 pub name: String,
213 pub patterns: Vec<String>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub root: Option<String>,
237}
238
239#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
241#[serde(rename_all = "camelCase")]
242pub struct BoundaryRule {
243 pub from: String,
245 #[serde(default)]
248 pub allow: Vec<String>,
249}
250
251#[derive(Debug, Default)]
253pub struct ResolvedBoundaryConfig {
254 pub zones: Vec<ResolvedZone>,
256 pub rules: Vec<ResolvedBoundaryRule>,
258}
259
260#[derive(Debug)]
262pub struct ResolvedZone {
263 pub name: String,
265 pub matchers: Vec<globset::GlobMatcher>,
269 pub root: Option<String>,
275}
276
277#[derive(Debug)]
279pub struct ResolvedBoundaryRule {
280 pub from_zone: String,
282 pub allowed_zones: Vec<String>,
284}
285
286impl BoundaryConfig {
287 #[must_use]
289 pub fn is_empty(&self) -> bool {
290 self.preset.is_none() && self.zones.is_empty()
291 }
292
293 pub fn expand(&mut self, source_root: &str) {
303 let Some(preset) = self.preset.take() else {
304 return;
305 };
306
307 let (preset_zones, preset_rules) = preset.default_config(source_root);
308
309 let user_zone_names: rustc_hash::FxHashSet<&str> =
311 self.zones.iter().map(|z| z.name.as_str()).collect();
312
313 let mut merged_zones: Vec<BoundaryZone> = preset_zones
315 .into_iter()
316 .filter(|pz| {
317 if user_zone_names.contains(pz.name.as_str()) {
318 tracing::info!(
319 "boundary preset: user zone '{}' replaces preset zone",
320 pz.name
321 );
322 false
323 } else {
324 true
325 }
326 })
327 .collect();
328 merged_zones.append(&mut self.zones);
330 self.zones = merged_zones;
331
332 let user_rule_sources: rustc_hash::FxHashSet<&str> =
334 self.rules.iter().map(|r| r.from.as_str()).collect();
335
336 let mut merged_rules: Vec<BoundaryRule> = preset_rules
337 .into_iter()
338 .filter(|pr| {
339 if user_rule_sources.contains(pr.from.as_str()) {
340 tracing::info!(
341 "boundary preset: user rule for '{}' replaces preset rule",
342 pr.from
343 );
344 false
345 } else {
346 true
347 }
348 })
349 .collect();
350 merged_rules.append(&mut self.rules);
351 self.rules = merged_rules;
352 }
353
354 #[must_use]
356 pub fn preset_name(&self) -> Option<&str> {
357 self.preset.as_ref().map(|p| match p {
358 BoundaryPreset::Layered => "layered",
359 BoundaryPreset::Hexagonal => "hexagonal",
360 BoundaryPreset::FeatureSliced => "feature-sliced",
361 BoundaryPreset::Bulletproof => "bulletproof",
362 })
363 }
364
365 #[must_use]
371 pub fn validate_root_prefixes(&self) -> Vec<String> {
372 let mut errors = Vec::new();
373 for zone in &self.zones {
374 let Some(raw_root) = zone.root.as_deref() else {
375 continue;
376 };
377 let normalized = normalize_zone_root(raw_root);
378 if normalized.is_empty() {
383 continue;
384 }
385 for pattern in &zone.patterns {
386 let normalized_pattern = pattern.replace('\\', "/");
387 let stripped = normalized_pattern
388 .strip_prefix("./")
389 .unwrap_or(&normalized_pattern);
390 if stripped.starts_with(&normalized) {
391 errors.push(format!(
392 "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.",
393 zone.name, pattern, normalized
394 ));
395 }
396 }
397 }
398 errors
399 }
400
401 #[must_use]
404 pub fn validate_zone_references(&self) -> Vec<(usize, &str)> {
405 let zone_names: rustc_hash::FxHashSet<&str> =
406 self.zones.iter().map(|z| z.name.as_str()).collect();
407
408 let mut errors = Vec::new();
409 for (i, rule) in self.rules.iter().enumerate() {
410 if !zone_names.contains(rule.from.as_str()) {
411 errors.push((i, rule.from.as_str()));
412 }
413 for allowed in &rule.allow {
414 if !zone_names.contains(allowed.as_str()) {
415 errors.push((i, allowed.as_str()));
416 }
417 }
418 }
419 errors
420 }
421
422 #[must_use]
425 pub fn resolve(&self) -> ResolvedBoundaryConfig {
426 let zones = self
427 .zones
428 .iter()
429 .map(|zone| {
430 let matchers = zone
431 .patterns
432 .iter()
433 .filter_map(|pattern| match Glob::new(pattern) {
434 Ok(glob) => Some(glob.compile_matcher()),
435 Err(e) => {
436 tracing::warn!(
437 "invalid boundary zone glob pattern '{}' in zone '{}': {e}",
438 pattern,
439 zone.name
440 );
441 None
442 }
443 })
444 .collect();
445 let root = zone.root.as_deref().map(normalize_zone_root);
446 ResolvedZone {
447 name: zone.name.clone(),
448 matchers,
449 root,
450 }
451 })
452 .collect();
453
454 let rules = self
455 .rules
456 .iter()
457 .map(|rule| ResolvedBoundaryRule {
458 from_zone: rule.from.clone(),
459 allowed_zones: rule.allow.clone(),
460 })
461 .collect();
462
463 ResolvedBoundaryConfig { zones, rules }
464 }
465}
466
467fn normalize_zone_root(raw: &str) -> String {
472 let with_slashes = raw.replace('\\', "/");
473 let trimmed = with_slashes.trim_start_matches("./");
474 let no_dot = if trimmed == "." { "" } else { trimmed };
475 if no_dot.is_empty() {
476 String::new()
477 } else if no_dot.ends_with('/') {
478 no_dot.to_owned()
479 } else {
480 format!("{no_dot}/")
481 }
482}
483
484impl ResolvedBoundaryConfig {
485 #[must_use]
487 pub fn is_empty(&self) -> bool {
488 self.zones.is_empty()
489 }
490
491 #[must_use]
499 pub fn classify_zone(&self, relative_path: &str) -> Option<&str> {
500 for zone in &self.zones {
501 let candidate: &str = match zone.root.as_deref() {
502 Some(root) if !root.is_empty() => {
503 let Some(stripped) = relative_path.strip_prefix(root) else {
504 continue;
505 };
506 stripped
507 }
508 _ => relative_path,
509 };
510 if zone.matchers.iter().any(|m| m.is_match(candidate)) {
511 return Some(&zone.name);
512 }
513 }
514 None
515 }
516
517 #[must_use]
520 pub fn is_import_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
521 if from_zone == to_zone {
523 return true;
524 }
525
526 let rule = self.rules.iter().find(|r| r.from_zone == from_zone);
528
529 match rule {
530 None => true,
532 Some(r) => r.allowed_zones.iter().any(|z| z == to_zone),
534 }
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn empty_config() {
544 let config = BoundaryConfig::default();
545 assert!(config.is_empty());
546 assert!(config.validate_zone_references().is_empty());
547 }
548
549 #[test]
550 fn deserialize_json() {
551 let json = r#"{
552 "zones": [
553 { "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
554 { "name": "db", "patterns": ["src/db/**"] },
555 { "name": "shared", "patterns": ["src/shared/**"] }
556 ],
557 "rules": [
558 { "from": "ui", "allow": ["shared"] },
559 { "from": "db", "allow": ["shared"] }
560 ]
561 }"#;
562 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
563 assert_eq!(config.zones.len(), 3);
564 assert_eq!(config.rules.len(), 2);
565 assert_eq!(config.zones[0].name, "ui");
566 assert_eq!(
567 config.zones[0].patterns,
568 vec!["src/components/**", "src/pages/**"]
569 );
570 assert_eq!(config.rules[0].from, "ui");
571 assert_eq!(config.rules[0].allow, vec!["shared"]);
572 }
573
574 #[test]
575 fn deserialize_toml() {
576 let toml_str = r#"
577[[zones]]
578name = "ui"
579patterns = ["src/components/**"]
580
581[[zones]]
582name = "db"
583patterns = ["src/db/**"]
584
585[[rules]]
586from = "ui"
587allow = ["db"]
588"#;
589 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
590 assert_eq!(config.zones.len(), 2);
591 assert_eq!(config.rules.len(), 1);
592 }
593
594 #[test]
595 fn validate_zone_references_valid() {
596 let config = BoundaryConfig {
597 preset: None,
598 zones: vec![
599 BoundaryZone {
600 name: "ui".to_string(),
601 patterns: vec![],
602 root: None,
603 },
604 BoundaryZone {
605 name: "db".to_string(),
606 patterns: vec![],
607 root: None,
608 },
609 ],
610 rules: vec![BoundaryRule {
611 from: "ui".to_string(),
612 allow: vec!["db".to_string()],
613 }],
614 };
615 assert!(config.validate_zone_references().is_empty());
616 }
617
618 #[test]
619 fn validate_zone_references_invalid_from() {
620 let config = BoundaryConfig {
621 preset: None,
622 zones: vec![BoundaryZone {
623 name: "ui".to_string(),
624 patterns: vec![],
625 root: None,
626 }],
627 rules: vec![BoundaryRule {
628 from: "nonexistent".to_string(),
629 allow: vec!["ui".to_string()],
630 }],
631 };
632 let errors = config.validate_zone_references();
633 assert_eq!(errors.len(), 1);
634 assert_eq!(errors[0].1, "nonexistent");
635 }
636
637 #[test]
638 fn validate_zone_references_invalid_allow() {
639 let config = BoundaryConfig {
640 preset: None,
641 zones: vec![BoundaryZone {
642 name: "ui".to_string(),
643 patterns: vec![],
644 root: None,
645 }],
646 rules: vec![BoundaryRule {
647 from: "ui".to_string(),
648 allow: vec!["nonexistent".to_string()],
649 }],
650 };
651 let errors = config.validate_zone_references();
652 assert_eq!(errors.len(), 1);
653 assert_eq!(errors[0].1, "nonexistent");
654 }
655
656 #[test]
657 fn resolve_and_classify() {
658 let config = BoundaryConfig {
659 preset: None,
660 zones: vec![
661 BoundaryZone {
662 name: "ui".to_string(),
663 patterns: vec!["src/components/**".to_string()],
664 root: None,
665 },
666 BoundaryZone {
667 name: "db".to_string(),
668 patterns: vec!["src/db/**".to_string()],
669 root: None,
670 },
671 ],
672 rules: vec![],
673 };
674 let resolved = config.resolve();
675 assert_eq!(
676 resolved.classify_zone("src/components/Button.tsx"),
677 Some("ui")
678 );
679 assert_eq!(resolved.classify_zone("src/db/queries.ts"), Some("db"));
680 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
681 }
682
683 #[test]
684 fn first_match_wins() {
685 let config = BoundaryConfig {
686 preset: None,
687 zones: vec![
688 BoundaryZone {
689 name: "specific".to_string(),
690 patterns: vec!["src/shared/db-utils/**".to_string()],
691 root: None,
692 },
693 BoundaryZone {
694 name: "shared".to_string(),
695 patterns: vec!["src/shared/**".to_string()],
696 root: None,
697 },
698 ],
699 rules: vec![],
700 };
701 let resolved = config.resolve();
702 assert_eq!(
703 resolved.classify_zone("src/shared/db-utils/pool.ts"),
704 Some("specific")
705 );
706 assert_eq!(
707 resolved.classify_zone("src/shared/helpers.ts"),
708 Some("shared")
709 );
710 }
711
712 #[test]
713 fn self_import_always_allowed() {
714 let config = BoundaryConfig {
715 preset: None,
716 zones: vec![BoundaryZone {
717 name: "ui".to_string(),
718 patterns: vec![],
719 root: None,
720 }],
721 rules: vec![BoundaryRule {
722 from: "ui".to_string(),
723 allow: vec![],
724 }],
725 };
726 let resolved = config.resolve();
727 assert!(resolved.is_import_allowed("ui", "ui"));
728 }
729
730 #[test]
731 fn unrestricted_zone_allows_all() {
732 let config = BoundaryConfig {
733 preset: None,
734 zones: vec![
735 BoundaryZone {
736 name: "shared".to_string(),
737 patterns: vec![],
738 root: None,
739 },
740 BoundaryZone {
741 name: "db".to_string(),
742 patterns: vec![],
743 root: None,
744 },
745 ],
746 rules: vec![],
747 };
748 let resolved = config.resolve();
749 assert!(resolved.is_import_allowed("shared", "db"));
750 }
751
752 #[test]
753 fn restricted_zone_blocks_unlisted() {
754 let config = BoundaryConfig {
755 preset: None,
756 zones: vec![
757 BoundaryZone {
758 name: "ui".to_string(),
759 patterns: vec![],
760 root: None,
761 },
762 BoundaryZone {
763 name: "db".to_string(),
764 patterns: vec![],
765 root: None,
766 },
767 BoundaryZone {
768 name: "shared".to_string(),
769 patterns: vec![],
770 root: None,
771 },
772 ],
773 rules: vec![BoundaryRule {
774 from: "ui".to_string(),
775 allow: vec!["shared".to_string()],
776 }],
777 };
778 let resolved = config.resolve();
779 assert!(resolved.is_import_allowed("ui", "shared"));
780 assert!(!resolved.is_import_allowed("ui", "db"));
781 }
782
783 #[test]
784 fn empty_allow_blocks_all_except_self() {
785 let config = BoundaryConfig {
786 preset: None,
787 zones: vec![
788 BoundaryZone {
789 name: "isolated".to_string(),
790 patterns: vec![],
791 root: None,
792 },
793 BoundaryZone {
794 name: "other".to_string(),
795 patterns: vec![],
796 root: None,
797 },
798 ],
799 rules: vec![BoundaryRule {
800 from: "isolated".to_string(),
801 allow: vec![],
802 }],
803 };
804 let resolved = config.resolve();
805 assert!(resolved.is_import_allowed("isolated", "isolated"));
806 assert!(!resolved.is_import_allowed("isolated", "other"));
807 }
808
809 #[test]
810 fn zone_root_filters_classification_to_subtree() {
811 let config = BoundaryConfig {
812 preset: None,
813 zones: vec![
814 BoundaryZone {
815 name: "ui".to_string(),
816 patterns: vec!["src/**".to_string()],
817 root: Some("packages/app/".to_string()),
818 },
819 BoundaryZone {
820 name: "domain".to_string(),
821 patterns: vec!["src/**".to_string()],
822 root: Some("packages/core/".to_string()),
823 },
824 ],
825 rules: vec![],
826 };
827 let resolved = config.resolve();
828 assert_eq!(
830 resolved.classify_zone("packages/app/src/login.tsx"),
831 Some("ui")
832 );
833 assert_eq!(
835 resolved.classify_zone("packages/core/src/order.ts"),
836 Some("domain")
837 );
838 assert_eq!(resolved.classify_zone("src/login.tsx"), None);
840 assert_eq!(resolved.classify_zone("packages/utils/src/x.ts"), None);
841 }
842
843 #[test]
850 fn zone_root_is_case_sensitive() {
851 let config = BoundaryConfig {
852 preset: None,
853 zones: vec![BoundaryZone {
854 name: "ui".to_string(),
855 patterns: vec!["src/**".to_string()],
856 root: Some("packages/app/".to_string()),
857 }],
858 rules: vec![],
859 };
860 let resolved = config.resolve();
861 assert_eq!(
862 resolved.classify_zone("packages/app/src/login.tsx"),
863 Some("ui"),
864 "exact-case path classifies"
865 );
866 assert_eq!(
867 resolved.classify_zone("packages/App/src/login.tsx"),
868 None,
869 "case-different path does not classify (root is case-sensitive)"
870 );
871 assert_eq!(
872 resolved.classify_zone("Packages/app/src/login.tsx"),
873 None,
874 "case-different prefix does not classify"
875 );
876 }
877
878 #[test]
879 fn zone_root_normalizes_trailing_slash_and_dot_prefix() {
880 let config = BoundaryConfig {
881 preset: None,
882 zones: vec![
883 BoundaryZone {
884 name: "no-slash".to_string(),
885 patterns: vec!["src/**".to_string()],
886 root: Some("packages/app".to_string()),
887 },
888 BoundaryZone {
889 name: "dot-prefixed".to_string(),
890 patterns: vec!["src/**".to_string()],
891 root: Some("./packages/lib/".to_string()),
892 },
893 ],
894 rules: vec![],
895 };
896 let resolved = config.resolve();
897 assert_eq!(resolved.zones[0].root.as_deref(), Some("packages/app/"));
898 assert_eq!(resolved.zones[1].root.as_deref(), Some("packages/lib/"));
899 assert_eq!(
900 resolved.classify_zone("packages/app/src/x.ts"),
901 Some("no-slash")
902 );
903 assert_eq!(
904 resolved.classify_zone("packages/lib/src/x.ts"),
905 Some("dot-prefixed")
906 );
907 }
908
909 #[test]
910 fn validate_root_prefixes_flags_redundant_pattern() {
911 let config = BoundaryConfig {
912 preset: None,
913 zones: vec![BoundaryZone {
914 name: "ui".to_string(),
915 patterns: vec!["packages/app/src/**".to_string()],
916 root: Some("packages/app/".to_string()),
917 }],
918 rules: vec![],
919 };
920 let errors = config.validate_root_prefixes();
921 assert_eq!(errors.len(), 1, "expected one redundant-prefix error");
922 assert!(
923 errors[0].contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"),
924 "error should be tagged: {}",
925 errors[0]
926 );
927 assert!(
928 errors[0].contains("zone 'ui'"),
929 "error should name the zone: {}",
930 errors[0]
931 );
932 assert!(
933 errors[0].contains("packages/app/src/**"),
934 "error should quote the pattern: {}",
935 errors[0]
936 );
937 }
938
939 #[test]
940 fn validate_root_prefixes_handles_unnormalized_root() {
941 let config = BoundaryConfig {
944 preset: None,
945 zones: vec![BoundaryZone {
946 name: "ui".to_string(),
947 patterns: vec!["./packages/app/src/**".to_string()],
948 root: Some("packages/app".to_string()),
949 }],
950 rules: vec![],
951 };
952 let errors = config.validate_root_prefixes();
953 assert_eq!(errors.len(), 1);
954 }
955
956 #[test]
957 fn validate_root_prefixes_empty_when_no_overlap() {
958 let config = BoundaryConfig {
959 preset: None,
960 zones: vec![BoundaryZone {
961 name: "ui".to_string(),
962 patterns: vec!["src/**".to_string()],
963 root: Some("packages/app/".to_string()),
964 }],
965 rules: vec![],
966 };
967 assert!(config.validate_root_prefixes().is_empty());
968 }
969
970 #[test]
971 fn validate_root_prefixes_skips_zones_without_root() {
972 let json = r#"{
973 "zones": [{ "name": "ui", "patterns": ["src/**"] }],
974 "rules": []
975 }"#;
976 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
977 assert!(config.validate_root_prefixes().is_empty());
978 }
979
980 #[test]
986 fn validate_root_prefixes_skips_empty_root() {
987 for raw_root in ["", ".", "./"] {
988 let config = BoundaryConfig {
989 preset: None,
990 zones: vec![BoundaryZone {
991 name: "ui".to_string(),
992 patterns: vec!["src/**".to_string(), "lib/**".to_string()],
993 root: Some(raw_root.to_string()),
994 }],
995 rules: vec![],
996 };
997 let errors = config.validate_root_prefixes();
998 assert!(
999 errors.is_empty(),
1000 "empty-normalized root {raw_root:?} produced spurious errors: {errors:?}"
1001 );
1002 }
1003 }
1004
1005 #[test]
1006 fn deserialize_zone_with_root() {
1007 let json = r#"{
1008 "zones": [
1009 { "name": "ui", "patterns": ["src/**"], "root": "packages/app/" }
1010 ],
1011 "rules": []
1012 }"#;
1013 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1014 assert_eq!(config.zones[0].root.as_deref(), Some("packages/app/"));
1015 }
1016
1017 #[test]
1020 fn deserialize_preset_json() {
1021 let json = r#"{ "preset": "layered" }"#;
1022 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1023 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
1024 assert!(config.zones.is_empty());
1025 }
1026
1027 #[test]
1028 fn deserialize_preset_hexagonal_json() {
1029 let json = r#"{ "preset": "hexagonal" }"#;
1030 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1031 assert_eq!(config.preset, Some(BoundaryPreset::Hexagonal));
1032 }
1033
1034 #[test]
1035 fn deserialize_preset_feature_sliced_json() {
1036 let json = r#"{ "preset": "feature-sliced" }"#;
1037 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1038 assert_eq!(config.preset, Some(BoundaryPreset::FeatureSliced));
1039 }
1040
1041 #[test]
1042 fn deserialize_preset_toml() {
1043 let toml_str = r#"preset = "layered""#;
1044 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
1045 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
1046 }
1047
1048 #[test]
1049 fn deserialize_invalid_preset_rejected() {
1050 let json = r#"{ "preset": "invalid_preset" }"#;
1051 let result: Result<BoundaryConfig, _> = serde_json::from_str(json);
1052 assert!(result.is_err());
1053 }
1054
1055 #[test]
1056 fn preset_absent_by_default() {
1057 let config = BoundaryConfig::default();
1058 assert!(config.preset.is_none());
1059 assert!(config.is_empty());
1060 }
1061
1062 #[test]
1063 fn preset_makes_config_non_empty() {
1064 let config = BoundaryConfig {
1065 preset: Some(BoundaryPreset::Layered),
1066 zones: vec![],
1067 rules: vec![],
1068 };
1069 assert!(!config.is_empty());
1070 }
1071
1072 #[test]
1075 fn expand_layered_produces_four_zones() {
1076 let mut config = BoundaryConfig {
1077 preset: Some(BoundaryPreset::Layered),
1078 zones: vec![],
1079 rules: vec![],
1080 };
1081 config.expand("src");
1082 assert_eq!(config.zones.len(), 4);
1083 assert_eq!(config.rules.len(), 4);
1084 assert!(config.preset.is_none(), "preset cleared after expand");
1085 assert_eq!(config.zones[0].name, "presentation");
1086 assert_eq!(config.zones[0].patterns, vec!["src/presentation/**"]);
1087 }
1088
1089 #[test]
1090 fn expand_layered_rules_correct() {
1091 let mut config = BoundaryConfig {
1092 preset: Some(BoundaryPreset::Layered),
1093 zones: vec![],
1094 rules: vec![],
1095 };
1096 config.expand("src");
1097 let pres_rule = config
1099 .rules
1100 .iter()
1101 .find(|r| r.from == "presentation")
1102 .unwrap();
1103 assert_eq!(pres_rule.allow, vec!["application"]);
1104 let app_rule = config
1106 .rules
1107 .iter()
1108 .find(|r| r.from == "application")
1109 .unwrap();
1110 assert_eq!(app_rule.allow, vec!["domain"]);
1111 let dom_rule = config.rules.iter().find(|r| r.from == "domain").unwrap();
1113 assert!(dom_rule.allow.is_empty());
1114 let infra_rule = config
1116 .rules
1117 .iter()
1118 .find(|r| r.from == "infrastructure")
1119 .unwrap();
1120 assert_eq!(infra_rule.allow, vec!["domain", "application"]);
1121 }
1122
1123 #[test]
1124 fn expand_hexagonal_produces_three_zones() {
1125 let mut config = BoundaryConfig {
1126 preset: Some(BoundaryPreset::Hexagonal),
1127 zones: vec![],
1128 rules: vec![],
1129 };
1130 config.expand("src");
1131 assert_eq!(config.zones.len(), 3);
1132 assert_eq!(config.rules.len(), 3);
1133 assert_eq!(config.zones[0].name, "adapters");
1134 assert_eq!(config.zones[1].name, "ports");
1135 assert_eq!(config.zones[2].name, "domain");
1136 }
1137
1138 #[test]
1139 fn expand_feature_sliced_produces_six_zones() {
1140 let mut config = BoundaryConfig {
1141 preset: Some(BoundaryPreset::FeatureSliced),
1142 zones: vec![],
1143 rules: vec![],
1144 };
1145 config.expand("src");
1146 assert_eq!(config.zones.len(), 6);
1147 assert_eq!(config.rules.len(), 6);
1148 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
1150 assert_eq!(
1151 app_rule.allow,
1152 vec!["pages", "widgets", "features", "entities", "shared"]
1153 );
1154 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
1156 assert!(shared_rule.allow.is_empty());
1157 let ent_rule = config.rules.iter().find(|r| r.from == "entities").unwrap();
1159 assert_eq!(ent_rule.allow, vec!["shared"]);
1160 }
1161
1162 #[test]
1163 fn expand_bulletproof_produces_four_zones() {
1164 let mut config = BoundaryConfig {
1165 preset: Some(BoundaryPreset::Bulletproof),
1166 zones: vec![],
1167 rules: vec![],
1168 };
1169 config.expand("src");
1170 assert_eq!(config.zones.len(), 4);
1171 assert_eq!(config.rules.len(), 4);
1172 assert_eq!(config.zones[0].name, "app");
1173 assert_eq!(config.zones[1].name, "features");
1174 assert_eq!(config.zones[2].name, "shared");
1175 assert_eq!(config.zones[3].name, "server");
1176 assert!(config.zones[2].patterns.len() > 1);
1178 assert!(
1179 config.zones[2]
1180 .patterns
1181 .contains(&"src/components/**".to_string())
1182 );
1183 assert!(
1184 config.zones[2]
1185 .patterns
1186 .contains(&"src/hooks/**".to_string())
1187 );
1188 assert!(config.zones[2].patterns.contains(&"src/lib/**".to_string()));
1189 assert!(
1190 config.zones[2]
1191 .patterns
1192 .contains(&"src/providers/**".to_string())
1193 );
1194 }
1195
1196 #[test]
1197 fn expand_bulletproof_rules_correct() {
1198 let mut config = BoundaryConfig {
1199 preset: Some(BoundaryPreset::Bulletproof),
1200 zones: vec![],
1201 rules: vec![],
1202 };
1203 config.expand("src");
1204 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
1206 assert_eq!(app_rule.allow, vec!["features", "shared", "server"]);
1207 let feat_rule = config.rules.iter().find(|r| r.from == "features").unwrap();
1209 assert_eq!(feat_rule.allow, vec!["shared", "server"]);
1210 let srv_rule = config.rules.iter().find(|r| r.from == "server").unwrap();
1212 assert_eq!(srv_rule.allow, vec!["shared"]);
1213 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
1215 assert!(shared_rule.allow.is_empty());
1216 }
1217
1218 #[test]
1219 fn expand_bulletproof_then_resolve_classifies() {
1220 let mut config = BoundaryConfig {
1221 preset: Some(BoundaryPreset::Bulletproof),
1222 zones: vec![],
1223 rules: vec![],
1224 };
1225 config.expand("src");
1226 let resolved = config.resolve();
1227 assert_eq!(
1228 resolved.classify_zone("src/app/dashboard/page.tsx"),
1229 Some("app")
1230 );
1231 assert_eq!(
1232 resolved.classify_zone("src/features/auth/hooks/useAuth.ts"),
1233 Some("features")
1234 );
1235 assert_eq!(
1236 resolved.classify_zone("src/components/Button/Button.tsx"),
1237 Some("shared")
1238 );
1239 assert_eq!(
1240 resolved.classify_zone("src/hooks/useFormatters.ts"),
1241 Some("shared")
1242 );
1243 assert_eq!(
1244 resolved.classify_zone("src/server/db/schema/users.ts"),
1245 Some("server")
1246 );
1247 assert!(resolved.is_import_allowed("features", "shared"));
1249 assert!(resolved.is_import_allowed("features", "server"));
1250 assert!(!resolved.is_import_allowed("features", "app"));
1251 assert!(!resolved.is_import_allowed("shared", "features"));
1252 assert!(!resolved.is_import_allowed("server", "features"));
1253 }
1254
1255 #[test]
1256 fn expand_uses_custom_source_root() {
1257 let mut config = BoundaryConfig {
1258 preset: Some(BoundaryPreset::Hexagonal),
1259 zones: vec![],
1260 rules: vec![],
1261 };
1262 config.expand("lib");
1263 assert_eq!(config.zones[0].patterns, vec!["lib/adapters/**"]);
1264 assert_eq!(config.zones[2].patterns, vec!["lib/domain/**"]);
1265 }
1266
1267 #[test]
1270 fn user_zone_replaces_preset_zone() {
1271 let mut config = BoundaryConfig {
1272 preset: Some(BoundaryPreset::Hexagonal),
1273 zones: vec![BoundaryZone {
1274 name: "domain".to_string(),
1275 patterns: vec!["src/core/**".to_string()],
1276 root: None,
1277 }],
1278 rules: vec![],
1279 };
1280 config.expand("src");
1281 assert_eq!(config.zones.len(), 3);
1283 let domain = config.zones.iter().find(|z| z.name == "domain").unwrap();
1284 assert_eq!(domain.patterns, vec!["src/core/**"]);
1285 }
1286
1287 #[test]
1288 fn user_zone_adds_to_preset() {
1289 let mut config = BoundaryConfig {
1290 preset: Some(BoundaryPreset::Hexagonal),
1291 zones: vec![BoundaryZone {
1292 name: "shared".to_string(),
1293 patterns: vec!["src/shared/**".to_string()],
1294 root: None,
1295 }],
1296 rules: vec![],
1297 };
1298 config.expand("src");
1299 assert_eq!(config.zones.len(), 4); assert!(config.zones.iter().any(|z| z.name == "shared"));
1301 }
1302
1303 #[test]
1304 fn user_rule_replaces_preset_rule() {
1305 let mut config = BoundaryConfig {
1306 preset: Some(BoundaryPreset::Hexagonal),
1307 zones: vec![],
1308 rules: vec![BoundaryRule {
1309 from: "adapters".to_string(),
1310 allow: vec!["ports".to_string(), "domain".to_string()],
1311 }],
1312 };
1313 config.expand("src");
1314 let adapter_rule = config.rules.iter().find(|r| r.from == "adapters").unwrap();
1315 assert_eq!(adapter_rule.allow, vec!["ports", "domain"]);
1317 assert_eq!(
1319 config.rules.iter().filter(|r| r.from == "adapters").count(),
1320 1
1321 );
1322 }
1323
1324 #[test]
1325 fn expand_without_preset_is_noop() {
1326 let mut config = BoundaryConfig {
1327 preset: None,
1328 zones: vec![BoundaryZone {
1329 name: "ui".to_string(),
1330 patterns: vec!["src/ui/**".to_string()],
1331 root: None,
1332 }],
1333 rules: vec![],
1334 };
1335 config.expand("src");
1336 assert_eq!(config.zones.len(), 1);
1337 assert_eq!(config.zones[0].name, "ui");
1338 }
1339
1340 #[test]
1341 fn expand_then_validate_succeeds() {
1342 let mut config = BoundaryConfig {
1343 preset: Some(BoundaryPreset::Layered),
1344 zones: vec![],
1345 rules: vec![],
1346 };
1347 config.expand("src");
1348 assert!(config.validate_zone_references().is_empty());
1349 }
1350
1351 #[test]
1352 fn expand_then_resolve_classifies() {
1353 let mut config = BoundaryConfig {
1354 preset: Some(BoundaryPreset::Hexagonal),
1355 zones: vec![],
1356 rules: vec![],
1357 };
1358 config.expand("src");
1359 let resolved = config.resolve();
1360 assert_eq!(
1361 resolved.classify_zone("src/adapters/http/handler.ts"),
1362 Some("adapters")
1363 );
1364 assert_eq!(resolved.classify_zone("src/domain/user.ts"), Some("domain"));
1365 assert!(!resolved.is_import_allowed("adapters", "domain"));
1366 assert!(resolved.is_import_allowed("adapters", "ports"));
1367 }
1368
1369 #[test]
1370 fn preset_name_returns_correct_string() {
1371 let config = BoundaryConfig {
1372 preset: Some(BoundaryPreset::FeatureSliced),
1373 zones: vec![],
1374 rules: vec![],
1375 };
1376 assert_eq!(config.preset_name(), Some("feature-sliced"));
1377
1378 let empty = BoundaryConfig::default();
1379 assert_eq!(empty.preset_name(), None);
1380 }
1381
1382 #[test]
1383 fn preset_name_all_variants() {
1384 let cases = [
1385 (BoundaryPreset::Layered, "layered"),
1386 (BoundaryPreset::Hexagonal, "hexagonal"),
1387 (BoundaryPreset::FeatureSliced, "feature-sliced"),
1388 (BoundaryPreset::Bulletproof, "bulletproof"),
1389 ];
1390 for (preset, expected_name) in cases {
1391 let config = BoundaryConfig {
1392 preset: Some(preset),
1393 zones: vec![],
1394 rules: vec![],
1395 };
1396 assert_eq!(
1397 config.preset_name(),
1398 Some(expected_name),
1399 "preset_name() mismatch for variant"
1400 );
1401 }
1402 }
1403
1404 #[test]
1407 fn resolved_boundary_config_empty() {
1408 let resolved = ResolvedBoundaryConfig::default();
1409 assert!(resolved.is_empty());
1410 }
1411
1412 #[test]
1413 fn resolved_boundary_config_with_zones_not_empty() {
1414 let config = BoundaryConfig {
1415 preset: None,
1416 zones: vec![BoundaryZone {
1417 name: "ui".to_string(),
1418 patterns: vec!["src/ui/**".to_string()],
1419 root: None,
1420 }],
1421 rules: vec![],
1422 };
1423 let resolved = config.resolve();
1424 assert!(!resolved.is_empty());
1425 }
1426
1427 #[test]
1430 fn boundary_config_with_only_rules_is_empty() {
1431 let config = BoundaryConfig {
1434 preset: None,
1435 zones: vec![],
1436 rules: vec![BoundaryRule {
1437 from: "ui".to_string(),
1438 allow: vec!["db".to_string()],
1439 }],
1440 };
1441 assert!(config.is_empty());
1442 }
1443
1444 #[test]
1445 fn boundary_config_with_zones_not_empty() {
1446 let config = BoundaryConfig {
1447 preset: None,
1448 zones: vec![BoundaryZone {
1449 name: "ui".to_string(),
1450 patterns: vec![],
1451 root: None,
1452 }],
1453 rules: vec![],
1454 };
1455 assert!(!config.is_empty());
1456 }
1457
1458 #[test]
1461 fn zone_with_multiple_patterns_matches_any() {
1462 let config = BoundaryConfig {
1463 preset: None,
1464 zones: vec![BoundaryZone {
1465 name: "ui".to_string(),
1466 patterns: vec![
1467 "src/components/**".to_string(),
1468 "src/pages/**".to_string(),
1469 "src/views/**".to_string(),
1470 ],
1471 root: None,
1472 }],
1473 rules: vec![],
1474 };
1475 let resolved = config.resolve();
1476 assert_eq!(
1477 resolved.classify_zone("src/components/Button.tsx"),
1478 Some("ui")
1479 );
1480 assert_eq!(resolved.classify_zone("src/pages/Home.tsx"), Some("ui"));
1481 assert_eq!(
1482 resolved.classify_zone("src/views/Dashboard.tsx"),
1483 Some("ui")
1484 );
1485 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
1486 }
1487
1488 #[test]
1491 fn validate_zone_references_multiple_errors() {
1492 let config = BoundaryConfig {
1493 preset: None,
1494 zones: vec![BoundaryZone {
1495 name: "ui".to_string(),
1496 patterns: vec![],
1497 root: None,
1498 }],
1499 rules: vec![
1500 BoundaryRule {
1501 from: "nonexistent_from".to_string(),
1502 allow: vec!["nonexistent_allow".to_string()],
1503 },
1504 BoundaryRule {
1505 from: "ui".to_string(),
1506 allow: vec!["also_nonexistent".to_string()],
1507 },
1508 ],
1509 };
1510 let errors = config.validate_zone_references();
1511 assert_eq!(errors.len(), 3);
1514 }
1515
1516 #[test]
1519 fn expand_feature_sliced_with_custom_root() {
1520 let mut config = BoundaryConfig {
1521 preset: Some(BoundaryPreset::FeatureSliced),
1522 zones: vec![],
1523 rules: vec![],
1524 };
1525 config.expand("lib");
1526 assert_eq!(config.zones[0].patterns, vec!["lib/app/**"]);
1527 assert_eq!(config.zones[5].patterns, vec!["lib/shared/**"]);
1528 }
1529
1530 #[test]
1533 fn zone_not_in_rules_is_unrestricted() {
1534 let config = BoundaryConfig {
1535 preset: None,
1536 zones: vec![
1537 BoundaryZone {
1538 name: "a".to_string(),
1539 patterns: vec![],
1540 root: None,
1541 },
1542 BoundaryZone {
1543 name: "b".to_string(),
1544 patterns: vec![],
1545 root: None,
1546 },
1547 BoundaryZone {
1548 name: "c".to_string(),
1549 patterns: vec![],
1550 root: None,
1551 },
1552 ],
1553 rules: vec![BoundaryRule {
1554 from: "a".to_string(),
1555 allow: vec!["b".to_string()],
1556 }],
1557 };
1558 let resolved = config.resolve();
1559 assert!(resolved.is_import_allowed("a", "b"));
1561 assert!(!resolved.is_import_allowed("a", "c"));
1562 assert!(resolved.is_import_allowed("b", "a"));
1564 assert!(resolved.is_import_allowed("b", "c"));
1565 assert!(resolved.is_import_allowed("c", "a"));
1567 }
1568
1569 #[test]
1572 fn boundary_preset_json_roundtrip() {
1573 let presets = [
1574 BoundaryPreset::Layered,
1575 BoundaryPreset::Hexagonal,
1576 BoundaryPreset::FeatureSliced,
1577 BoundaryPreset::Bulletproof,
1578 ];
1579 for preset in presets {
1580 let json = serde_json::to_string(&preset).unwrap();
1581 let restored: BoundaryPreset = serde_json::from_str(&json).unwrap();
1582 assert_eq!(restored, preset);
1583 }
1584 }
1585
1586 #[test]
1587 fn deserialize_preset_bulletproof_json() {
1588 let json = r#"{ "preset": "bulletproof" }"#;
1589 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1590 assert_eq!(config.preset, Some(BoundaryPreset::Bulletproof));
1591 }
1592
1593 #[test]
1596 fn resolve_skips_invalid_zone_glob() {
1597 let config = BoundaryConfig {
1598 preset: None,
1599 zones: vec![BoundaryZone {
1600 name: "broken".to_string(),
1601 patterns: vec!["[invalid".to_string()],
1602 root: None,
1603 }],
1604 rules: vec![],
1605 };
1606 let resolved = config.resolve();
1607 assert!(!resolved.is_empty());
1609 assert_eq!(resolved.classify_zone("anything.ts"), None);
1610 }
1611}