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")]
220 pub root: Option<String>,
221}
222
223#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
225#[serde(rename_all = "camelCase")]
226pub struct BoundaryRule {
227 pub from: String,
229 #[serde(default)]
232 pub allow: Vec<String>,
233}
234
235#[derive(Debug, Default)]
237pub struct ResolvedBoundaryConfig {
238 pub zones: Vec<ResolvedZone>,
240 pub rules: Vec<ResolvedBoundaryRule>,
242}
243
244#[derive(Debug)]
246pub struct ResolvedZone {
247 pub name: String,
249 pub matchers: Vec<globset::GlobMatcher>,
251}
252
253#[derive(Debug)]
255pub struct ResolvedBoundaryRule {
256 pub from_zone: String,
258 pub allowed_zones: Vec<String>,
260}
261
262impl BoundaryConfig {
263 #[must_use]
265 pub fn is_empty(&self) -> bool {
266 self.preset.is_none() && self.zones.is_empty()
267 }
268
269 pub fn expand(&mut self, source_root: &str) {
279 let Some(preset) = self.preset.take() else {
280 return;
281 };
282
283 let (preset_zones, preset_rules) = preset.default_config(source_root);
284
285 let user_zone_names: rustc_hash::FxHashSet<&str> =
287 self.zones.iter().map(|z| z.name.as_str()).collect();
288
289 let mut merged_zones: Vec<BoundaryZone> = preset_zones
291 .into_iter()
292 .filter(|pz| {
293 if user_zone_names.contains(pz.name.as_str()) {
294 tracing::info!(
295 "boundary preset: user zone '{}' replaces preset zone",
296 pz.name
297 );
298 false
299 } else {
300 true
301 }
302 })
303 .collect();
304 merged_zones.append(&mut self.zones);
306 self.zones = merged_zones;
307
308 let user_rule_sources: rustc_hash::FxHashSet<&str> =
310 self.rules.iter().map(|r| r.from.as_str()).collect();
311
312 let mut merged_rules: Vec<BoundaryRule> = preset_rules
313 .into_iter()
314 .filter(|pr| {
315 if user_rule_sources.contains(pr.from.as_str()) {
316 tracing::info!(
317 "boundary preset: user rule for '{}' replaces preset rule",
318 pr.from
319 );
320 false
321 } else {
322 true
323 }
324 })
325 .collect();
326 merged_rules.append(&mut self.rules);
327 self.rules = merged_rules;
328 }
329
330 #[must_use]
332 pub fn preset_name(&self) -> Option<&str> {
333 self.preset.as_ref().map(|p| match p {
334 BoundaryPreset::Layered => "layered",
335 BoundaryPreset::Hexagonal => "hexagonal",
336 BoundaryPreset::FeatureSliced => "feature-sliced",
337 BoundaryPreset::Bulletproof => "bulletproof",
338 })
339 }
340
341 #[must_use]
344 pub fn validate_zone_references(&self) -> Vec<(usize, &str)> {
345 let zone_names: rustc_hash::FxHashSet<&str> =
346 self.zones.iter().map(|z| z.name.as_str()).collect();
347
348 let mut errors = Vec::new();
349 for (i, rule) in self.rules.iter().enumerate() {
350 if !zone_names.contains(rule.from.as_str()) {
351 errors.push((i, rule.from.as_str()));
352 }
353 for allowed in &rule.allow {
354 if !zone_names.contains(allowed.as_str()) {
355 errors.push((i, allowed.as_str()));
356 }
357 }
358 }
359 errors
360 }
361
362 #[must_use]
365 pub fn resolve(&self) -> ResolvedBoundaryConfig {
366 let zones = self
367 .zones
368 .iter()
369 .map(|zone| {
370 let matchers = zone
371 .patterns
372 .iter()
373 .filter_map(|pattern| match Glob::new(pattern) {
374 Ok(glob) => Some(glob.compile_matcher()),
375 Err(e) => {
376 tracing::warn!(
377 "invalid boundary zone glob pattern '{}' in zone '{}': {e}",
378 pattern,
379 zone.name
380 );
381 None
382 }
383 })
384 .collect();
385 ResolvedZone {
386 name: zone.name.clone(),
387 matchers,
388 }
389 })
390 .collect();
391
392 let rules = self
393 .rules
394 .iter()
395 .map(|rule| ResolvedBoundaryRule {
396 from_zone: rule.from.clone(),
397 allowed_zones: rule.allow.clone(),
398 })
399 .collect();
400
401 ResolvedBoundaryConfig { zones, rules }
402 }
403}
404
405impl ResolvedBoundaryConfig {
406 #[must_use]
408 pub fn is_empty(&self) -> bool {
409 self.zones.is_empty()
410 }
411
412 #[must_use]
415 pub fn classify_zone(&self, relative_path: &str) -> Option<&str> {
416 for zone in &self.zones {
417 if zone.matchers.iter().any(|m| m.is_match(relative_path)) {
418 return Some(&zone.name);
419 }
420 }
421 None
422 }
423
424 #[must_use]
427 pub fn is_import_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
428 if from_zone == to_zone {
430 return true;
431 }
432
433 let rule = self.rules.iter().find(|r| r.from_zone == from_zone);
435
436 match rule {
437 None => true,
439 Some(r) => r.allowed_zones.iter().any(|z| z == to_zone),
441 }
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn empty_config() {
451 let config = BoundaryConfig::default();
452 assert!(config.is_empty());
453 assert!(config.validate_zone_references().is_empty());
454 }
455
456 #[test]
457 fn deserialize_json() {
458 let json = r#"{
459 "zones": [
460 { "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
461 { "name": "db", "patterns": ["src/db/**"] },
462 { "name": "shared", "patterns": ["src/shared/**"] }
463 ],
464 "rules": [
465 { "from": "ui", "allow": ["shared"] },
466 { "from": "db", "allow": ["shared"] }
467 ]
468 }"#;
469 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
470 assert_eq!(config.zones.len(), 3);
471 assert_eq!(config.rules.len(), 2);
472 assert_eq!(config.zones[0].name, "ui");
473 assert_eq!(
474 config.zones[0].patterns,
475 vec!["src/components/**", "src/pages/**"]
476 );
477 assert_eq!(config.rules[0].from, "ui");
478 assert_eq!(config.rules[0].allow, vec!["shared"]);
479 }
480
481 #[test]
482 fn deserialize_toml() {
483 let toml_str = r#"
484[[zones]]
485name = "ui"
486patterns = ["src/components/**"]
487
488[[zones]]
489name = "db"
490patterns = ["src/db/**"]
491
492[[rules]]
493from = "ui"
494allow = ["db"]
495"#;
496 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
497 assert_eq!(config.zones.len(), 2);
498 assert_eq!(config.rules.len(), 1);
499 }
500
501 #[test]
502 fn validate_zone_references_valid() {
503 let config = BoundaryConfig {
504 preset: None,
505 zones: vec![
506 BoundaryZone {
507 name: "ui".to_string(),
508 patterns: vec![],
509 root: None,
510 },
511 BoundaryZone {
512 name: "db".to_string(),
513 patterns: vec![],
514 root: None,
515 },
516 ],
517 rules: vec![BoundaryRule {
518 from: "ui".to_string(),
519 allow: vec!["db".to_string()],
520 }],
521 };
522 assert!(config.validate_zone_references().is_empty());
523 }
524
525 #[test]
526 fn validate_zone_references_invalid_from() {
527 let config = BoundaryConfig {
528 preset: None,
529 zones: vec![BoundaryZone {
530 name: "ui".to_string(),
531 patterns: vec![],
532 root: None,
533 }],
534 rules: vec![BoundaryRule {
535 from: "nonexistent".to_string(),
536 allow: vec!["ui".to_string()],
537 }],
538 };
539 let errors = config.validate_zone_references();
540 assert_eq!(errors.len(), 1);
541 assert_eq!(errors[0].1, "nonexistent");
542 }
543
544 #[test]
545 fn validate_zone_references_invalid_allow() {
546 let config = BoundaryConfig {
547 preset: None,
548 zones: vec![BoundaryZone {
549 name: "ui".to_string(),
550 patterns: vec![],
551 root: None,
552 }],
553 rules: vec![BoundaryRule {
554 from: "ui".to_string(),
555 allow: vec!["nonexistent".to_string()],
556 }],
557 };
558 let errors = config.validate_zone_references();
559 assert_eq!(errors.len(), 1);
560 assert_eq!(errors[0].1, "nonexistent");
561 }
562
563 #[test]
564 fn resolve_and_classify() {
565 let config = BoundaryConfig {
566 preset: None,
567 zones: vec![
568 BoundaryZone {
569 name: "ui".to_string(),
570 patterns: vec!["src/components/**".to_string()],
571 root: None,
572 },
573 BoundaryZone {
574 name: "db".to_string(),
575 patterns: vec!["src/db/**".to_string()],
576 root: None,
577 },
578 ],
579 rules: vec![],
580 };
581 let resolved = config.resolve();
582 assert_eq!(
583 resolved.classify_zone("src/components/Button.tsx"),
584 Some("ui")
585 );
586 assert_eq!(resolved.classify_zone("src/db/queries.ts"), Some("db"));
587 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
588 }
589
590 #[test]
591 fn first_match_wins() {
592 let config = BoundaryConfig {
593 preset: None,
594 zones: vec![
595 BoundaryZone {
596 name: "specific".to_string(),
597 patterns: vec!["src/shared/db-utils/**".to_string()],
598 root: None,
599 },
600 BoundaryZone {
601 name: "shared".to_string(),
602 patterns: vec!["src/shared/**".to_string()],
603 root: None,
604 },
605 ],
606 rules: vec![],
607 };
608 let resolved = config.resolve();
609 assert_eq!(
610 resolved.classify_zone("src/shared/db-utils/pool.ts"),
611 Some("specific")
612 );
613 assert_eq!(
614 resolved.classify_zone("src/shared/helpers.ts"),
615 Some("shared")
616 );
617 }
618
619 #[test]
620 fn self_import_always_allowed() {
621 let config = BoundaryConfig {
622 preset: None,
623 zones: vec![BoundaryZone {
624 name: "ui".to_string(),
625 patterns: vec![],
626 root: None,
627 }],
628 rules: vec![BoundaryRule {
629 from: "ui".to_string(),
630 allow: vec![],
631 }],
632 };
633 let resolved = config.resolve();
634 assert!(resolved.is_import_allowed("ui", "ui"));
635 }
636
637 #[test]
638 fn unrestricted_zone_allows_all() {
639 let config = BoundaryConfig {
640 preset: None,
641 zones: vec![
642 BoundaryZone {
643 name: "shared".to_string(),
644 patterns: vec![],
645 root: None,
646 },
647 BoundaryZone {
648 name: "db".to_string(),
649 patterns: vec![],
650 root: None,
651 },
652 ],
653 rules: vec![],
654 };
655 let resolved = config.resolve();
656 assert!(resolved.is_import_allowed("shared", "db"));
657 }
658
659 #[test]
660 fn restricted_zone_blocks_unlisted() {
661 let config = BoundaryConfig {
662 preset: None,
663 zones: vec![
664 BoundaryZone {
665 name: "ui".to_string(),
666 patterns: vec![],
667 root: None,
668 },
669 BoundaryZone {
670 name: "db".to_string(),
671 patterns: vec![],
672 root: None,
673 },
674 BoundaryZone {
675 name: "shared".to_string(),
676 patterns: vec![],
677 root: None,
678 },
679 ],
680 rules: vec![BoundaryRule {
681 from: "ui".to_string(),
682 allow: vec!["shared".to_string()],
683 }],
684 };
685 let resolved = config.resolve();
686 assert!(resolved.is_import_allowed("ui", "shared"));
687 assert!(!resolved.is_import_allowed("ui", "db"));
688 }
689
690 #[test]
691 fn empty_allow_blocks_all_except_self() {
692 let config = BoundaryConfig {
693 preset: None,
694 zones: vec![
695 BoundaryZone {
696 name: "isolated".to_string(),
697 patterns: vec![],
698 root: None,
699 },
700 BoundaryZone {
701 name: "other".to_string(),
702 patterns: vec![],
703 root: None,
704 },
705 ],
706 rules: vec![BoundaryRule {
707 from: "isolated".to_string(),
708 allow: vec![],
709 }],
710 };
711 let resolved = config.resolve();
712 assert!(resolved.is_import_allowed("isolated", "isolated"));
713 assert!(!resolved.is_import_allowed("isolated", "other"));
714 }
715
716 #[test]
717 fn root_field_reserved() {
718 let json = r#"{
719 "zones": [{ "name": "ui", "patterns": ["src/**"], "root": "packages/app/" }],
720 "rules": []
721 }"#;
722 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
723 assert_eq!(config.zones[0].root.as_deref(), Some("packages/app/"));
724 }
725
726 #[test]
729 fn deserialize_preset_json() {
730 let json = r#"{ "preset": "layered" }"#;
731 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
732 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
733 assert!(config.zones.is_empty());
734 }
735
736 #[test]
737 fn deserialize_preset_hexagonal_json() {
738 let json = r#"{ "preset": "hexagonal" }"#;
739 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
740 assert_eq!(config.preset, Some(BoundaryPreset::Hexagonal));
741 }
742
743 #[test]
744 fn deserialize_preset_feature_sliced_json() {
745 let json = r#"{ "preset": "feature-sliced" }"#;
746 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
747 assert_eq!(config.preset, Some(BoundaryPreset::FeatureSliced));
748 }
749
750 #[test]
751 fn deserialize_preset_toml() {
752 let toml_str = r#"preset = "layered""#;
753 let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
754 assert_eq!(config.preset, Some(BoundaryPreset::Layered));
755 }
756
757 #[test]
758 fn deserialize_invalid_preset_rejected() {
759 let json = r#"{ "preset": "invalid_preset" }"#;
760 let result: Result<BoundaryConfig, _> = serde_json::from_str(json);
761 assert!(result.is_err());
762 }
763
764 #[test]
765 fn preset_absent_by_default() {
766 let config = BoundaryConfig::default();
767 assert!(config.preset.is_none());
768 assert!(config.is_empty());
769 }
770
771 #[test]
772 fn preset_makes_config_non_empty() {
773 let config = BoundaryConfig {
774 preset: Some(BoundaryPreset::Layered),
775 zones: vec![],
776 rules: vec![],
777 };
778 assert!(!config.is_empty());
779 }
780
781 #[test]
784 fn expand_layered_produces_four_zones() {
785 let mut config = BoundaryConfig {
786 preset: Some(BoundaryPreset::Layered),
787 zones: vec![],
788 rules: vec![],
789 };
790 config.expand("src");
791 assert_eq!(config.zones.len(), 4);
792 assert_eq!(config.rules.len(), 4);
793 assert!(config.preset.is_none(), "preset cleared after expand");
794 assert_eq!(config.zones[0].name, "presentation");
795 assert_eq!(config.zones[0].patterns, vec!["src/presentation/**"]);
796 }
797
798 #[test]
799 fn expand_layered_rules_correct() {
800 let mut config = BoundaryConfig {
801 preset: Some(BoundaryPreset::Layered),
802 zones: vec![],
803 rules: vec![],
804 };
805 config.expand("src");
806 let pres_rule = config
808 .rules
809 .iter()
810 .find(|r| r.from == "presentation")
811 .unwrap();
812 assert_eq!(pres_rule.allow, vec!["application"]);
813 let app_rule = config
815 .rules
816 .iter()
817 .find(|r| r.from == "application")
818 .unwrap();
819 assert_eq!(app_rule.allow, vec!["domain"]);
820 let dom_rule = config.rules.iter().find(|r| r.from == "domain").unwrap();
822 assert!(dom_rule.allow.is_empty());
823 let infra_rule = config
825 .rules
826 .iter()
827 .find(|r| r.from == "infrastructure")
828 .unwrap();
829 assert_eq!(infra_rule.allow, vec!["domain", "application"]);
830 }
831
832 #[test]
833 fn expand_hexagonal_produces_three_zones() {
834 let mut config = BoundaryConfig {
835 preset: Some(BoundaryPreset::Hexagonal),
836 zones: vec![],
837 rules: vec![],
838 };
839 config.expand("src");
840 assert_eq!(config.zones.len(), 3);
841 assert_eq!(config.rules.len(), 3);
842 assert_eq!(config.zones[0].name, "adapters");
843 assert_eq!(config.zones[1].name, "ports");
844 assert_eq!(config.zones[2].name, "domain");
845 }
846
847 #[test]
848 fn expand_feature_sliced_produces_six_zones() {
849 let mut config = BoundaryConfig {
850 preset: Some(BoundaryPreset::FeatureSliced),
851 zones: vec![],
852 rules: vec![],
853 };
854 config.expand("src");
855 assert_eq!(config.zones.len(), 6);
856 assert_eq!(config.rules.len(), 6);
857 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
859 assert_eq!(
860 app_rule.allow,
861 vec!["pages", "widgets", "features", "entities", "shared"]
862 );
863 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
865 assert!(shared_rule.allow.is_empty());
866 let ent_rule = config.rules.iter().find(|r| r.from == "entities").unwrap();
868 assert_eq!(ent_rule.allow, vec!["shared"]);
869 }
870
871 #[test]
872 fn expand_bulletproof_produces_four_zones() {
873 let mut config = BoundaryConfig {
874 preset: Some(BoundaryPreset::Bulletproof),
875 zones: vec![],
876 rules: vec![],
877 };
878 config.expand("src");
879 assert_eq!(config.zones.len(), 4);
880 assert_eq!(config.rules.len(), 4);
881 assert_eq!(config.zones[0].name, "app");
882 assert_eq!(config.zones[1].name, "features");
883 assert_eq!(config.zones[2].name, "shared");
884 assert_eq!(config.zones[3].name, "server");
885 assert!(config.zones[2].patterns.len() > 1);
887 assert!(
888 config.zones[2]
889 .patterns
890 .contains(&"src/components/**".to_string())
891 );
892 assert!(
893 config.zones[2]
894 .patterns
895 .contains(&"src/hooks/**".to_string())
896 );
897 assert!(config.zones[2].patterns.contains(&"src/lib/**".to_string()));
898 assert!(
899 config.zones[2]
900 .patterns
901 .contains(&"src/providers/**".to_string())
902 );
903 }
904
905 #[test]
906 fn expand_bulletproof_rules_correct() {
907 let mut config = BoundaryConfig {
908 preset: Some(BoundaryPreset::Bulletproof),
909 zones: vec![],
910 rules: vec![],
911 };
912 config.expand("src");
913 let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
915 assert_eq!(app_rule.allow, vec!["features", "shared", "server"]);
916 let feat_rule = config.rules.iter().find(|r| r.from == "features").unwrap();
918 assert_eq!(feat_rule.allow, vec!["shared", "server"]);
919 let srv_rule = config.rules.iter().find(|r| r.from == "server").unwrap();
921 assert_eq!(srv_rule.allow, vec!["shared"]);
922 let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
924 assert!(shared_rule.allow.is_empty());
925 }
926
927 #[test]
928 fn expand_bulletproof_then_resolve_classifies() {
929 let mut config = BoundaryConfig {
930 preset: Some(BoundaryPreset::Bulletproof),
931 zones: vec![],
932 rules: vec![],
933 };
934 config.expand("src");
935 let resolved = config.resolve();
936 assert_eq!(
937 resolved.classify_zone("src/app/dashboard/page.tsx"),
938 Some("app")
939 );
940 assert_eq!(
941 resolved.classify_zone("src/features/auth/hooks/useAuth.ts"),
942 Some("features")
943 );
944 assert_eq!(
945 resolved.classify_zone("src/components/Button/Button.tsx"),
946 Some("shared")
947 );
948 assert_eq!(
949 resolved.classify_zone("src/hooks/useFormatters.ts"),
950 Some("shared")
951 );
952 assert_eq!(
953 resolved.classify_zone("src/server/db/schema/users.ts"),
954 Some("server")
955 );
956 assert!(resolved.is_import_allowed("features", "shared"));
958 assert!(resolved.is_import_allowed("features", "server"));
959 assert!(!resolved.is_import_allowed("features", "app"));
960 assert!(!resolved.is_import_allowed("shared", "features"));
961 assert!(!resolved.is_import_allowed("server", "features"));
962 }
963
964 #[test]
965 fn expand_uses_custom_source_root() {
966 let mut config = BoundaryConfig {
967 preset: Some(BoundaryPreset::Hexagonal),
968 zones: vec![],
969 rules: vec![],
970 };
971 config.expand("lib");
972 assert_eq!(config.zones[0].patterns, vec!["lib/adapters/**"]);
973 assert_eq!(config.zones[2].patterns, vec!["lib/domain/**"]);
974 }
975
976 #[test]
979 fn user_zone_replaces_preset_zone() {
980 let mut config = BoundaryConfig {
981 preset: Some(BoundaryPreset::Hexagonal),
982 zones: vec![BoundaryZone {
983 name: "domain".to_string(),
984 patterns: vec!["src/core/**".to_string()],
985 root: None,
986 }],
987 rules: vec![],
988 };
989 config.expand("src");
990 assert_eq!(config.zones.len(), 3);
992 let domain = config.zones.iter().find(|z| z.name == "domain").unwrap();
993 assert_eq!(domain.patterns, vec!["src/core/**"]);
994 }
995
996 #[test]
997 fn user_zone_adds_to_preset() {
998 let mut config = BoundaryConfig {
999 preset: Some(BoundaryPreset::Hexagonal),
1000 zones: vec![BoundaryZone {
1001 name: "shared".to_string(),
1002 patterns: vec!["src/shared/**".to_string()],
1003 root: None,
1004 }],
1005 rules: vec![],
1006 };
1007 config.expand("src");
1008 assert_eq!(config.zones.len(), 4); assert!(config.zones.iter().any(|z| z.name == "shared"));
1010 }
1011
1012 #[test]
1013 fn user_rule_replaces_preset_rule() {
1014 let mut config = BoundaryConfig {
1015 preset: Some(BoundaryPreset::Hexagonal),
1016 zones: vec![],
1017 rules: vec![BoundaryRule {
1018 from: "adapters".to_string(),
1019 allow: vec!["ports".to_string(), "domain".to_string()],
1020 }],
1021 };
1022 config.expand("src");
1023 let adapter_rule = config.rules.iter().find(|r| r.from == "adapters").unwrap();
1024 assert_eq!(adapter_rule.allow, vec!["ports", "domain"]);
1026 assert_eq!(
1028 config.rules.iter().filter(|r| r.from == "adapters").count(),
1029 1
1030 );
1031 }
1032
1033 #[test]
1034 fn expand_without_preset_is_noop() {
1035 let mut config = BoundaryConfig {
1036 preset: None,
1037 zones: vec![BoundaryZone {
1038 name: "ui".to_string(),
1039 patterns: vec!["src/ui/**".to_string()],
1040 root: None,
1041 }],
1042 rules: vec![],
1043 };
1044 config.expand("src");
1045 assert_eq!(config.zones.len(), 1);
1046 assert_eq!(config.zones[0].name, "ui");
1047 }
1048
1049 #[test]
1050 fn expand_then_validate_succeeds() {
1051 let mut config = BoundaryConfig {
1052 preset: Some(BoundaryPreset::Layered),
1053 zones: vec![],
1054 rules: vec![],
1055 };
1056 config.expand("src");
1057 assert!(config.validate_zone_references().is_empty());
1058 }
1059
1060 #[test]
1061 fn expand_then_resolve_classifies() {
1062 let mut config = BoundaryConfig {
1063 preset: Some(BoundaryPreset::Hexagonal),
1064 zones: vec![],
1065 rules: vec![],
1066 };
1067 config.expand("src");
1068 let resolved = config.resolve();
1069 assert_eq!(
1070 resolved.classify_zone("src/adapters/http/handler.ts"),
1071 Some("adapters")
1072 );
1073 assert_eq!(resolved.classify_zone("src/domain/user.ts"), Some("domain"));
1074 assert!(!resolved.is_import_allowed("adapters", "domain"));
1075 assert!(resolved.is_import_allowed("adapters", "ports"));
1076 }
1077
1078 #[test]
1079 fn preset_name_returns_correct_string() {
1080 let config = BoundaryConfig {
1081 preset: Some(BoundaryPreset::FeatureSliced),
1082 zones: vec![],
1083 rules: vec![],
1084 };
1085 assert_eq!(config.preset_name(), Some("feature-sliced"));
1086
1087 let empty = BoundaryConfig::default();
1088 assert_eq!(empty.preset_name(), None);
1089 }
1090
1091 #[test]
1092 fn preset_name_all_variants() {
1093 let cases = [
1094 (BoundaryPreset::Layered, "layered"),
1095 (BoundaryPreset::Hexagonal, "hexagonal"),
1096 (BoundaryPreset::FeatureSliced, "feature-sliced"),
1097 (BoundaryPreset::Bulletproof, "bulletproof"),
1098 ];
1099 for (preset, expected_name) in cases {
1100 let config = BoundaryConfig {
1101 preset: Some(preset),
1102 zones: vec![],
1103 rules: vec![],
1104 };
1105 assert_eq!(
1106 config.preset_name(),
1107 Some(expected_name),
1108 "preset_name() mismatch for variant"
1109 );
1110 }
1111 }
1112
1113 #[test]
1116 fn resolved_boundary_config_empty() {
1117 let resolved = ResolvedBoundaryConfig::default();
1118 assert!(resolved.is_empty());
1119 }
1120
1121 #[test]
1122 fn resolved_boundary_config_with_zones_not_empty() {
1123 let config = BoundaryConfig {
1124 preset: None,
1125 zones: vec![BoundaryZone {
1126 name: "ui".to_string(),
1127 patterns: vec!["src/ui/**".to_string()],
1128 root: None,
1129 }],
1130 rules: vec![],
1131 };
1132 let resolved = config.resolve();
1133 assert!(!resolved.is_empty());
1134 }
1135
1136 #[test]
1139 fn boundary_config_with_only_rules_is_empty() {
1140 let config = BoundaryConfig {
1143 preset: None,
1144 zones: vec![],
1145 rules: vec![BoundaryRule {
1146 from: "ui".to_string(),
1147 allow: vec!["db".to_string()],
1148 }],
1149 };
1150 assert!(config.is_empty());
1151 }
1152
1153 #[test]
1154 fn boundary_config_with_zones_not_empty() {
1155 let config = BoundaryConfig {
1156 preset: None,
1157 zones: vec![BoundaryZone {
1158 name: "ui".to_string(),
1159 patterns: vec![],
1160 root: None,
1161 }],
1162 rules: vec![],
1163 };
1164 assert!(!config.is_empty());
1165 }
1166
1167 #[test]
1170 fn zone_with_multiple_patterns_matches_any() {
1171 let config = BoundaryConfig {
1172 preset: None,
1173 zones: vec![BoundaryZone {
1174 name: "ui".to_string(),
1175 patterns: vec![
1176 "src/components/**".to_string(),
1177 "src/pages/**".to_string(),
1178 "src/views/**".to_string(),
1179 ],
1180 root: None,
1181 }],
1182 rules: vec![],
1183 };
1184 let resolved = config.resolve();
1185 assert_eq!(
1186 resolved.classify_zone("src/components/Button.tsx"),
1187 Some("ui")
1188 );
1189 assert_eq!(resolved.classify_zone("src/pages/Home.tsx"), Some("ui"));
1190 assert_eq!(
1191 resolved.classify_zone("src/views/Dashboard.tsx"),
1192 Some("ui")
1193 );
1194 assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
1195 }
1196
1197 #[test]
1200 fn validate_zone_references_multiple_errors() {
1201 let config = BoundaryConfig {
1202 preset: None,
1203 zones: vec![BoundaryZone {
1204 name: "ui".to_string(),
1205 patterns: vec![],
1206 root: None,
1207 }],
1208 rules: vec![
1209 BoundaryRule {
1210 from: "nonexistent_from".to_string(),
1211 allow: vec!["nonexistent_allow".to_string()],
1212 },
1213 BoundaryRule {
1214 from: "ui".to_string(),
1215 allow: vec!["also_nonexistent".to_string()],
1216 },
1217 ],
1218 };
1219 let errors = config.validate_zone_references();
1220 assert_eq!(errors.len(), 3);
1223 }
1224
1225 #[test]
1228 fn expand_feature_sliced_with_custom_root() {
1229 let mut config = BoundaryConfig {
1230 preset: Some(BoundaryPreset::FeatureSliced),
1231 zones: vec![],
1232 rules: vec![],
1233 };
1234 config.expand("lib");
1235 assert_eq!(config.zones[0].patterns, vec!["lib/app/**"]);
1236 assert_eq!(config.zones[5].patterns, vec!["lib/shared/**"]);
1237 }
1238
1239 #[test]
1242 fn zone_not_in_rules_is_unrestricted() {
1243 let config = BoundaryConfig {
1244 preset: None,
1245 zones: vec![
1246 BoundaryZone {
1247 name: "a".to_string(),
1248 patterns: vec![],
1249 root: None,
1250 },
1251 BoundaryZone {
1252 name: "b".to_string(),
1253 patterns: vec![],
1254 root: None,
1255 },
1256 BoundaryZone {
1257 name: "c".to_string(),
1258 patterns: vec![],
1259 root: None,
1260 },
1261 ],
1262 rules: vec![BoundaryRule {
1263 from: "a".to_string(),
1264 allow: vec!["b".to_string()],
1265 }],
1266 };
1267 let resolved = config.resolve();
1268 assert!(resolved.is_import_allowed("a", "b"));
1270 assert!(!resolved.is_import_allowed("a", "c"));
1271 assert!(resolved.is_import_allowed("b", "a"));
1273 assert!(resolved.is_import_allowed("b", "c"));
1274 assert!(resolved.is_import_allowed("c", "a"));
1276 }
1277
1278 #[test]
1281 fn boundary_preset_json_roundtrip() {
1282 let presets = [
1283 BoundaryPreset::Layered,
1284 BoundaryPreset::Hexagonal,
1285 BoundaryPreset::FeatureSliced,
1286 BoundaryPreset::Bulletproof,
1287 ];
1288 for preset in presets {
1289 let json = serde_json::to_string(&preset).unwrap();
1290 let restored: BoundaryPreset = serde_json::from_str(&json).unwrap();
1291 assert_eq!(restored, preset);
1292 }
1293 }
1294
1295 #[test]
1296 fn deserialize_preset_bulletproof_json() {
1297 let json = r#"{ "preset": "bulletproof" }"#;
1298 let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1299 assert_eq!(config.preset, Some(BoundaryPreset::Bulletproof));
1300 }
1301
1302 #[test]
1305 fn resolve_skips_invalid_zone_glob() {
1306 let config = BoundaryConfig {
1307 preset: None,
1308 zones: vec![BoundaryZone {
1309 name: "broken".to_string(),
1310 patterns: vec!["[invalid".to_string()],
1311 root: None,
1312 }],
1313 rules: vec![],
1314 };
1315 let resolved = config.resolve();
1316 assert!(!resolved.is_empty());
1318 assert_eq!(resolved.classify_zone("anything.ts"), None);
1319 }
1320}