Skip to main content

fallow_config/config/
boundaries.rs

1//! Architecture boundary zone and rule definitions.
2
3use globset::Glob;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// Built-in architecture presets.
8///
9/// Each preset expands into a set of zones and import rules for a common
10/// architecture pattern. User-defined zones and rules merge on top of the
11/// preset defaults (zones with the same name replace the preset zone;
12/// rules with the same `from` replace the preset rule).
13///
14/// # Examples
15///
16/// ```
17/// use fallow_config::BoundaryPreset;
18///
19/// let preset: BoundaryPreset = serde_json::from_str(r#""layered""#).unwrap();
20/// assert!(matches!(preset, BoundaryPreset::Layered));
21/// ```
22#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
23#[serde(rename_all = "kebab-case")]
24pub enum BoundaryPreset {
25    /// Classic layered architecture: presentation → application → domain ← infrastructure.
26    /// Infrastructure may also import from application (common in DI frameworks).
27    Layered,
28    /// Hexagonal / ports-and-adapters: adapters → ports → domain.
29    Hexagonal,
30    /// Feature-Sliced Design: app > pages > widgets > features > entities > shared.
31    /// Each layer may only import from layers below it.
32    FeatureSliced,
33    /// Bulletproof React: app → features → shared + server.
34    /// Feature modules are isolated from each other; shared utilities and server
35    /// infrastructure form the base layers.
36    Bulletproof,
37}
38
39impl BoundaryPreset {
40    /// Expand the preset into default zones and rules.
41    ///
42    /// `source_root` is the directory prefix for zone patterns (e.g., `"src"`, `"lib"`).
43    /// Patterns are generated as `{source_root}/{zone_name}/**`.
44    #[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/// Architecture boundary configuration.
152///
153/// Defines zones (directory groupings) and rules (which zones may import from which).
154/// Optionally uses a built-in preset as a starting point.
155///
156/// # Examples
157///
158/// ```
159/// use fallow_config::BoundaryConfig;
160///
161/// let json = r#"{
162///     "zones": [
163///         { "name": "ui", "patterns": ["src/components/**"] },
164///         { "name": "db", "patterns": ["src/db/**"] }
165///     ],
166///     "rules": [
167///         { "from": "ui", "allow": ["db"] }
168///     ]
169/// }"#;
170/// let config: BoundaryConfig = serde_json::from_str(json).unwrap();
171/// assert_eq!(config.zones.len(), 2);
172/// assert_eq!(config.rules.len(), 1);
173/// ```
174///
175/// Using a preset:
176///
177/// ```
178/// use fallow_config::BoundaryConfig;
179///
180/// let json = r#"{ "preset": "layered" }"#;
181/// let mut config: BoundaryConfig = serde_json::from_str(json).unwrap();
182/// config.expand("src");
183/// assert_eq!(config.zones.len(), 4);
184/// assert_eq!(config.rules.len(), 4);
185/// ```
186#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
187#[serde(rename_all = "camelCase")]
188pub struct BoundaryConfig {
189    /// Built-in architecture preset. When set, expands into default zones and rules.
190    /// User-defined zones and rules merge on top: zones with the same name replace
191    /// the preset zone; rules with the same `from` replace the preset rule.
192    /// Preset patterns use `{rootDir}/{zone}/**` where rootDir is auto-detected
193    /// from tsconfig.json (falls back to `src`).
194    /// Note: preset patterns are flat (`src/<zone>/**`). For monorepos with
195    /// per-package source directories, define zones explicitly instead.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub preset: Option<BoundaryPreset>,
198    /// Named zones mapping directory patterns to architectural layers.
199    #[serde(default)]
200    pub zones: Vec<BoundaryZone>,
201    /// Import rules between zones. A zone with a rule entry can only import
202    /// from the listed zones (plus itself). A zone without a rule entry is unrestricted.
203    #[serde(default)]
204    pub rules: Vec<BoundaryRule>,
205}
206
207/// A named zone grouping files by directory pattern.
208#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
209#[serde(rename_all = "camelCase")]
210pub struct BoundaryZone {
211    /// Zone identifier referenced in rules (e.g., `"ui"`, `"database"`, `"shared"`).
212    pub name: String,
213    /// Glob patterns (relative to project root) that define zone membership.
214    /// A file belongs to the first zone whose pattern matches.
215    pub patterns: Vec<String>,
216    /// Optional subtree scope. When set, patterns are relative to this directory
217    /// instead of the project root. Useful for monorepos with per-package boundaries.
218    /// Reserved for future use — currently ignored by the detector.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub root: Option<String>,
221}
222
223/// An import rule between zones.
224#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
225#[serde(rename_all = "camelCase")]
226pub struct BoundaryRule {
227    /// The zone this rule applies to (the importing side).
228    pub from: String,
229    /// Zones that `from` is allowed to import from. Self-imports are always allowed.
230    /// An empty list means the zone may not import from any other zone.
231    #[serde(default)]
232    pub allow: Vec<String>,
233}
234
235/// Resolved boundary config with pre-compiled glob matchers.
236#[derive(Debug, Default)]
237pub struct ResolvedBoundaryConfig {
238    /// Zones with compiled glob matchers for fast file classification.
239    pub zones: Vec<ResolvedZone>,
240    /// Rules indexed by source zone name.
241    pub rules: Vec<ResolvedBoundaryRule>,
242}
243
244/// A zone with pre-compiled glob matchers.
245#[derive(Debug)]
246pub struct ResolvedZone {
247    /// Zone identifier.
248    pub name: String,
249    /// Pre-compiled glob matchers for zone membership.
250    pub matchers: Vec<globset::GlobMatcher>,
251}
252
253/// A resolved boundary rule.
254#[derive(Debug)]
255pub struct ResolvedBoundaryRule {
256    /// The zone this rule restricts.
257    pub from_zone: String,
258    /// Zones that `from_zone` is allowed to import from.
259    pub allowed_zones: Vec<String>,
260}
261
262impl BoundaryConfig {
263    /// Whether any boundaries are configured (including via preset).
264    #[must_use]
265    pub fn is_empty(&self) -> bool {
266        self.preset.is_none() && self.zones.is_empty()
267    }
268
269    /// Expand the preset (if set) into zones and rules, merging user overrides on top.
270    ///
271    /// `source_root` is the directory prefix for preset zone patterns (e.g., `"src"`).
272    /// After expansion, `self.preset` is cleared and all zones/rules are explicit.
273    ///
274    /// Merge semantics:
275    /// - User zones with the same name as a preset zone **replace** the preset zone entirely.
276    /// - User rules with the same `from` as a preset rule **replace** the preset rule.
277    /// - User zones/rules with new names **add** to the preset set.
278    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        // Build set of user-defined zone names for override detection.
286        let user_zone_names: rustc_hash::FxHashSet<&str> =
287            self.zones.iter().map(|z| z.name.as_str()).collect();
288
289        // Start with preset zones, replacing any that the user overrides.
290        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        // Append all user zones (both overrides and additions).
305        merged_zones.append(&mut self.zones);
306        self.zones = merged_zones;
307
308        // Build set of user-defined rule `from` names for override detection.
309        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    /// Return the preset name if one is configured but not yet expanded.
331    #[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    /// Validate that all zone names referenced in rules are defined in `zones`.
342    /// Returns a list of (rule_index, undefined_zone_name) pairs.
343    #[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    /// Resolve into compiled form with pre-built glob matchers.
363    /// Invalid glob patterns are logged and skipped.
364    #[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    /// Whether any boundaries are configured.
407    #[must_use]
408    pub fn is_empty(&self) -> bool {
409        self.zones.is_empty()
410    }
411
412    /// Classify a file path into a zone. Returns the first matching zone name.
413    /// Path should be relative to the project root with forward slashes.
414    #[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    /// Check if an import from `from_zone` to `to_zone` is allowed.
425    /// Returns `true` if the import is permitted.
426    #[must_use]
427    pub fn is_import_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
428        // Self-imports are always allowed.
429        if from_zone == to_zone {
430            return true;
431        }
432
433        // Find the rule for the source zone.
434        let rule = self.rules.iter().find(|r| r.from_zone == from_zone);
435
436        match rule {
437            // Zone has no rule entry — unrestricted.
438            None => true,
439            // Zone has a rule — check the allowlist.
440            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    // ── Preset deserialization ─────────────────────────────────
727
728    #[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    // ── Preset expansion ───────────────────────────────────────
782
783    #[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        // presentation → application only
807        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        // application → domain only
814        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        // domain → nothing
821        let dom_rule = config.rules.iter().find(|r| r.from == "domain").unwrap();
822        assert!(dom_rule.allow.is_empty());
823        // infrastructure → domain + application (DI-friendly)
824        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        // app can import everything below
858        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        // shared imports nothing
864        let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
865        assert!(shared_rule.allow.is_empty());
866        // entities → shared only
867        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        // shared zone has multiple patterns
886        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        // app → features, shared, server
914        let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
915        assert_eq!(app_rule.allow, vec!["features", "shared", "server"]);
916        // features → shared, server
917        let feat_rule = config.rules.iter().find(|r| r.from == "features").unwrap();
918        assert_eq!(feat_rule.allow, vec!["shared", "server"]);
919        // server → shared
920        let srv_rule = config.rules.iter().find(|r| r.from == "server").unwrap();
921        assert_eq!(srv_rule.allow, vec!["shared"]);
922        // shared → nothing (isolated)
923        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        // features cannot import shared directly — only via allowed rules
957        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    // ── Preset merge behavior ──────────────────────────────────
977
978    #[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        // 3 zones total: adapters + ports from preset, domain from user
991        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); // 3 preset + 1 user
1009        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        // User rule allows both ports and domain (preset only allowed ports)
1025        assert_eq!(adapter_rule.allow, vec!["ports", "domain"]);
1026        // Other preset rules untouched
1027        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    // ── ResolvedBoundaryConfig::is_empty ────────────────────────────
1114
1115    #[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    // ── BoundaryConfig::is_empty edge cases ─────────────────────────
1137
1138    #[test]
1139    fn boundary_config_with_only_rules_is_empty() {
1140        // Having rules but no zones/preset is still "empty" since rules without zones
1141        // cannot produce boundary violations.
1142        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    // ── Multiple zone patterns ──────────────────────────────────────
1168
1169    #[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    // ── validate_zone_references with multiple errors ───────────────
1198
1199    #[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        // Rule 0: invalid "from" + invalid "allow" = 2 errors
1221        // Rule 1: valid "from", invalid "allow" = 1 error
1222        assert_eq!(errors.len(), 3);
1223    }
1224
1225    // ── Preset expansion with custom source root ────────────────────
1226
1227    #[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    // ── is_import_allowed for zone not in rules (unrestricted) ──────
1240
1241    #[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        // "a" is restricted: can import from "b" but not "c"
1269        assert!(resolved.is_import_allowed("a", "b"));
1270        assert!(!resolved.is_import_allowed("a", "c"));
1271        // "b" has no rule entry: unrestricted
1272        assert!(resolved.is_import_allowed("b", "a"));
1273        assert!(resolved.is_import_allowed("b", "c"));
1274        // "c" has no rule entry: unrestricted
1275        assert!(resolved.is_import_allowed("c", "a"));
1276    }
1277
1278    // ── Preset serialization/deserialization roundtrip ───────────────
1279
1280    #[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    // ── Zone with invalid glob ──────────────────────────────────────
1303
1304    #[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        // Zone exists but has no valid matchers, so no file can be classified into it
1317        assert!(!resolved.is_empty());
1318        assert_eq!(resolved.classify_zone("anything.ts"), None);
1319    }
1320}