Skip to main content

fallow_config/config/
boundaries.rs

1//! Architecture boundary zone and rule definitions.
2
3use std::path::Path;
4use std::sync::{Mutex, OnceLock};
5
6use globset::Glob;
7use rustc_hash::FxHashSet;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// Process-local dedup state for the
12/// `patterns + autoDiscover` footgun warning. Keyed on the offending zone
13/// name. The warn fires once per (process, zone name) so long-running hosts
14/// (`fallow watch`, the LSP, the NAPI worker, the MCP server) do not spam
15/// the same diagnostic on every re-analysis. Restart re-arms the warning.
16static AUTO_DISCOVER_PATTERNS_WARN_SEEN: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
17
18/// Returns `true` if the warn for `zone_name` has not yet fired in this
19/// process, `false` if it has already fired. A poisoned mutex falls back to
20/// "would fire" so the user still sees one diagnostic per session.
21fn record_auto_discover_patterns_warn_seen(zone_name: &str) -> bool {
22    let seen = AUTO_DISCOVER_PATTERNS_WARN_SEEN.get_or_init(|| Mutex::new(FxHashSet::default()));
23    seen.lock()
24        .map_or(true, |mut set| set.insert(zone_name.to_owned()))
25}
26
27/// Built-in architecture presets.
28///
29/// Each preset expands into a set of zones and import rules for a common
30/// architecture pattern. User-defined zones and rules merge on top of the
31/// preset defaults (zones with the same name replace the preset zone;
32/// rules with the same `from` replace the preset rule).
33///
34/// # Examples
35///
36/// ```
37/// use fallow_config::BoundaryPreset;
38///
39/// let preset: BoundaryPreset = serde_json::from_str(r#""layered""#).unwrap();
40/// assert!(matches!(preset, BoundaryPreset::Layered));
41/// ```
42#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
43#[serde(rename_all = "kebab-case")]
44pub enum BoundaryPreset {
45    /// Classic layered architecture: presentation → application → domain ← infrastructure.
46    /// Infrastructure may also import from application (common in DI frameworks).
47    Layered,
48    /// Hexagonal / ports-and-adapters: adapters → ports → domain.
49    Hexagonal,
50    /// Feature-Sliced Design: app > pages > widgets > features > entities > shared.
51    /// Each layer may only import from layers below it.
52    FeatureSliced,
53    /// Bulletproof React: app → features → shared + server.
54    /// Feature modules are isolated from each other via `autoDiscover`: every
55    /// immediate child of `src/features/` becomes its own `features/<name>` zone,
56    /// and cross-feature imports are reported as boundary violations.
57    ///
58    /// **Trade-off (intentional):** top-level files in `src/features/` (e.g.
59    /// `src/features/index.ts` barrel, `src/features/types.ts`) do NOT match any
60    /// child pattern and are unclassified, meaning they are unrestricted by the
61    /// preset. This is deliberate so feature barrels can re-export children
62    /// without producing false-positive `features → features/<child>` violations.
63    /// To classify top-level files strictly, override the `features` zone with
64    /// an explicit user definition that includes a `patterns` field.
65    Bulletproof,
66}
67
68impl BoundaryPreset {
69    /// Expand the preset into default zones and rules.
70    ///
71    /// `source_root` is the directory prefix for zone patterns (e.g., `"src"`, `"lib"`).
72    /// Patterns are generated as `{source_root}/{zone_name}/**`.
73    #[must_use]
74    pub fn default_config(&self, source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
75        match self {
76            Self::Layered => Self::layered_config(source_root),
77            Self::Hexagonal => Self::hexagonal_config(source_root),
78            Self::FeatureSliced => Self::feature_sliced_config(source_root),
79            Self::Bulletproof => Self::bulletproof_config(source_root),
80        }
81    }
82
83    fn zone(name: &str, source_root: &str) -> BoundaryZone {
84        BoundaryZone {
85            name: name.to_owned(),
86            patterns: vec![format!("{source_root}/{name}/**")],
87            auto_discover: vec![],
88            root: None,
89        }
90    }
91
92    fn rule(from: &str, allow: &[&str]) -> BoundaryRule {
93        BoundaryRule {
94            from: from.to_owned(),
95            allow: allow.iter().map(|s| (*s).to_owned()).collect(),
96        }
97    }
98
99    fn layered_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
100        let zones = vec![
101            Self::zone("presentation", source_root),
102            Self::zone("application", source_root),
103            Self::zone("domain", source_root),
104            Self::zone("infrastructure", source_root),
105        ];
106        let rules = vec![
107            Self::rule("presentation", &["application"]),
108            Self::rule("application", &["domain"]),
109            Self::rule("domain", &[]),
110            Self::rule("infrastructure", &["domain", "application"]),
111        ];
112        (zones, rules)
113    }
114
115    fn hexagonal_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
116        let zones = vec![
117            Self::zone("adapters", source_root),
118            Self::zone("ports", source_root),
119            Self::zone("domain", source_root),
120        ];
121        let rules = vec![
122            Self::rule("adapters", &["ports"]),
123            Self::rule("ports", &["domain"]),
124            Self::rule("domain", &[]),
125        ];
126        (zones, rules)
127    }
128
129    fn feature_sliced_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
130        let layer_names = ["app", "pages", "widgets", "features", "entities", "shared"];
131        let zones = layer_names
132            .iter()
133            .map(|name| Self::zone(name, source_root))
134            .collect();
135        let rules = layer_names
136            .iter()
137            .enumerate()
138            .map(|(i, name)| {
139                let below: Vec<&str> = layer_names[i + 1..].to_vec();
140                Self::rule(name, &below)
141            })
142            .collect();
143        (zones, rules)
144    }
145
146    fn bulletproof_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
147        let zones = vec![
148            Self::zone("app", source_root),
149            BoundaryZone {
150                // `features` is a logical group only: auto-discovered child
151                // zones (`features/<name>`) classify the actual files. Leaving
152                // `patterns` empty keeps top-level files in `src/features/`
153                // (typically a barrel like `src/features/index.ts`) unclassified
154                // so the barrel can re-export children without a cross-zone
155                // `features → features/<child>` false positive.
156                name: "features".to_owned(),
157                patterns: vec![],
158                auto_discover: vec![format!("{source_root}/features")],
159                root: None,
160            },
161            BoundaryZone {
162                name: "shared".to_owned(),
163                patterns: [
164                    "components",
165                    "hooks",
166                    "lib",
167                    "utils",
168                    "utilities",
169                    "providers",
170                    "shared",
171                    "types",
172                    "styles",
173                    "i18n",
174                ]
175                .iter()
176                .map(|dir| format!("{source_root}/{dir}/**"))
177                .collect(),
178                auto_discover: vec![],
179                root: None,
180            },
181            Self::zone("server", source_root),
182        ];
183        let rules = vec![
184            Self::rule("app", &["features", "shared", "server"]),
185            Self::rule("features", &["shared", "server"]),
186            Self::rule("server", &["shared"]),
187            Self::rule("shared", &[]),
188        ];
189        (zones, rules)
190    }
191}
192
193/// Architecture boundary configuration.
194///
195/// Defines zones (directory groupings) and rules (which zones may import from which).
196/// Optionally uses a built-in preset as a starting point.
197///
198/// # Examples
199///
200/// ```
201/// use fallow_config::BoundaryConfig;
202///
203/// let json = r#"{
204///     "zones": [
205///         { "name": "ui", "patterns": ["src/components/**"] },
206///         { "name": "db", "patterns": ["src/db/**"] }
207///     ],
208///     "rules": [
209///         { "from": "ui", "allow": ["db"] }
210///     ]
211/// }"#;
212/// let config: BoundaryConfig = serde_json::from_str(json).unwrap();
213/// assert_eq!(config.zones.len(), 2);
214/// assert_eq!(config.rules.len(), 1);
215/// ```
216///
217/// Using a preset:
218///
219/// ```
220/// use fallow_config::BoundaryConfig;
221///
222/// let json = r#"{ "preset": "layered" }"#;
223/// let mut config: BoundaryConfig = serde_json::from_str(json).unwrap();
224/// config.expand("src");
225/// assert_eq!(config.zones.len(), 4);
226/// assert_eq!(config.rules.len(), 4);
227/// ```
228#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
229#[serde(rename_all = "camelCase")]
230pub struct BoundaryConfig {
231    /// Built-in architecture preset. When set, expands into default zones and rules.
232    /// User-defined zones and rules merge on top: zones with the same name replace
233    /// the preset zone; rules with the same `from` replace the preset rule.
234    /// Preset patterns use `{rootDir}/{zone}/**` where rootDir is auto-detected
235    /// from tsconfig.json (falls back to `src`).
236    /// Note: preset patterns are flat (`src/<zone>/**`). For monorepos with
237    /// per-package source directories, define zones explicitly instead.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub preset: Option<BoundaryPreset>,
240    /// Named zones mapping directory patterns to architectural layers.
241    #[serde(default)]
242    pub zones: Vec<BoundaryZone>,
243    /// Import rules between zones. A zone with a rule entry can only import
244    /// from the listed zones (plus itself). A zone without a rule entry is unrestricted.
245    #[serde(default)]
246    pub rules: Vec<BoundaryRule>,
247}
248
249/// A named zone grouping files by directory pattern.
250#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
251#[serde(rename_all = "camelCase")]
252pub struct BoundaryZone {
253    /// Zone identifier referenced in rules (e.g., `"ui"`, `"database"`, `"shared"`).
254    pub name: String,
255    /// Glob patterns (relative to project root) that define zone membership.
256    /// A file belongs to the first zone whose pattern matches.
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub patterns: Vec<String>,
259    /// Directories whose immediate child directories should become separate
260    /// zones under this logical group.
261    ///
262    /// For example, `{ "name": "features", "autoDiscover": ["src/features"] }`
263    /// creates zones such as `features/auth` and `features/billing`, each with
264    /// a pattern for its own subtree. Rules that reference `features` expand to
265    /// every discovered child zone. If `patterns` is also set, the parent zone
266    /// remains as a fallback after discovered child zones.
267    #[serde(default, skip_serializing_if = "Vec::is_empty")]
268    pub auto_discover: Vec<String>,
269    /// Optional subtree scope for monorepo per-package boundaries.
270    ///
271    /// When set, the zone's `patterns` are matched against paths *relative*
272    /// to this directory rather than the project root. At classification
273    /// time, fallow checks that a candidate path starts with `root` and
274    /// strips that prefix before glob-matching the patterns against the
275    /// remainder. Files outside the subtree never match the zone.
276    ///
277    /// Useful for monorepos where each package has the same internal
278    /// directory layout: instead of writing `packages/app/src/**` and
279    /// `packages/core/src/**` (which collide on shared zone names), set
280    /// `root: "packages/app/"` and `patterns: ["src/**"]` per package.
281    ///
282    /// Trailing slash and leading `./` are normalized; backslashes are
283    /// converted to forward slashes. Patterns must NOT redundantly include
284    /// the root prefix: `root: "packages/app/"` with
285    /// `patterns: ["packages/app/src/**"]` is rejected with
286    /// `FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX` because patterns are
287    /// resolved relative to the root.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub root: Option<String>,
290}
291
292/// An import rule between zones.
293#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
294#[serde(rename_all = "camelCase")]
295pub struct BoundaryRule {
296    /// The zone this rule applies to (the importing side).
297    pub from: String,
298    /// Zones that `from` is allowed to import from. Self-imports are always allowed.
299    /// An empty list means the zone may not import from any other zone.
300    #[serde(default)]
301    pub allow: Vec<String>,
302}
303
304/// Resolved boundary config with pre-compiled glob matchers.
305#[derive(Debug, Default)]
306pub struct ResolvedBoundaryConfig {
307    /// Zones with compiled glob matchers for fast file classification.
308    pub zones: Vec<ResolvedZone>,
309    /// Rules indexed by source zone name.
310    pub rules: Vec<ResolvedBoundaryRule>,
311}
312
313/// A zone with pre-compiled glob matchers.
314#[derive(Debug)]
315pub struct ResolvedZone {
316    /// Zone identifier.
317    pub name: String,
318    /// Pre-compiled glob matchers for zone membership.
319    /// When `root` is set, matchers are applied to the path with the
320    /// `root` prefix stripped (subtree-relative patterns).
321    pub matchers: Vec<globset::GlobMatcher>,
322    /// Normalized subtree scope (e.g. `"packages/app/"`). When present,
323    /// only paths starting with this prefix can match this zone, and the
324    /// prefix is stripped before glob matching. Forward slashes only,
325    /// always trailing slash. `None` means patterns are matched against
326    /// the project-root-relative path as-is.
327    pub root: Option<String>,
328}
329
330/// A resolved boundary rule.
331#[derive(Debug)]
332pub struct ResolvedBoundaryRule {
333    /// The zone this rule restricts.
334    pub from_zone: String,
335    /// Zones that `from_zone` is allowed to import from.
336    pub allowed_zones: Vec<String>,
337}
338
339impl BoundaryConfig {
340    /// Whether any boundaries are configured (including via preset).
341    #[must_use]
342    pub fn is_empty(&self) -> bool {
343        self.preset.is_none() && self.zones.is_empty()
344    }
345
346    /// Expand the preset (if set) into zones and rules, merging user overrides on top.
347    ///
348    /// `source_root` is the directory prefix for preset zone patterns (e.g., `"src"`).
349    /// After expansion, `self.preset` is cleared and all zones/rules are explicit.
350    ///
351    /// Merge semantics:
352    /// - User zones with the same name as a preset zone **replace** the preset zone entirely.
353    /// - User rules with the same `from` as a preset rule **replace** the preset rule.
354    /// - User zones/rules with new names **add** to the preset set.
355    pub fn expand(&mut self, source_root: &str) {
356        let Some(preset) = self.preset.take() else {
357            return;
358        };
359
360        let (preset_zones, preset_rules) = preset.default_config(source_root);
361
362        // Build set of user-defined zone names for override detection.
363        let user_zone_names: rustc_hash::FxHashSet<&str> =
364            self.zones.iter().map(|z| z.name.as_str()).collect();
365
366        // Start with preset zones, replacing any that the user overrides.
367        let mut merged_zones: Vec<BoundaryZone> = preset_zones
368            .into_iter()
369            .filter(|pz| {
370                if user_zone_names.contains(pz.name.as_str()) {
371                    tracing::info!(
372                        "boundary preset: user zone '{}' replaces preset zone",
373                        pz.name
374                    );
375                    false
376                } else {
377                    true
378                }
379            })
380            .collect();
381        // Append all user zones (both overrides and additions).
382        merged_zones.append(&mut self.zones);
383        self.zones = merged_zones;
384
385        // Build set of user-defined rule `from` names for override detection.
386        let user_rule_sources: rustc_hash::FxHashSet<&str> =
387            self.rules.iter().map(|r| r.from.as_str()).collect();
388
389        let mut merged_rules: Vec<BoundaryRule> = preset_rules
390            .into_iter()
391            .filter(|pr| {
392                if user_rule_sources.contains(pr.from.as_str()) {
393                    tracing::info!(
394                        "boundary preset: user rule for '{}' replaces preset rule",
395                        pr.from
396                    );
397                    false
398                } else {
399                    true
400                }
401            })
402            .collect();
403        merged_rules.append(&mut self.rules);
404        self.rules = merged_rules;
405    }
406
407    /// Expand auto-discovered boundary groups into concrete child zones.
408    ///
409    /// A zone with `autoDiscover: ["src/features"]` discovers the immediate
410    /// child directories below `src/features` and emits child zones named
411    /// `zone_name/child`. Rules that reference the logical parent are expanded
412    /// to all discovered children. If the parent also has explicit `patterns`,
413    /// it is kept after the children as a fallback so child directories remain
414    /// isolated by first-match classification.
415    pub fn expand_auto_discover(&mut self, project_root: &Path) {
416        if self.zones.iter().all(|zone| zone.auto_discover.is_empty()) {
417            return;
418        }
419
420        let original_zones = std::mem::take(&mut self.zones);
421        let mut expanded_zones = Vec::new();
422        let mut group_expansions: rustc_hash::FxHashMap<String, Vec<String>> =
423            rustc_hash::FxHashMap::default();
424
425        for mut zone in original_zones {
426            if zone.auto_discover.is_empty() {
427                expanded_zones.push(zone);
428                continue;
429            }
430
431            let group_name = zone.name.clone();
432            let discovered_zones = discover_child_zones(project_root, &zone);
433            let mut expanded_names: Vec<String> = discovered_zones
434                .iter()
435                .map(|child| child.name.clone())
436                .collect();
437            expanded_zones.extend(discovered_zones);
438
439            if !zone.patterns.is_empty() {
440                // Footgun: top-level files inside the auto-discover directory
441                // (e.g. a `src/features/index.ts` barrel) fall back to the
442                // parent zone, and the parent rule's allow list typically does
443                // not include the discovered child zones, so re-exports from
444                // the barrel surface as `parent -> parent/<child>` false
445                // positives. The Bulletproof preset deliberately leaves
446                // `patterns` empty for this reason.
447                if record_auto_discover_patterns_warn_seen(&group_name) {
448                    tracing::warn!(
449                        "boundary zone '{group_name}' sets BOTH `patterns` and `autoDiscover`. \
450                         Top-level files matching the parent pattern fall back to zone '{group_name}' \
451                         and may produce false-positive cross-zone violations when they re-export \
452                         auto-discovered children (e.g. a `{group_name}/index.ts` barrel). \
453                         Drop `patterns` to leave top-level files unclassified, or define explicit \
454                         allow rules that include the discovered child zones."
455                    );
456                }
457                expanded_names.push(group_name.clone());
458                zone.auto_discover.clear();
459                expanded_zones.push(zone);
460            }
461
462            if !expanded_names.is_empty() {
463                group_expansions
464                    .entry(group_name)
465                    .or_default()
466                    .extend(expanded_names);
467            }
468        }
469
470        self.zones = expanded_zones;
471        if group_expansions.is_empty() {
472            return;
473        }
474
475        let original_rules = std::mem::take(&mut self.rules);
476        let mut generated_rules = Vec::new();
477        let mut explicit_rules = Vec::new();
478        for rule in original_rules {
479            let allow = expand_rule_allow(&rule.allow, &group_expansions);
480
481            if let Some(from_zones) = group_expansions.get(&rule.from) {
482                for from in from_zones {
483                    let expanded_rule = BoundaryRule {
484                        from: from.clone(),
485                        allow: allow.clone(),
486                    };
487                    if from == &rule.from {
488                        explicit_rules.push(expanded_rule);
489                    } else {
490                        generated_rules.push(expanded_rule);
491                    }
492                }
493            } else {
494                explicit_rules.push(BoundaryRule {
495                    from: rule.from,
496                    allow,
497                });
498            }
499        }
500
501        let mut expanded_rules = dedupe_rules_keep_last(generated_rules);
502        expanded_rules.extend(dedupe_rules_keep_last(explicit_rules));
503        self.rules = dedupe_rules_keep_last(expanded_rules);
504    }
505
506    /// Return the preset name if one is configured but not yet expanded.
507    #[must_use]
508    pub fn preset_name(&self) -> Option<&str> {
509        self.preset.as_ref().map(|p| match p {
510            BoundaryPreset::Layered => "layered",
511            BoundaryPreset::Hexagonal => "hexagonal",
512            BoundaryPreset::FeatureSliced => "feature-sliced",
513            BoundaryPreset::Bulletproof => "bulletproof",
514        })
515    }
516
517    /// Validate that no zone's pattern redundantly includes its `root`
518    /// prefix. Returns a list of error messages tagged with
519    /// `FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX`. Patterns are resolved
520    /// relative to the zone root, so prefixing the pattern with the same
521    /// root double-prefixes the path and never matches.
522    #[must_use]
523    pub fn validate_root_prefixes(&self) -> Vec<String> {
524        let mut errors = Vec::new();
525        for zone in &self.zones {
526            let Some(raw_root) = zone.root.as_deref() else {
527                continue;
528            };
529            let normalized = normalize_zone_root(raw_root);
530            // Skip empty-root zones: `""`, `"."`, and `"./"` all normalize to
531            // `""`, which behaves as no root at classification time. Without
532            // this guard `starts_with("")` is always true and every pattern
533            // produces a spurious redundant-prefix error.
534            if normalized.is_empty() {
535                continue;
536            }
537            for pattern in &zone.patterns {
538                let normalized_pattern = pattern.replace('\\', "/");
539                let stripped = normalized_pattern
540                    .strip_prefix("./")
541                    .unwrap_or(&normalized_pattern);
542                if stripped.starts_with(&normalized) {
543                    errors.push(format!(
544                        "FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX: zone '{}': pattern '{}' starts with the zone root '{}'. Patterns are now resolved relative to root; remove the redundant prefix from the pattern.",
545                        zone.name, pattern, normalized
546                    ));
547                }
548            }
549        }
550        errors
551    }
552
553    /// Validate that all zone names referenced in rules are defined in `zones`.
554    /// Returns a list of (rule_index, undefined_zone_name) pairs.
555    #[must_use]
556    pub fn validate_zone_references(&self) -> Vec<(usize, &str)> {
557        let zone_names: rustc_hash::FxHashSet<&str> =
558            self.zones.iter().map(|z| z.name.as_str()).collect();
559
560        let mut errors = Vec::new();
561        for (i, rule) in self.rules.iter().enumerate() {
562            if !zone_names.contains(rule.from.as_str()) {
563                errors.push((i, rule.from.as_str()));
564            }
565            for allowed in &rule.allow {
566                if !zone_names.contains(allowed.as_str()) {
567                    errors.push((i, allowed.as_str()));
568                }
569            }
570        }
571        errors
572    }
573
574    /// Resolve into compiled form with pre-built glob matchers.
575    /// Invalid glob patterns are logged and skipped.
576    #[must_use]
577    pub fn resolve(&self) -> ResolvedBoundaryConfig {
578        let zones = self
579            .zones
580            .iter()
581            .map(|zone| {
582                let matchers = zone
583                    .patterns
584                    .iter()
585                    .filter_map(|pattern| match Glob::new(pattern) {
586                        Ok(glob) => Some(glob.compile_matcher()),
587                        Err(e) => {
588                            tracing::warn!(
589                                "invalid boundary zone glob pattern '{}' in zone '{}': {e}",
590                                pattern,
591                                zone.name
592                            );
593                            None
594                        }
595                    })
596                    .collect();
597                let root = zone.root.as_deref().map(normalize_zone_root);
598                ResolvedZone {
599                    name: zone.name.clone(),
600                    matchers,
601                    root,
602                }
603            })
604            .collect();
605
606        let rules = self
607            .rules
608            .iter()
609            .map(|rule| ResolvedBoundaryRule {
610                from_zone: rule.from.clone(),
611                allowed_zones: rule.allow.clone(),
612            })
613            .collect();
614
615        ResolvedBoundaryConfig { zones, rules }
616    }
617}
618
619/// Normalize a zone `root` string into the canonical form used at
620/// classification time: forward slashes, no leading `./`, always a
621/// trailing slash. Empty / `"."` / `"./"` collapse to `""` which means
622/// "subtree is the project root" and effectively behaves like no root.
623fn normalize_zone_root(raw: &str) -> String {
624    let with_slashes = raw.replace('\\', "/");
625    let trimmed = with_slashes.trim_start_matches("./");
626    let no_dot = if trimmed == "." { "" } else { trimmed };
627    if no_dot.is_empty() {
628        String::new()
629    } else if no_dot.ends_with('/') {
630        no_dot.to_owned()
631    } else {
632        format!("{no_dot}/")
633    }
634}
635
636fn normalize_auto_discover_dir(raw: &str) -> Option<String> {
637    let with_slashes = raw.replace('\\', "/");
638    let trimmed = with_slashes.trim_start_matches("./").trim_end_matches('/');
639    if trimmed.starts_with('/') || trimmed.split('/').any(|part| part == "..") {
640        None
641    } else if trimmed == "." {
642        Some(String::new())
643    } else {
644        Some(trimmed.to_owned())
645    }
646}
647
648fn join_relative_path(prefix: &str, suffix: &str) -> String {
649    match (prefix.is_empty(), suffix.is_empty()) {
650        (true, true) => String::new(),
651        (true, false) => suffix.to_owned(),
652        (false, true) => prefix.trim_end_matches('/').to_owned(),
653        (false, false) => format!("{}/{}", prefix.trim_end_matches('/'), suffix),
654    }
655}
656
657fn discover_child_zones(project_root: &Path, zone: &BoundaryZone) -> Vec<BoundaryZone> {
658    let mut zones_by_name: rustc_hash::FxHashMap<String, BoundaryZone> =
659        rustc_hash::FxHashMap::default();
660    let normalized_root = zone
661        .root
662        .as_deref()
663        .map(normalize_zone_root)
664        .unwrap_or_default();
665
666    for raw_dir in &zone.auto_discover {
667        let Some(discover_dir) = normalize_auto_discover_dir(raw_dir) else {
668            tracing::warn!(
669                "invalid boundary autoDiscover path '{}' in zone '{}': paths must be project-relative and must not contain '..'",
670                raw_dir,
671                zone.name
672            );
673            continue;
674        };
675
676        let fs_relative = join_relative_path(&normalized_root, &discover_dir);
677        let absolute_dir = if fs_relative.is_empty() {
678            project_root.to_path_buf()
679        } else {
680            project_root.join(&fs_relative)
681        };
682        let Ok(entries) = std::fs::read_dir(&absolute_dir) else {
683            tracing::warn!(
684                "boundary zone '{}' autoDiscover path '{}' did not resolve to a readable directory",
685                zone.name,
686                raw_dir
687            );
688            continue;
689        };
690
691        let mut children: Vec<_> = entries
692            .filter_map(Result::ok)
693            .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_dir()))
694            .collect();
695        children.sort_by_key(|entry| entry.file_name());
696
697        for child in children {
698            let child_name = child.file_name().to_string_lossy().to_string();
699            if child_name.is_empty() {
700                continue;
701            }
702
703            let zone_name = format!("{}/{}", zone.name, child_name);
704            let child_pattern = format!("{}/**", join_relative_path(&discover_dir, &child_name));
705            let entry = zones_by_name
706                .entry(zone_name.clone())
707                .or_insert_with(|| BoundaryZone {
708                    name: zone_name,
709                    patterns: vec![],
710                    auto_discover: vec![],
711                    root: zone.root.clone(),
712                });
713            if !entry
714                .patterns
715                .iter()
716                .any(|pattern| pattern == &child_pattern)
717            {
718                entry.patterns.push(child_pattern);
719            }
720        }
721    }
722
723    let mut zones: Vec<_> = zones_by_name.into_values().collect();
724    zones.sort_by(|a, b| a.name.cmp(&b.name));
725    zones
726}
727
728fn expand_rule_allow(
729    allow: &[String],
730    group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
731) -> Vec<String> {
732    let mut expanded = Vec::new();
733    for zone in allow {
734        if let Some(expansion) = group_expansions.get(zone) {
735            expanded.extend(expansion.iter().cloned());
736        } else {
737            expanded.push(zone.clone());
738        }
739    }
740    dedupe_preserving_order(expanded)
741}
742
743fn dedupe_preserving_order(values: Vec<String>) -> Vec<String> {
744    let mut seen = rustc_hash::FxHashSet::default();
745    values
746        .into_iter()
747        .filter(|value| seen.insert(value.clone()))
748        .collect()
749}
750
751fn dedupe_rules_keep_last(rules: Vec<BoundaryRule>) -> Vec<BoundaryRule> {
752    let mut seen = rustc_hash::FxHashSet::default();
753    let mut deduped: Vec<_> = rules
754        .into_iter()
755        .rev()
756        .filter(|rule| seen.insert(rule.from.clone()))
757        .collect();
758    deduped.reverse();
759    deduped
760}
761
762impl ResolvedBoundaryConfig {
763    /// Whether any boundaries are configured.
764    #[must_use]
765    pub fn is_empty(&self) -> bool {
766        self.zones.is_empty()
767    }
768
769    /// Classify a file path into a zone. Returns the first matching zone name.
770    /// Path should be relative to the project root with forward slashes.
771    ///
772    /// When a zone declares a `root` (subtree scope), the path must start
773    /// with that prefix and the prefix is stripped before glob matching;
774    /// otherwise the zone is skipped. Zones without a `root` keep
775    /// project-root-relative behavior.
776    #[must_use]
777    pub fn classify_zone(&self, relative_path: &str) -> Option<&str> {
778        for zone in &self.zones {
779            let candidate: &str = match zone.root.as_deref() {
780                Some(root) if !root.is_empty() => {
781                    let Some(stripped) = relative_path.strip_prefix(root) else {
782                        continue;
783                    };
784                    stripped
785                }
786                _ => relative_path,
787            };
788            if zone.matchers.iter().any(|m| m.is_match(candidate)) {
789                return Some(&zone.name);
790            }
791        }
792        None
793    }
794
795    /// Check if an import from `from_zone` to `to_zone` is allowed.
796    /// Returns `true` if the import is permitted.
797    #[must_use]
798    pub fn is_import_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
799        // Self-imports are always allowed.
800        if from_zone == to_zone {
801            return true;
802        }
803
804        // Find the rule for the source zone.
805        let rule = self.rules.iter().find(|r| r.from_zone == from_zone);
806
807        match rule {
808            // Zone has no rule entry — unrestricted.
809            None => true,
810            // Zone has a rule — check the allowlist.
811            Some(r) => r.allowed_zones.iter().any(|z| z == to_zone),
812        }
813    }
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819
820    #[test]
821    fn empty_config() {
822        let config = BoundaryConfig::default();
823        assert!(config.is_empty());
824        assert!(config.validate_zone_references().is_empty());
825    }
826
827    #[test]
828    fn deserialize_json() {
829        let json = r#"{
830            "zones": [
831                { "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
832                { "name": "db", "patterns": ["src/db/**"] },
833                { "name": "shared", "patterns": ["src/shared/**"] }
834            ],
835            "rules": [
836                { "from": "ui", "allow": ["shared"] },
837                { "from": "db", "allow": ["shared"] }
838            ]
839        }"#;
840        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
841        assert_eq!(config.zones.len(), 3);
842        assert_eq!(config.rules.len(), 2);
843        assert_eq!(config.zones[0].name, "ui");
844        assert_eq!(
845            config.zones[0].patterns,
846            vec!["src/components/**", "src/pages/**"]
847        );
848        assert_eq!(config.rules[0].from, "ui");
849        assert_eq!(config.rules[0].allow, vec!["shared"]);
850    }
851
852    #[test]
853    fn deserialize_toml() {
854        let toml_str = r#"
855[[zones]]
856name = "ui"
857patterns = ["src/components/**"]
858
859[[zones]]
860name = "db"
861patterns = ["src/db/**"]
862
863[[rules]]
864from = "ui"
865allow = ["db"]
866"#;
867        let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
868        assert_eq!(config.zones.len(), 2);
869        assert_eq!(config.rules.len(), 1);
870    }
871
872    #[test]
873    fn auto_discover_expands_child_zones_and_parent_rules() {
874        let temp = tempfile::tempdir().unwrap();
875        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
876        std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
877
878        let mut config = BoundaryConfig {
879            preset: None,
880            zones: vec![
881                BoundaryZone {
882                    name: "app".to_string(),
883                    patterns: vec!["src/app/**".to_string()],
884                    auto_discover: vec![],
885                    root: None,
886                },
887                BoundaryZone {
888                    name: "features".to_string(),
889                    patterns: vec![],
890                    auto_discover: vec!["src/features".to_string()],
891                    root: None,
892                },
893            ],
894            rules: vec![
895                BoundaryRule {
896                    from: "app".to_string(),
897                    allow: vec!["features".to_string()],
898                },
899                BoundaryRule {
900                    from: "features".to_string(),
901                    allow: vec![],
902                },
903            ],
904        };
905
906        config.expand_auto_discover(temp.path());
907
908        let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
909        assert_eq!(zone_names, vec!["app", "features/auth", "features/billing"]);
910        assert_eq!(
911            config.zones[1].patterns,
912            vec!["src/features/auth/**".to_string()]
913        );
914        assert_eq!(
915            config.zones[2].patterns,
916            vec!["src/features/billing/**".to_string()]
917        );
918        let app_rule = config
919            .rules
920            .iter()
921            .find(|rule| rule.from == "app")
922            .expect("app rule should be preserved");
923        assert_eq!(
924            app_rule.allow,
925            vec!["features/auth".to_string(), "features/billing".to_string()]
926        );
927        assert!(
928            config
929                .rules
930                .iter()
931                .any(|rule| rule.from == "features/auth" && rule.allow.is_empty())
932        );
933        assert!(
934            config
935                .rules
936                .iter()
937                .any(|rule| rule.from == "features/billing" && rule.allow.is_empty())
938        );
939        assert!(config.validate_zone_references().is_empty());
940    }
941
942    #[test]
943    fn auto_discover_explicit_child_rule_wins_over_generated_parent_rule() {
944        let temp = tempfile::tempdir().unwrap();
945        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
946        std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
947
948        for explicit_child_first in [true, false] {
949            let explicit_child_rule = BoundaryRule {
950                from: "features/auth".to_string(),
951                allow: vec!["shared".to_string(), "features/billing".to_string()],
952            };
953            let parent_rule = BoundaryRule {
954                from: "features".to_string(),
955                allow: vec!["shared".to_string()],
956            };
957            let rules = if explicit_child_first {
958                vec![explicit_child_rule, parent_rule]
959            } else {
960                vec![parent_rule, explicit_child_rule]
961            };
962
963            let mut config = BoundaryConfig {
964                preset: None,
965                zones: vec![
966                    BoundaryZone {
967                        name: "features".to_string(),
968                        patterns: vec![],
969                        auto_discover: vec!["src/features".to_string()],
970                        root: None,
971                    },
972                    BoundaryZone {
973                        name: "shared".to_string(),
974                        patterns: vec!["src/shared/**".to_string()],
975                        auto_discover: vec![],
976                        root: None,
977                    },
978                ],
979                rules,
980            };
981
982            config.expand_auto_discover(temp.path());
983
984            let auth_rule = config
985                .rules
986                .iter()
987                .find(|rule| rule.from == "features/auth")
988                .expect("explicit child rule should remain");
989            assert_eq!(
990                auth_rule.allow,
991                vec!["shared".to_string(), "features/billing".to_string()],
992                "explicit child rule should win regardless of rule order"
993            );
994
995            let billing_rule = config
996                .rules
997                .iter()
998                .find(|rule| rule.from == "features/billing")
999                .expect("parent rule should still generate sibling child rule");
1000            assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1001            assert!(config.validate_zone_references().is_empty());
1002        }
1003    }
1004
1005    #[test]
1006    fn validate_zone_references_valid() {
1007        let config = BoundaryConfig {
1008            preset: None,
1009            zones: vec![
1010                BoundaryZone {
1011                    name: "ui".to_string(),
1012                    patterns: vec![],
1013                    auto_discover: vec![],
1014                    root: None,
1015                },
1016                BoundaryZone {
1017                    name: "db".to_string(),
1018                    patterns: vec![],
1019                    auto_discover: vec![],
1020                    root: None,
1021                },
1022            ],
1023            rules: vec![BoundaryRule {
1024                from: "ui".to_string(),
1025                allow: vec!["db".to_string()],
1026            }],
1027        };
1028        assert!(config.validate_zone_references().is_empty());
1029    }
1030
1031    #[test]
1032    fn validate_zone_references_invalid_from() {
1033        let config = BoundaryConfig {
1034            preset: None,
1035            zones: vec![BoundaryZone {
1036                name: "ui".to_string(),
1037                patterns: vec![],
1038                auto_discover: vec![],
1039                root: None,
1040            }],
1041            rules: vec![BoundaryRule {
1042                from: "nonexistent".to_string(),
1043                allow: vec!["ui".to_string()],
1044            }],
1045        };
1046        let errors = config.validate_zone_references();
1047        assert_eq!(errors.len(), 1);
1048        assert_eq!(errors[0].1, "nonexistent");
1049    }
1050
1051    #[test]
1052    fn validate_zone_references_invalid_allow() {
1053        let config = BoundaryConfig {
1054            preset: None,
1055            zones: vec![BoundaryZone {
1056                name: "ui".to_string(),
1057                patterns: vec![],
1058                auto_discover: vec![],
1059                root: None,
1060            }],
1061            rules: vec![BoundaryRule {
1062                from: "ui".to_string(),
1063                allow: vec!["nonexistent".to_string()],
1064            }],
1065        };
1066        let errors = config.validate_zone_references();
1067        assert_eq!(errors.len(), 1);
1068        assert_eq!(errors[0].1, "nonexistent");
1069    }
1070
1071    #[test]
1072    fn resolve_and_classify() {
1073        let config = BoundaryConfig {
1074            preset: None,
1075            zones: vec![
1076                BoundaryZone {
1077                    name: "ui".to_string(),
1078                    patterns: vec!["src/components/**".to_string()],
1079                    auto_discover: vec![],
1080                    root: None,
1081                },
1082                BoundaryZone {
1083                    name: "db".to_string(),
1084                    patterns: vec!["src/db/**".to_string()],
1085                    auto_discover: vec![],
1086                    root: None,
1087                },
1088            ],
1089            rules: vec![],
1090        };
1091        let resolved = config.resolve();
1092        assert_eq!(
1093            resolved.classify_zone("src/components/Button.tsx"),
1094            Some("ui")
1095        );
1096        assert_eq!(resolved.classify_zone("src/db/queries.ts"), Some("db"));
1097        assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
1098    }
1099
1100    #[test]
1101    fn first_match_wins() {
1102        let config = BoundaryConfig {
1103            preset: None,
1104            zones: vec![
1105                BoundaryZone {
1106                    name: "specific".to_string(),
1107                    patterns: vec!["src/shared/db-utils/**".to_string()],
1108                    auto_discover: vec![],
1109                    root: None,
1110                },
1111                BoundaryZone {
1112                    name: "shared".to_string(),
1113                    patterns: vec!["src/shared/**".to_string()],
1114                    auto_discover: vec![],
1115                    root: None,
1116                },
1117            ],
1118            rules: vec![],
1119        };
1120        let resolved = config.resolve();
1121        assert_eq!(
1122            resolved.classify_zone("src/shared/db-utils/pool.ts"),
1123            Some("specific")
1124        );
1125        assert_eq!(
1126            resolved.classify_zone("src/shared/helpers.ts"),
1127            Some("shared")
1128        );
1129    }
1130
1131    #[test]
1132    fn self_import_always_allowed() {
1133        let config = BoundaryConfig {
1134            preset: None,
1135            zones: vec![BoundaryZone {
1136                name: "ui".to_string(),
1137                patterns: vec![],
1138                auto_discover: vec![],
1139                root: None,
1140            }],
1141            rules: vec![BoundaryRule {
1142                from: "ui".to_string(),
1143                allow: vec![],
1144            }],
1145        };
1146        let resolved = config.resolve();
1147        assert!(resolved.is_import_allowed("ui", "ui"));
1148    }
1149
1150    #[test]
1151    fn unrestricted_zone_allows_all() {
1152        let config = BoundaryConfig {
1153            preset: None,
1154            zones: vec![
1155                BoundaryZone {
1156                    name: "shared".to_string(),
1157                    patterns: vec![],
1158                    auto_discover: vec![],
1159                    root: None,
1160                },
1161                BoundaryZone {
1162                    name: "db".to_string(),
1163                    patterns: vec![],
1164                    auto_discover: vec![],
1165                    root: None,
1166                },
1167            ],
1168            rules: vec![],
1169        };
1170        let resolved = config.resolve();
1171        assert!(resolved.is_import_allowed("shared", "db"));
1172    }
1173
1174    #[test]
1175    fn restricted_zone_blocks_unlisted() {
1176        let config = BoundaryConfig {
1177            preset: None,
1178            zones: vec![
1179                BoundaryZone {
1180                    name: "ui".to_string(),
1181                    patterns: vec![],
1182                    auto_discover: vec![],
1183                    root: None,
1184                },
1185                BoundaryZone {
1186                    name: "db".to_string(),
1187                    patterns: vec![],
1188                    auto_discover: vec![],
1189                    root: None,
1190                },
1191                BoundaryZone {
1192                    name: "shared".to_string(),
1193                    patterns: vec![],
1194                    auto_discover: vec![],
1195                    root: None,
1196                },
1197            ],
1198            rules: vec![BoundaryRule {
1199                from: "ui".to_string(),
1200                allow: vec!["shared".to_string()],
1201            }],
1202        };
1203        let resolved = config.resolve();
1204        assert!(resolved.is_import_allowed("ui", "shared"));
1205        assert!(!resolved.is_import_allowed("ui", "db"));
1206    }
1207
1208    #[test]
1209    fn empty_allow_blocks_all_except_self() {
1210        let config = BoundaryConfig {
1211            preset: None,
1212            zones: vec![
1213                BoundaryZone {
1214                    name: "isolated".to_string(),
1215                    patterns: vec![],
1216                    auto_discover: vec![],
1217                    root: None,
1218                },
1219                BoundaryZone {
1220                    name: "other".to_string(),
1221                    patterns: vec![],
1222                    auto_discover: vec![],
1223                    root: None,
1224                },
1225            ],
1226            rules: vec![BoundaryRule {
1227                from: "isolated".to_string(),
1228                allow: vec![],
1229            }],
1230        };
1231        let resolved = config.resolve();
1232        assert!(resolved.is_import_allowed("isolated", "isolated"));
1233        assert!(!resolved.is_import_allowed("isolated", "other"));
1234    }
1235
1236    #[test]
1237    fn zone_root_filters_classification_to_subtree() {
1238        let config = BoundaryConfig {
1239            preset: None,
1240            zones: vec![
1241                BoundaryZone {
1242                    name: "ui".to_string(),
1243                    patterns: vec!["src/**".to_string()],
1244                    auto_discover: vec![],
1245                    root: Some("packages/app/".to_string()),
1246                },
1247                BoundaryZone {
1248                    name: "domain".to_string(),
1249                    patterns: vec!["src/**".to_string()],
1250                    auto_discover: vec![],
1251                    root: Some("packages/core/".to_string()),
1252                },
1253            ],
1254            rules: vec![],
1255        };
1256        let resolved = config.resolve();
1257        // Files inside packages/app/ classify as ui
1258        assert_eq!(
1259            resolved.classify_zone("packages/app/src/login.tsx"),
1260            Some("ui")
1261        );
1262        // Files inside packages/core/ classify as domain (same pattern, different root)
1263        assert_eq!(
1264            resolved.classify_zone("packages/core/src/order.ts"),
1265            Some("domain")
1266        );
1267        // Files outside either subtree do not match
1268        assert_eq!(resolved.classify_zone("src/login.tsx"), None);
1269        assert_eq!(resolved.classify_zone("packages/utils/src/x.ts"), None);
1270    }
1271
1272    /// Case-sensitivity contract: `root` matching is case-sensitive,
1273    /// matching the existing globset case-sensitivity for `patterns`. On
1274    /// case-insensitive filesystems (HFS+, NTFS) two files differing only
1275    /// in case still classify only when the configured `root` exactly
1276    /// matches the path's case as fallow recorded it. Locking this down
1277    /// prevents silent platform-divergent classification.
1278    #[test]
1279    fn zone_root_is_case_sensitive() {
1280        let config = BoundaryConfig {
1281            preset: None,
1282            zones: vec![BoundaryZone {
1283                name: "ui".to_string(),
1284                patterns: vec!["src/**".to_string()],
1285                auto_discover: vec![],
1286                root: Some("packages/app/".to_string()),
1287            }],
1288            rules: vec![],
1289        };
1290        let resolved = config.resolve();
1291        assert_eq!(
1292            resolved.classify_zone("packages/app/src/login.tsx"),
1293            Some("ui"),
1294            "exact-case path classifies"
1295        );
1296        assert_eq!(
1297            resolved.classify_zone("packages/App/src/login.tsx"),
1298            None,
1299            "case-different path does not classify (root is case-sensitive)"
1300        );
1301        assert_eq!(
1302            resolved.classify_zone("Packages/app/src/login.tsx"),
1303            None,
1304            "case-different prefix does not classify"
1305        );
1306    }
1307
1308    #[test]
1309    fn zone_root_normalizes_trailing_slash_and_dot_prefix() {
1310        let config = BoundaryConfig {
1311            preset: None,
1312            zones: vec![
1313                BoundaryZone {
1314                    name: "no-slash".to_string(),
1315                    patterns: vec!["src/**".to_string()],
1316                    auto_discover: vec![],
1317                    root: Some("packages/app".to_string()),
1318                },
1319                BoundaryZone {
1320                    name: "dot-prefixed".to_string(),
1321                    patterns: vec!["src/**".to_string()],
1322                    auto_discover: vec![],
1323                    root: Some("./packages/lib/".to_string()),
1324                },
1325            ],
1326            rules: vec![],
1327        };
1328        let resolved = config.resolve();
1329        assert_eq!(resolved.zones[0].root.as_deref(), Some("packages/app/"));
1330        assert_eq!(resolved.zones[1].root.as_deref(), Some("packages/lib/"));
1331        assert_eq!(
1332            resolved.classify_zone("packages/app/src/x.ts"),
1333            Some("no-slash")
1334        );
1335        assert_eq!(
1336            resolved.classify_zone("packages/lib/src/x.ts"),
1337            Some("dot-prefixed")
1338        );
1339    }
1340
1341    #[test]
1342    fn validate_root_prefixes_flags_redundant_pattern() {
1343        let config = BoundaryConfig {
1344            preset: None,
1345            zones: vec![BoundaryZone {
1346                name: "ui".to_string(),
1347                patterns: vec!["packages/app/src/**".to_string()],
1348                auto_discover: vec![],
1349                root: Some("packages/app/".to_string()),
1350            }],
1351            rules: vec![],
1352        };
1353        let errors = config.validate_root_prefixes();
1354        assert_eq!(errors.len(), 1, "expected one redundant-prefix error");
1355        assert!(
1356            errors[0].contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"),
1357            "error should be tagged: {}",
1358            errors[0]
1359        );
1360        assert!(
1361            errors[0].contains("zone 'ui'"),
1362            "error should name the zone: {}",
1363            errors[0]
1364        );
1365        assert!(
1366            errors[0].contains("packages/app/src/**"),
1367            "error should quote the pattern: {}",
1368            errors[0]
1369        );
1370    }
1371
1372    #[test]
1373    fn validate_root_prefixes_handles_unnormalized_root() {
1374        // Root without trailing slash + pattern with leading "./" should
1375        // still be detected as redundant after normalization.
1376        let config = BoundaryConfig {
1377            preset: None,
1378            zones: vec![BoundaryZone {
1379                name: "ui".to_string(),
1380                patterns: vec!["./packages/app/src/**".to_string()],
1381                auto_discover: vec![],
1382                root: Some("packages/app".to_string()),
1383            }],
1384            rules: vec![],
1385        };
1386        let errors = config.validate_root_prefixes();
1387        assert_eq!(errors.len(), 1);
1388    }
1389
1390    #[test]
1391    fn validate_root_prefixes_empty_when_no_overlap() {
1392        let config = BoundaryConfig {
1393            preset: None,
1394            zones: vec![BoundaryZone {
1395                name: "ui".to_string(),
1396                patterns: vec!["src/**".to_string()],
1397                auto_discover: vec![],
1398                root: Some("packages/app/".to_string()),
1399            }],
1400            rules: vec![],
1401        };
1402        assert!(config.validate_root_prefixes().is_empty());
1403    }
1404
1405    #[test]
1406    fn validate_root_prefixes_skips_zones_without_root() {
1407        let json = r#"{
1408            "zones": [{ "name": "ui", "patterns": ["src/**"] }],
1409            "rules": []
1410        }"#;
1411        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1412        assert!(config.validate_root_prefixes().is_empty());
1413    }
1414
1415    /// Regression: an empty `root` (or `"."`/`"./"`, both of which normalize
1416    /// to `""`) used to make `starts_with("")` always true, producing a
1417    /// spurious FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX error for every
1418    /// pattern in the zone. The validation must skip empty-normalized roots
1419    /// the same way `classify_zone` does.
1420    #[test]
1421    fn validate_root_prefixes_skips_empty_root() {
1422        for raw_root in ["", ".", "./"] {
1423            let config = BoundaryConfig {
1424                preset: None,
1425                zones: vec![BoundaryZone {
1426                    name: "ui".to_string(),
1427                    patterns: vec!["src/**".to_string(), "lib/**".to_string()],
1428                    auto_discover: vec![],
1429                    root: Some(raw_root.to_string()),
1430                }],
1431                rules: vec![],
1432            };
1433            let errors = config.validate_root_prefixes();
1434            assert!(
1435                errors.is_empty(),
1436                "empty-normalized root {raw_root:?} produced spurious errors: {errors:?}"
1437            );
1438        }
1439    }
1440
1441    #[test]
1442    fn deserialize_zone_with_root() {
1443        let json = r#"{
1444            "zones": [
1445                { "name": "ui", "patterns": ["src/**"], "root": "packages/app/" }
1446            ],
1447            "rules": []
1448        }"#;
1449        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1450        assert_eq!(config.zones[0].root.as_deref(), Some("packages/app/"));
1451    }
1452
1453    // ── Preset deserialization ─────────────────────────────────
1454
1455    #[test]
1456    fn deserialize_preset_json() {
1457        let json = r#"{ "preset": "layered" }"#;
1458        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1459        assert_eq!(config.preset, Some(BoundaryPreset::Layered));
1460        assert!(config.zones.is_empty());
1461    }
1462
1463    #[test]
1464    fn deserialize_preset_hexagonal_json() {
1465        let json = r#"{ "preset": "hexagonal" }"#;
1466        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1467        assert_eq!(config.preset, Some(BoundaryPreset::Hexagonal));
1468    }
1469
1470    #[test]
1471    fn deserialize_preset_feature_sliced_json() {
1472        let json = r#"{ "preset": "feature-sliced" }"#;
1473        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1474        assert_eq!(config.preset, Some(BoundaryPreset::FeatureSliced));
1475    }
1476
1477    #[test]
1478    fn deserialize_preset_toml() {
1479        let toml_str = r#"preset = "layered""#;
1480        let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
1481        assert_eq!(config.preset, Some(BoundaryPreset::Layered));
1482    }
1483
1484    #[test]
1485    fn deserialize_invalid_preset_rejected() {
1486        let json = r#"{ "preset": "invalid_preset" }"#;
1487        let result: Result<BoundaryConfig, _> = serde_json::from_str(json);
1488        assert!(result.is_err());
1489    }
1490
1491    #[test]
1492    fn preset_absent_by_default() {
1493        let config = BoundaryConfig::default();
1494        assert!(config.preset.is_none());
1495        assert!(config.is_empty());
1496    }
1497
1498    #[test]
1499    fn preset_makes_config_non_empty() {
1500        let config = BoundaryConfig {
1501            preset: Some(BoundaryPreset::Layered),
1502            zones: vec![],
1503            rules: vec![],
1504        };
1505        assert!(!config.is_empty());
1506    }
1507
1508    // ── Preset expansion ───────────────────────────────────────
1509
1510    #[test]
1511    fn expand_layered_produces_four_zones() {
1512        let mut config = BoundaryConfig {
1513            preset: Some(BoundaryPreset::Layered),
1514            zones: vec![],
1515            rules: vec![],
1516        };
1517        config.expand("src");
1518        assert_eq!(config.zones.len(), 4);
1519        assert_eq!(config.rules.len(), 4);
1520        assert!(config.preset.is_none(), "preset cleared after expand");
1521        assert_eq!(config.zones[0].name, "presentation");
1522        assert_eq!(config.zones[0].patterns, vec!["src/presentation/**"]);
1523    }
1524
1525    #[test]
1526    fn expand_layered_rules_correct() {
1527        let mut config = BoundaryConfig {
1528            preset: Some(BoundaryPreset::Layered),
1529            zones: vec![],
1530            rules: vec![],
1531        };
1532        config.expand("src");
1533        // presentation → application only
1534        let pres_rule = config
1535            .rules
1536            .iter()
1537            .find(|r| r.from == "presentation")
1538            .unwrap();
1539        assert_eq!(pres_rule.allow, vec!["application"]);
1540        // application → domain only
1541        let app_rule = config
1542            .rules
1543            .iter()
1544            .find(|r| r.from == "application")
1545            .unwrap();
1546        assert_eq!(app_rule.allow, vec!["domain"]);
1547        // domain → nothing
1548        let dom_rule = config.rules.iter().find(|r| r.from == "domain").unwrap();
1549        assert!(dom_rule.allow.is_empty());
1550        // infrastructure → domain + application (DI-friendly)
1551        let infra_rule = config
1552            .rules
1553            .iter()
1554            .find(|r| r.from == "infrastructure")
1555            .unwrap();
1556        assert_eq!(infra_rule.allow, vec!["domain", "application"]);
1557    }
1558
1559    #[test]
1560    fn expand_hexagonal_produces_three_zones() {
1561        let mut config = BoundaryConfig {
1562            preset: Some(BoundaryPreset::Hexagonal),
1563            zones: vec![],
1564            rules: vec![],
1565        };
1566        config.expand("src");
1567        assert_eq!(config.zones.len(), 3);
1568        assert_eq!(config.rules.len(), 3);
1569        assert_eq!(config.zones[0].name, "adapters");
1570        assert_eq!(config.zones[1].name, "ports");
1571        assert_eq!(config.zones[2].name, "domain");
1572    }
1573
1574    #[test]
1575    fn expand_feature_sliced_produces_six_zones() {
1576        let mut config = BoundaryConfig {
1577            preset: Some(BoundaryPreset::FeatureSliced),
1578            zones: vec![],
1579            rules: vec![],
1580        };
1581        config.expand("src");
1582        assert_eq!(config.zones.len(), 6);
1583        assert_eq!(config.rules.len(), 6);
1584        // app can import everything below
1585        let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
1586        assert_eq!(
1587            app_rule.allow,
1588            vec!["pages", "widgets", "features", "entities", "shared"]
1589        );
1590        // shared imports nothing
1591        let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
1592        assert!(shared_rule.allow.is_empty());
1593        // entities → shared only
1594        let ent_rule = config.rules.iter().find(|r| r.from == "entities").unwrap();
1595        assert_eq!(ent_rule.allow, vec!["shared"]);
1596    }
1597
1598    #[test]
1599    fn expand_bulletproof_produces_four_zones() {
1600        let mut config = BoundaryConfig {
1601            preset: Some(BoundaryPreset::Bulletproof),
1602            zones: vec![],
1603            rules: vec![],
1604        };
1605        config.expand("src");
1606        assert_eq!(config.zones.len(), 4);
1607        assert_eq!(config.rules.len(), 4);
1608        assert_eq!(config.zones[0].name, "app");
1609        assert_eq!(config.zones[1].name, "features");
1610        assert_eq!(config.zones[2].name, "shared");
1611        assert_eq!(config.zones[3].name, "server");
1612        // shared zone has multiple patterns
1613        assert!(config.zones[2].patterns.len() > 1);
1614        assert!(
1615            config.zones[2]
1616                .patterns
1617                .contains(&"src/components/**".to_string())
1618        );
1619        assert!(
1620            config.zones[2]
1621                .patterns
1622                .contains(&"src/hooks/**".to_string())
1623        );
1624        assert!(config.zones[2].patterns.contains(&"src/lib/**".to_string()));
1625        assert!(
1626            config.zones[2]
1627                .patterns
1628                .contains(&"src/providers/**".to_string())
1629        );
1630    }
1631
1632    #[test]
1633    fn expand_bulletproof_rules_correct() {
1634        let mut config = BoundaryConfig {
1635            preset: Some(BoundaryPreset::Bulletproof),
1636            zones: vec![],
1637            rules: vec![],
1638        };
1639        config.expand("src");
1640        // app → features, shared, server
1641        let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
1642        assert_eq!(app_rule.allow, vec!["features", "shared", "server"]);
1643        // features → shared, server
1644        let feat_rule = config.rules.iter().find(|r| r.from == "features").unwrap();
1645        assert_eq!(feat_rule.allow, vec!["shared", "server"]);
1646        // server → shared
1647        let srv_rule = config.rules.iter().find(|r| r.from == "server").unwrap();
1648        assert_eq!(srv_rule.allow, vec!["shared"]);
1649        // shared → nothing (isolated)
1650        let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
1651        assert!(shared_rule.allow.is_empty());
1652    }
1653
1654    #[test]
1655    fn expand_bulletproof_then_resolve_classifies() {
1656        // `expand()` alone (without `expand_auto_discover`) does not produce
1657        // the per-feature child zones, so the `features` group is empty and
1658        // top-level `src/features/...` files are unclassified. Sibling
1659        // `app` / `shared` / `server` zones still classify normally.
1660        let mut config = BoundaryConfig {
1661            preset: Some(BoundaryPreset::Bulletproof),
1662            zones: vec![],
1663            rules: vec![],
1664        };
1665        config.expand("src");
1666        let resolved = config.resolve();
1667        assert_eq!(
1668            resolved.classify_zone("src/app/dashboard/page.tsx"),
1669            Some("app")
1670        );
1671        assert_eq!(
1672            resolved.classify_zone("src/features/auth/hooks/useAuth.ts"),
1673            None,
1674            "without expand_auto_discover, src/features/... is unclassified"
1675        );
1676        assert_eq!(
1677            resolved.classify_zone("src/components/Button/Button.tsx"),
1678            Some("shared")
1679        );
1680        assert_eq!(
1681            resolved.classify_zone("src/hooks/useFormatters.ts"),
1682            Some("shared")
1683        );
1684        assert_eq!(
1685            resolved.classify_zone("src/server/db/schema/users.ts"),
1686            Some("server")
1687        );
1688        // features cannot import shared directly — only via allowed rules
1689        assert!(resolved.is_import_allowed("features", "shared"));
1690        assert!(resolved.is_import_allowed("features", "server"));
1691        assert!(!resolved.is_import_allowed("features", "app"));
1692        assert!(!resolved.is_import_allowed("shared", "features"));
1693        assert!(!resolved.is_import_allowed("server", "features"));
1694    }
1695
1696    /// Regression for the bulletproof barrel pattern: a top-level
1697    /// `src/features/index.ts` barrel re-exporting child features must NOT
1698    /// trigger `features → features/<child>` boundary violations. The fix is
1699    /// to keep the bulletproof `features` zone pattern-free so the barrel is
1700    /// unclassified (unrestricted) while child zones still enforce sibling
1701    /// isolation.
1702    #[test]
1703    fn bulletproof_features_barrel_is_unclassified() {
1704        let temp = tempfile::tempdir().unwrap();
1705        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1706        std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1707
1708        let mut config = BoundaryConfig {
1709            preset: Some(BoundaryPreset::Bulletproof),
1710            zones: vec![],
1711            rules: vec![],
1712        };
1713        config.expand("src");
1714        config.expand_auto_discover(temp.path());
1715        let resolved = config.resolve();
1716
1717        // Top-level barrel inside src/features stays unclassified.
1718        assert_eq!(
1719            resolved.classify_zone("src/features/index.ts"),
1720            None,
1721            "src/features/index.ts barrel must be unclassified to allow re-exporting children"
1722        );
1723        // Discovered child zones still classify normally.
1724        assert_eq!(
1725            resolved.classify_zone("src/features/auth/login.ts"),
1726            Some("features/auth")
1727        );
1728        assert_eq!(
1729            resolved.classify_zone("src/features/billing/invoice.ts"),
1730            Some("features/billing")
1731        );
1732        // Sibling-feature import is still a cross-zone violation.
1733        assert!(!resolved.is_import_allowed("features/auth", "features/billing"));
1734    }
1735
1736    #[test]
1737    fn expand_uses_custom_source_root() {
1738        let mut config = BoundaryConfig {
1739            preset: Some(BoundaryPreset::Hexagonal),
1740            zones: vec![],
1741            rules: vec![],
1742        };
1743        config.expand("lib");
1744        assert_eq!(config.zones[0].patterns, vec!["lib/adapters/**"]);
1745        assert_eq!(config.zones[2].patterns, vec!["lib/domain/**"]);
1746    }
1747
1748    // ── Preset merge behavior ──────────────────────────────────
1749
1750    #[test]
1751    fn user_zone_replaces_preset_zone() {
1752        let mut config = BoundaryConfig {
1753            preset: Some(BoundaryPreset::Hexagonal),
1754            zones: vec![BoundaryZone {
1755                name: "domain".to_string(),
1756                patterns: vec!["src/core/**".to_string()],
1757                auto_discover: vec![],
1758                root: None,
1759            }],
1760            rules: vec![],
1761        };
1762        config.expand("src");
1763        // 3 zones total: adapters + ports from preset, domain from user
1764        assert_eq!(config.zones.len(), 3);
1765        let domain = config.zones.iter().find(|z| z.name == "domain").unwrap();
1766        assert_eq!(domain.patterns, vec!["src/core/**"]);
1767    }
1768
1769    #[test]
1770    fn user_zone_adds_to_preset() {
1771        let mut config = BoundaryConfig {
1772            preset: Some(BoundaryPreset::Hexagonal),
1773            zones: vec![BoundaryZone {
1774                name: "shared".to_string(),
1775                patterns: vec!["src/shared/**".to_string()],
1776                auto_discover: vec![],
1777                root: None,
1778            }],
1779            rules: vec![],
1780        };
1781        config.expand("src");
1782        assert_eq!(config.zones.len(), 4); // 3 preset + 1 user
1783        assert!(config.zones.iter().any(|z| z.name == "shared"));
1784    }
1785
1786    #[test]
1787    fn user_rule_replaces_preset_rule() {
1788        let mut config = BoundaryConfig {
1789            preset: Some(BoundaryPreset::Hexagonal),
1790            zones: vec![],
1791            rules: vec![BoundaryRule {
1792                from: "adapters".to_string(),
1793                allow: vec!["ports".to_string(), "domain".to_string()],
1794            }],
1795        };
1796        config.expand("src");
1797        let adapter_rule = config.rules.iter().find(|r| r.from == "adapters").unwrap();
1798        // User rule allows both ports and domain (preset only allowed ports)
1799        assert_eq!(adapter_rule.allow, vec!["ports", "domain"]);
1800        // Other preset rules untouched
1801        assert_eq!(
1802            config.rules.iter().filter(|r| r.from == "adapters").count(),
1803            1
1804        );
1805    }
1806
1807    #[test]
1808    fn expand_without_preset_is_noop() {
1809        let mut config = BoundaryConfig {
1810            preset: None,
1811            zones: vec![BoundaryZone {
1812                name: "ui".to_string(),
1813                patterns: vec!["src/ui/**".to_string()],
1814                auto_discover: vec![],
1815                root: None,
1816            }],
1817            rules: vec![],
1818        };
1819        config.expand("src");
1820        assert_eq!(config.zones.len(), 1);
1821        assert_eq!(config.zones[0].name, "ui");
1822    }
1823
1824    #[test]
1825    fn expand_then_validate_succeeds() {
1826        let mut config = BoundaryConfig {
1827            preset: Some(BoundaryPreset::Layered),
1828            zones: vec![],
1829            rules: vec![],
1830        };
1831        config.expand("src");
1832        assert!(config.validate_zone_references().is_empty());
1833    }
1834
1835    #[test]
1836    fn expand_then_resolve_classifies() {
1837        let mut config = BoundaryConfig {
1838            preset: Some(BoundaryPreset::Hexagonal),
1839            zones: vec![],
1840            rules: vec![],
1841        };
1842        config.expand("src");
1843        let resolved = config.resolve();
1844        assert_eq!(
1845            resolved.classify_zone("src/adapters/http/handler.ts"),
1846            Some("adapters")
1847        );
1848        assert_eq!(resolved.classify_zone("src/domain/user.ts"), Some("domain"));
1849        assert!(!resolved.is_import_allowed("adapters", "domain"));
1850        assert!(resolved.is_import_allowed("adapters", "ports"));
1851    }
1852
1853    #[test]
1854    fn preset_name_returns_correct_string() {
1855        let config = BoundaryConfig {
1856            preset: Some(BoundaryPreset::FeatureSliced),
1857            zones: vec![],
1858            rules: vec![],
1859        };
1860        assert_eq!(config.preset_name(), Some("feature-sliced"));
1861
1862        let empty = BoundaryConfig::default();
1863        assert_eq!(empty.preset_name(), None);
1864    }
1865
1866    #[test]
1867    fn preset_name_all_variants() {
1868        let cases = [
1869            (BoundaryPreset::Layered, "layered"),
1870            (BoundaryPreset::Hexagonal, "hexagonal"),
1871            (BoundaryPreset::FeatureSliced, "feature-sliced"),
1872            (BoundaryPreset::Bulletproof, "bulletproof"),
1873        ];
1874        for (preset, expected_name) in cases {
1875            let config = BoundaryConfig {
1876                preset: Some(preset),
1877                zones: vec![],
1878                rules: vec![],
1879            };
1880            assert_eq!(
1881                config.preset_name(),
1882                Some(expected_name),
1883                "preset_name() mismatch for variant"
1884            );
1885        }
1886    }
1887
1888    // ── ResolvedBoundaryConfig::is_empty ────────────────────────────
1889
1890    #[test]
1891    fn resolved_boundary_config_empty() {
1892        let resolved = ResolvedBoundaryConfig::default();
1893        assert!(resolved.is_empty());
1894    }
1895
1896    #[test]
1897    fn resolved_boundary_config_with_zones_not_empty() {
1898        let config = BoundaryConfig {
1899            preset: None,
1900            zones: vec![BoundaryZone {
1901                name: "ui".to_string(),
1902                patterns: vec!["src/ui/**".to_string()],
1903                auto_discover: vec![],
1904                root: None,
1905            }],
1906            rules: vec![],
1907        };
1908        let resolved = config.resolve();
1909        assert!(!resolved.is_empty());
1910    }
1911
1912    // ── BoundaryConfig::is_empty edge cases ─────────────────────────
1913
1914    #[test]
1915    fn boundary_config_with_only_rules_is_empty() {
1916        // Having rules but no zones/preset is still "empty" since rules without zones
1917        // cannot produce boundary violations.
1918        let config = BoundaryConfig {
1919            preset: None,
1920            zones: vec![],
1921            rules: vec![BoundaryRule {
1922                from: "ui".to_string(),
1923                allow: vec!["db".to_string()],
1924            }],
1925        };
1926        assert!(config.is_empty());
1927    }
1928
1929    #[test]
1930    fn boundary_config_with_zones_not_empty() {
1931        let config = BoundaryConfig {
1932            preset: None,
1933            zones: vec![BoundaryZone {
1934                name: "ui".to_string(),
1935                patterns: vec![],
1936                auto_discover: vec![],
1937                root: None,
1938            }],
1939            rules: vec![],
1940        };
1941        assert!(!config.is_empty());
1942    }
1943
1944    // ── Multiple zone patterns ──────────────────────────────────────
1945
1946    #[test]
1947    fn zone_with_multiple_patterns_matches_any() {
1948        let config = BoundaryConfig {
1949            preset: None,
1950            zones: vec![BoundaryZone {
1951                name: "ui".to_string(),
1952                patterns: vec![
1953                    "src/components/**".to_string(),
1954                    "src/pages/**".to_string(),
1955                    "src/views/**".to_string(),
1956                ],
1957                auto_discover: vec![],
1958                root: None,
1959            }],
1960            rules: vec![],
1961        };
1962        let resolved = config.resolve();
1963        assert_eq!(
1964            resolved.classify_zone("src/components/Button.tsx"),
1965            Some("ui")
1966        );
1967        assert_eq!(resolved.classify_zone("src/pages/Home.tsx"), Some("ui"));
1968        assert_eq!(
1969            resolved.classify_zone("src/views/Dashboard.tsx"),
1970            Some("ui")
1971        );
1972        assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
1973    }
1974
1975    // ── validate_zone_references with multiple errors ───────────────
1976
1977    #[test]
1978    fn validate_zone_references_multiple_errors() {
1979        let config = BoundaryConfig {
1980            preset: None,
1981            zones: vec![BoundaryZone {
1982                name: "ui".to_string(),
1983                patterns: vec![],
1984                auto_discover: vec![],
1985                root: None,
1986            }],
1987            rules: vec![
1988                BoundaryRule {
1989                    from: "nonexistent_from".to_string(),
1990                    allow: vec!["nonexistent_allow".to_string()],
1991                },
1992                BoundaryRule {
1993                    from: "ui".to_string(),
1994                    allow: vec!["also_nonexistent".to_string()],
1995                },
1996            ],
1997        };
1998        let errors = config.validate_zone_references();
1999        // Rule 0: invalid "from" + invalid "allow" = 2 errors
2000        // Rule 1: valid "from", invalid "allow" = 1 error
2001        assert_eq!(errors.len(), 3);
2002    }
2003
2004    // ── Preset expansion with custom source root ────────────────────
2005
2006    #[test]
2007    fn expand_feature_sliced_with_custom_root() {
2008        let mut config = BoundaryConfig {
2009            preset: Some(BoundaryPreset::FeatureSliced),
2010            zones: vec![],
2011            rules: vec![],
2012        };
2013        config.expand("lib");
2014        assert_eq!(config.zones[0].patterns, vec!["lib/app/**"]);
2015        assert_eq!(config.zones[5].patterns, vec!["lib/shared/**"]);
2016    }
2017
2018    // ── is_import_allowed for zone not in rules (unrestricted) ──────
2019
2020    #[test]
2021    fn zone_not_in_rules_is_unrestricted() {
2022        let config = BoundaryConfig {
2023            preset: None,
2024            zones: vec![
2025                BoundaryZone {
2026                    name: "a".to_string(),
2027                    patterns: vec![],
2028                    auto_discover: vec![],
2029                    root: None,
2030                },
2031                BoundaryZone {
2032                    name: "b".to_string(),
2033                    patterns: vec![],
2034                    auto_discover: vec![],
2035                    root: None,
2036                },
2037                BoundaryZone {
2038                    name: "c".to_string(),
2039                    patterns: vec![],
2040                    auto_discover: vec![],
2041                    root: None,
2042                },
2043            ],
2044            rules: vec![BoundaryRule {
2045                from: "a".to_string(),
2046                allow: vec!["b".to_string()],
2047            }],
2048        };
2049        let resolved = config.resolve();
2050        // "a" is restricted: can import from "b" but not "c"
2051        assert!(resolved.is_import_allowed("a", "b"));
2052        assert!(!resolved.is_import_allowed("a", "c"));
2053        // "b" has no rule entry: unrestricted
2054        assert!(resolved.is_import_allowed("b", "a"));
2055        assert!(resolved.is_import_allowed("b", "c"));
2056        // "c" has no rule entry: unrestricted
2057        assert!(resolved.is_import_allowed("c", "a"));
2058    }
2059
2060    // ── Preset serialization/deserialization roundtrip ───────────────
2061
2062    #[test]
2063    fn boundary_preset_json_roundtrip() {
2064        let presets = [
2065            BoundaryPreset::Layered,
2066            BoundaryPreset::Hexagonal,
2067            BoundaryPreset::FeatureSliced,
2068            BoundaryPreset::Bulletproof,
2069        ];
2070        for preset in presets {
2071            let json = serde_json::to_string(&preset).unwrap();
2072            let restored: BoundaryPreset = serde_json::from_str(&json).unwrap();
2073            assert_eq!(restored, preset);
2074        }
2075    }
2076
2077    #[test]
2078    fn deserialize_preset_bulletproof_json() {
2079        let json = r#"{ "preset": "bulletproof" }"#;
2080        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2081        assert_eq!(config.preset, Some(BoundaryPreset::Bulletproof));
2082    }
2083
2084    // ── Zone with invalid glob ──────────────────────────────────────
2085
2086    #[test]
2087    fn resolve_skips_invalid_zone_glob() {
2088        let config = BoundaryConfig {
2089            preset: None,
2090            zones: vec![BoundaryZone {
2091                name: "broken".to_string(),
2092                patterns: vec!["[invalid".to_string()],
2093                auto_discover: vec![],
2094                root: None,
2095            }],
2096            rules: vec![],
2097        };
2098        let resolved = config.resolve();
2099        // Zone exists but has no valid matchers, so no file can be classified into it
2100        assert!(!resolved.is_empty());
2101        assert_eq!(resolved.classify_zone("anything.ts"), None);
2102    }
2103}