Skip to main content

fallow_config/config/
boundaries.rs

1//! Architecture boundary zone and rule definitions.
2
3use std::path::Path;
4
5use globset::Glob;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9/// Built-in architecture presets.
10///
11/// Each preset expands into a set of zones and import rules for a common
12/// architecture pattern. User-defined zones and rules merge on top of the
13/// preset defaults (zones with the same name replace the preset zone;
14/// rules with the same `from` replace the preset rule).
15///
16/// # Examples
17///
18/// ```
19/// use fallow_config::BoundaryPreset;
20///
21/// let preset: BoundaryPreset = serde_json::from_str(r#""layered""#).unwrap();
22/// assert!(matches!(preset, BoundaryPreset::Layered));
23/// ```
24#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
25#[serde(rename_all = "kebab-case")]
26pub enum BoundaryPreset {
27    /// Classic layered architecture: presentation → application → domain ← infrastructure.
28    /// Infrastructure may also import from application (common in DI frameworks).
29    Layered,
30    /// Hexagonal / ports-and-adapters: adapters → ports → domain.
31    Hexagonal,
32    /// Feature-Sliced Design: app > pages > widgets > features > entities > shared.
33    /// Each layer may only import from layers below it.
34    FeatureSliced,
35    /// Bulletproof React: app → features → shared + server.
36    /// Feature modules are isolated from each other via `autoDiscover`: every
37    /// immediate child of `src/features/` becomes its own `features/<name>` zone,
38    /// and cross-feature imports are reported as boundary violations.
39    /// Top-level files in `src/features/` are classified by the logical
40    /// `features` parent zone, so barrels can re-export child features while
41    /// non-barrel top-level files still obey the `features` boundary rule.
42    Bulletproof,
43}
44
45impl BoundaryPreset {
46    /// Expand the preset into default zones and rules.
47    ///
48    /// `source_root` is the directory prefix for zone patterns (e.g., `"src"`, `"lib"`).
49    /// Patterns are generated as `{source_root}/{zone_name}/**`.
50    #[must_use]
51    pub fn default_config(&self, source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
52        match self {
53            Self::Layered => Self::layered_config(source_root),
54            Self::Hexagonal => Self::hexagonal_config(source_root),
55            Self::FeatureSliced => Self::feature_sliced_config(source_root),
56            Self::Bulletproof => Self::bulletproof_config(source_root),
57        }
58    }
59
60    fn zone(name: &str, source_root: &str) -> BoundaryZone {
61        BoundaryZone {
62            name: name.to_owned(),
63            patterns: vec![format!("{source_root}/{name}/**")],
64            auto_discover: vec![],
65            root: None,
66        }
67    }
68
69    fn rule(from: &str, allow: &[&str]) -> BoundaryRule {
70        BoundaryRule {
71            from: from.to_owned(),
72            allow: allow.iter().map(|s| (*s).to_owned()).collect(),
73            allow_type_only: Vec::new(),
74        }
75    }
76
77    fn layered_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
78        let zones = vec![
79            Self::zone("presentation", source_root),
80            Self::zone("application", source_root),
81            Self::zone("domain", source_root),
82            Self::zone("infrastructure", source_root),
83        ];
84        let rules = vec![
85            Self::rule("presentation", &["application"]),
86            Self::rule("application", &["domain"]),
87            Self::rule("domain", &[]),
88            Self::rule("infrastructure", &["domain", "application"]),
89        ];
90        (zones, rules)
91    }
92
93    fn hexagonal_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
94        let zones = vec![
95            Self::zone("adapters", source_root),
96            Self::zone("ports", source_root),
97            Self::zone("domain", source_root),
98        ];
99        let rules = vec![
100            Self::rule("adapters", &["ports"]),
101            Self::rule("ports", &["domain"]),
102            Self::rule("domain", &[]),
103        ];
104        (zones, rules)
105    }
106
107    fn feature_sliced_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
108        let layer_names = ["app", "pages", "widgets", "features", "entities", "shared"];
109        let zones = layer_names
110            .iter()
111            .map(|name| Self::zone(name, source_root))
112            .collect();
113        let rules = layer_names
114            .iter()
115            .enumerate()
116            .map(|(i, name)| {
117                let below: Vec<&str> = layer_names[i + 1..].to_vec();
118                Self::rule(name, &below)
119            })
120            .collect();
121        (zones, rules)
122    }
123
124    fn bulletproof_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
125        let zones = vec![
126            Self::zone("app", source_root),
127            BoundaryZone {
128                // Discovered child zones classify concrete feature modules
129                // first; the parent pattern catches top-level feature files
130                // such as barrels and shared types.
131                name: "features".to_owned(),
132                patterns: vec![format!("{source_root}/features/**")],
133                auto_discover: vec![format!("{source_root}/features")],
134                root: None,
135            },
136            BoundaryZone {
137                name: "shared".to_owned(),
138                patterns: [
139                    "components",
140                    "hooks",
141                    "lib",
142                    "utils",
143                    "utilities",
144                    "providers",
145                    "shared",
146                    "types",
147                    "styles",
148                    "i18n",
149                ]
150                .iter()
151                .map(|dir| format!("{source_root}/{dir}/**"))
152                .collect(),
153                auto_discover: vec![],
154                root: None,
155            },
156            Self::zone("server", source_root),
157        ];
158        let rules = vec![
159            Self::rule("app", &["features", "shared", "server"]),
160            Self::rule("features", &["shared", "server"]),
161            Self::rule("server", &["shared"]),
162            Self::rule("shared", &[]),
163        ];
164        (zones, rules)
165    }
166}
167
168/// Architecture boundary configuration.
169///
170/// Defines zones (directory groupings) and rules (which zones may import from which).
171/// Optionally uses a built-in preset as a starting point.
172///
173/// # Examples
174///
175/// ```
176/// use fallow_config::BoundaryConfig;
177///
178/// let json = r#"{
179///     "zones": [
180///         { "name": "ui", "patterns": ["src/components/**"] },
181///         { "name": "db", "patterns": ["src/db/**"] }
182///     ],
183///     "rules": [
184///         { "from": "ui", "allow": ["db"] }
185///     ]
186/// }"#;
187/// let config: BoundaryConfig = serde_json::from_str(json).unwrap();
188/// assert_eq!(config.zones.len(), 2);
189/// assert_eq!(config.rules.len(), 1);
190/// ```
191///
192/// Using a preset:
193///
194/// ```
195/// use fallow_config::BoundaryConfig;
196///
197/// let json = r#"{ "preset": "layered" }"#;
198/// let mut config: BoundaryConfig = serde_json::from_str(json).unwrap();
199/// config.expand("src");
200/// assert_eq!(config.zones.len(), 4);
201/// assert_eq!(config.rules.len(), 4);
202/// ```
203#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
204#[serde(rename_all = "camelCase")]
205pub struct BoundaryConfig {
206    /// Built-in architecture preset. When set, expands into default zones and rules.
207    /// User-defined zones and rules merge on top: zones with the same name replace
208    /// the preset zone; rules with the same `from` replace the preset rule.
209    /// Preset patterns use `{rootDir}/{zone}/**` where rootDir is auto-detected
210    /// from tsconfig.json (falls back to `src`).
211    /// Note: preset patterns are flat (`src/<zone>/**`). For monorepos with
212    /// per-package source directories, define zones explicitly instead.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub preset: Option<BoundaryPreset>,
215    /// Named zones mapping directory patterns to architectural layers.
216    #[serde(default)]
217    pub zones: Vec<BoundaryZone>,
218    /// Import rules between zones. A zone with a rule entry can only import
219    /// from the listed zones (plus itself). A zone without a rule entry is unrestricted.
220    #[serde(default)]
221    pub rules: Vec<BoundaryRule>,
222}
223
224/// A named zone grouping files by directory pattern.
225#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
226#[serde(rename_all = "camelCase")]
227pub struct BoundaryZone {
228    /// Zone identifier referenced in rules (e.g., `"ui"`, `"database"`, `"shared"`).
229    pub name: String,
230    /// Glob patterns (relative to project root) that define zone membership.
231    /// A file belongs to the first zone whose pattern matches.
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub patterns: Vec<String>,
234    /// Directories whose immediate child directories should become separate
235    /// zones under this logical group.
236    ///
237    /// For example, `{ "name": "features", "autoDiscover": ["src/features"] }`
238    /// creates zones such as `features/auth` and `features/billing`, each with
239    /// a pattern for its own subtree. Rules that reference `features` expand to
240    /// every discovered child zone. If `patterns` is also set, the parent zone
241    /// remains as a fallback after discovered child zones.
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub auto_discover: Vec<String>,
244    /// Optional subtree scope for monorepo per-package boundaries.
245    ///
246    /// When set, the zone's `patterns` are matched against paths *relative*
247    /// to this directory rather than the project root. At classification
248    /// time, fallow checks that a candidate path starts with `root` and
249    /// strips that prefix before glob-matching the patterns against the
250    /// remainder. Files outside the subtree never match the zone.
251    ///
252    /// Useful for monorepos where each package has the same internal
253    /// directory layout: instead of writing `packages/app/src/**` and
254    /// `packages/core/src/**` (which collide on shared zone names), set
255    /// `root: "packages/app/"` and `patterns: ["src/**"]` per package.
256    ///
257    /// Trailing slash and leading `./` are normalized; backslashes are
258    /// converted to forward slashes. Patterns must NOT redundantly include
259    /// the root prefix: `root: "packages/app/"` with
260    /// `patterns: ["packages/app/src/**"]` is rejected with
261    /// `FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX` because patterns are
262    /// resolved relative to the root.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub root: Option<String>,
265}
266
267/// An import rule between zones.
268#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
269#[serde(rename_all = "camelCase")]
270pub struct BoundaryRule {
271    /// The zone this rule applies to (the importing side).
272    pub from: String,
273    /// Zones that `from` is allowed to import from. Self-imports are always allowed.
274    /// An empty list means the zone may not import from any other zone.
275    #[serde(default)]
276    pub allow: Vec<String>,
277    /// Zones that `from` may type-only-import from even when not listed in
278    /// `allow`. Mirrors the `allow` shape: a list of target zone names. A
279    /// type-only import declaration (`import type {...}`, `import type * as ns`,
280    /// or a per-specifier inline `type` qualifier on every named specifier) to a
281    /// listed zone is not reported as a boundary violation. Mixed-specifier
282    /// imports (`import { type Foo, Bar }`) that carry at least one value
283    /// symbol still fire because the runtime dependency on `Bar` is real.
284    /// Type-only re-exports (`export type { Foo } from "..."`) participate
285    /// in the same allowance because they surface as edges flagged
286    /// `is_type_only: true` and, like type-only imports, are erased at
287    /// compile time.
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub allow_type_only: Vec<String>,
290}
291
292/// Resolved boundary config with pre-compiled glob matchers.
293#[derive(Debug, Default)]
294pub struct ResolvedBoundaryConfig {
295    /// Zones with compiled glob matchers for fast file classification.
296    pub zones: Vec<ResolvedZone>,
297    /// Rules indexed by source zone name.
298    pub rules: Vec<ResolvedBoundaryRule>,
299    /// Pre-expansion logical groups captured during `expand_auto_discover`,
300    /// preserved here for observability (`fallow list --boundaries --format
301    /// json`). One entry per `autoDiscover`-bearing zone in user-declaration
302    /// order. Empty unless the user (or a preset) wrote at least one
303    /// `autoDiscover`. See [`LogicalGroup`] for the per-entry shape.
304    pub logical_groups: Vec<LogicalGroup>,
305}
306
307/// A user-declared zone that fanned out into one or more child zones via
308/// `autoDiscover`. Surfaced verbatim through `fallow list --boundaries
309/// --format json` so consumers (config UIs, Sankey renderers, agent-driven
310/// config tooling, dashboards) can reconstruct the original grouping intent
311/// after expansion has flattened the parent name out of `zones[]`.
312#[derive(Debug, Clone, Serialize, JsonSchema)]
313#[serde(rename_all = "snake_case")]
314pub struct LogicalGroup {
315    /// Logical parent zone name as authored by the user (e.g. `"features"`).
316    pub name: String,
317    /// Discovered child zone names in stable directory-sorted order
318    /// (e.g. `["features/auth", "features/billing"]`). Empty when the parent
319    /// directory was empty or unreadable; `status` discriminates the two.
320    pub children: Vec<String>,
321    /// The exact `autoDiscover` strings the user wrote, preserved verbatim
322    /// (no normalization). Round-trip tooling depends on byte-exact match
323    /// against the user's config source.
324    pub auto_discover: Vec<String>,
325    /// Pre-expansion rule keyed on this parent zone name, captured before
326    /// `expand_auto_discover` rewrote it into per-child rules. `None` when
327    /// the user wrote no rule for the parent (the children are then
328    /// unrestricted unless a per-child rule exists).
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub authored_rule: Option<AuthoredRule>,
331    /// When the parent zone also carried explicit `patterns`, it stayed in
332    /// `zones[]` after expansion as a fallback classifier. This is its name
333    /// (always equal to [`Self::name`]). `None` when the parent had no
334    /// patterns and was dropped from `zones[]` entirely. Lets consumers wire
335    /// the logical-group entry to its zone twin without name-matching
336    /// heuristics.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub fallback_zone: Option<String>,
339    /// Position of the parent zone in the user's pre-expansion `zones[]`
340    /// array. Enables byte-accurate config patches by agent tooling without
341    /// re-parsing the user's config source.
342    pub source_zone_index: usize,
343    /// Why [`Self::children`] is what it is.
344    pub status: LogicalGroupStatus,
345    /// Parent zone indices whose declarations were merged into this group
346    /// because they shared a name (`{ name: "features", autoDiscover: [...] }`
347    /// declared twice). `None` on the common case (single declaration);
348    /// `Some([i, j, ...])` when at least two declarations were merged. The
349    /// FIRST entry equals [`Self::source_zone_index`]; subsequent entries are
350    /// the positions of the additional declarations in user-declaration order.
351    /// Surfaced in JSON so consumers (config-edit agents, config-hygiene
352    /// dashboards) can detect duplicates that `tracing::warn!` would otherwise
353    /// hide from `--format json` consumption.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub merged_from: Option<Vec<usize>>,
356    /// The parent zone's `root` (subtree scope) as the user authored it,
357    /// echoed onto the logical group so monorepo-aware tooling can tell
358    /// whether `root` was set on the parent (and inherited by every
359    /// discovered child) or set per-child. `None` when the parent had no
360    /// `root` field. The string is verbatim from the user's config (not
361    /// the post-`normalize_zone_root` form) for byte-exact round-trip.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub original_zone_root: Option<String>,
364    /// For each entry in [`Self::children`], the index into
365    /// [`Self::auto_discover`] of the path that produced it (or the FIRST
366    /// path that produced it when multiple `autoDiscover` entries each yield
367    /// the same child name). Empty when only one `autoDiscover` path was
368    /// authored (every child trivially maps to index 0); populated only when
369    /// the parent has two or more `autoDiscover` entries so consumers can
370    /// attribute children to specific source directories. The length equals
371    /// `children.len()` when populated.
372    ///
373    /// `#[serde(default)]` pairs with `skip_serializing_if` so the JSON
374    /// runtime omits this field on the common single-path case AND the
375    /// derived schema marks it optional (schemars 1 promotes any field with a
376    /// `serde(default)` attribute out of `required`).
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub child_source_indices: Vec<usize>,
379}
380
381/// Discovery outcome for a [`LogicalGroup`]. Discriminates "no children" into
382/// "the directory exists and is empty" versus "at least one `autoDiscover`
383/// path was invalid or unreadable", so consumers can render an actionable
384/// hint instead of "0 children, mystery".
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
386#[serde(rename_all = "snake_case")]
387pub enum LogicalGroupStatus {
388    /// At least one child zone was discovered.
389    Ok,
390    /// Every `autoDiscover` path resolved to a readable directory, but
391    /// none contained child directories.
392    Empty,
393    /// At least one `autoDiscover` path was malformed (contained `..`,
394    /// absolute) or did not resolve to a readable directory, and zero
395    /// children were discovered across all paths. When a mix of invalid and
396    /// valid paths produces children, status is [`Self::Ok`] instead.
397    InvalidPath,
398}
399
400/// Pre-expansion `from`-rule preserved on a [`LogicalGroup`]. Surfaces the
401/// user's original intent (`{ from: "features", allow: ["shared"] }`) even
402/// after `expand_auto_discover` rewrote it into per-child rules
403/// (`features/auth -> shared`, `features/billing -> shared`).
404#[derive(Debug, Clone, Serialize, JsonSchema)]
405pub struct AuthoredRule {
406    /// Pre-expansion `allow` list as the user wrote it.
407    pub allow: Vec<String>,
408    /// Pre-expansion `allowTypeOnly` list as the user wrote it. Omitted
409    /// from JSON output when empty; `serde(default)` keeps the derived
410    /// schema in lock-step (schemars 1 marks any field with a
411    /// `serde(default)` attribute as non-required).
412    #[serde(default, skip_serializing_if = "Vec::is_empty")]
413    pub allow_type_only: Vec<String>,
414}
415
416/// A zone with pre-compiled glob matchers.
417#[derive(Debug)]
418pub struct ResolvedZone {
419    /// Zone identifier.
420    pub name: String,
421    /// Pre-compiled glob matchers for zone membership.
422    /// When `root` is set, matchers are applied to the path with the
423    /// `root` prefix stripped (subtree-relative patterns).
424    pub matchers: Vec<globset::GlobMatcher>,
425    /// Normalized subtree scope (e.g. `"packages/app/"`). When present,
426    /// only paths starting with this prefix can match this zone, and the
427    /// prefix is stripped before glob matching. Forward slashes only,
428    /// always trailing slash. `None` means patterns are matched against
429    /// the project-root-relative path as-is.
430    pub root: Option<String>,
431}
432
433/// A resolved boundary rule.
434#[derive(Debug)]
435pub struct ResolvedBoundaryRule {
436    /// The zone this rule restricts.
437    pub from_zone: String,
438    /// Zones that `from_zone` is allowed to import from.
439    pub allowed_zones: Vec<String>,
440    /// Zones that `from_zone` may type-only-import from even when not listed
441    /// in `allowed_zones`. See `BoundaryRule::allow_type_only`.
442    pub allow_type_only_zones: Vec<String>,
443}
444
445impl BoundaryConfig {
446    /// Whether any boundaries are configured (including via preset).
447    #[must_use]
448    pub fn is_empty(&self) -> bool {
449        self.preset.is_none() && self.zones.is_empty()
450    }
451
452    /// Expand the preset (if set) into zones and rules, merging user overrides on top.
453    ///
454    /// `source_root` is the directory prefix for preset zone patterns (e.g., `"src"`).
455    /// After expansion, `self.preset` is cleared and all zones/rules are explicit.
456    ///
457    /// Merge semantics:
458    /// - User zones with the same name as a preset zone **replace** the preset zone entirely.
459    /// - User rules with the same `from` as a preset rule **replace** the preset rule.
460    /// - User zones/rules with new names **add** to the preset set.
461    pub fn expand(&mut self, source_root: &str) {
462        let Some(preset) = self.preset.take() else {
463            return;
464        };
465
466        let (preset_zones, preset_rules) = preset.default_config(source_root);
467
468        // Build set of user-defined zone names for override detection.
469        let user_zone_names: rustc_hash::FxHashSet<&str> =
470            self.zones.iter().map(|z| z.name.as_str()).collect();
471
472        // Start with preset zones, replacing any that the user overrides.
473        let mut merged_zones: Vec<BoundaryZone> = preset_zones
474            .into_iter()
475            .filter(|pz| {
476                if user_zone_names.contains(pz.name.as_str()) {
477                    tracing::info!(
478                        "boundary preset: user zone '{}' replaces preset zone",
479                        pz.name
480                    );
481                    false
482                } else {
483                    true
484                }
485            })
486            .collect();
487        // Append all user zones (both overrides and additions).
488        merged_zones.append(&mut self.zones);
489        self.zones = merged_zones;
490
491        // Build set of user-defined rule `from` names for override detection.
492        let user_rule_sources: rustc_hash::FxHashSet<&str> =
493            self.rules.iter().map(|r| r.from.as_str()).collect();
494
495        let mut merged_rules: Vec<BoundaryRule> = preset_rules
496            .into_iter()
497            .filter(|pr| {
498                if user_rule_sources.contains(pr.from.as_str()) {
499                    tracing::info!(
500                        "boundary preset: user rule for '{}' replaces preset rule",
501                        pr.from
502                    );
503                    false
504                } else {
505                    true
506                }
507            })
508            .collect();
509        merged_rules.append(&mut self.rules);
510        self.rules = merged_rules;
511    }
512
513    /// Expand auto-discovered boundary groups into concrete child zones.
514    ///
515    /// A zone with `autoDiscover: ["src/features"]` discovers the immediate
516    /// child directories below `src/features` and emits child zones named
517    /// `zone_name/child`. Rules that reference the logical parent are expanded
518    /// to all discovered children. If the parent also has explicit `patterns`,
519    /// it is kept after the children as a fallback so child directories remain
520    /// isolated by first-match classification. The parent fallback rule
521    /// automatically allows its discovered children so top-level barrels can
522    /// re-export child modules without relaxing sibling isolation on the child
523    /// rules.
524    ///
525    /// Returns one [`LogicalGroup`] per pre-expansion zone that carried a
526    /// non-empty `autoDiscover`, in user-declaration order. The caller (the
527    /// resolution pipeline) stashes the result onto
528    /// [`ResolvedBoundaryConfig::logical_groups`] for `fallow list
529    /// --boundaries --format json` to render. Discarding the return is fine
530    /// for callers that only need the expansion side effect (classification);
531    /// the data is regenerated on the next run.
532    ///
533    /// Duplicate parent zone name behavior: when two `BoundaryZone`
534    /// declarations share a name and both carry `autoDiscover`, their
535    /// discovered children merge into a single `LogicalGroup` whose
536    /// `auto_discover` concatenates both source path lists in declaration
537    /// order. This mirrors the existing rule-side merge behavior (both rules
538    /// expand to the same union of child names). A `tracing::warn!` surfaces
539    /// the duplicate at config-load time so the user can deduplicate the
540    /// source; the merged behavior is a soft default rather than a hard
541    /// rejection so existing configs continue to load.
542    pub fn expand_auto_discover(&mut self, project_root: &Path) -> Vec<LogicalGroup> {
543        if self.zones.iter().all(|zone| zone.auto_discover.is_empty()) {
544            return Vec::new();
545        }
546
547        let original_zones = std::mem::take(&mut self.zones);
548        let mut expanded_zones = Vec::new();
549        let mut group_expansions: rustc_hash::FxHashMap<String, Vec<String>> =
550            rustc_hash::FxHashMap::default();
551        // Preserves user-declaration order: `FxHashMap` iteration is not
552        // insertion-ordered, and consumers (snapshot tests, diff-based
553        // dashboards) depend on stable JSON output across runs.
554        let mut group_drafts: Vec<LogicalGroupDraft> = Vec::new();
555
556        for (source_zone_index, mut zone) in original_zones.into_iter().enumerate() {
557            if zone.auto_discover.is_empty() {
558                expanded_zones.push(zone);
559                continue;
560            }
561
562            let group_name = zone.name.clone();
563            // Capture the user's verbatim `autoDiscover` strings before
564            // discovery normalizes them; round-trip tooling depends on
565            // byte-exact match against the source.
566            let raw_auto_discover = zone.auto_discover.clone();
567            let original_zone_root = zone.root.clone();
568            let DiscoveryOutcome {
569                zones: discovered_zones,
570                source_indices: discovered_source_indices,
571                had_invalid_path,
572            } = discover_child_zones(project_root, &zone);
573            let discovered_count = discovered_zones.len();
574            let mut expanded_names: Vec<String> = discovered_zones
575                .iter()
576                .map(|child| child.name.clone())
577                .collect();
578            let child_names_only = expanded_names.clone();
579            for child_zone in discovered_zones {
580                merge_zone_by_name(&mut expanded_zones, child_zone);
581            }
582
583            let fallback_zone = if zone.patterns.is_empty() {
584                None
585            } else {
586                expanded_names.push(group_name.clone());
587                zone.auto_discover.clear();
588                merge_zone_by_name(&mut expanded_zones, zone);
589                Some(group_name.clone())
590            };
591
592            if !expanded_names.is_empty() {
593                group_expansions
594                    .entry(group_name.clone())
595                    .or_default()
596                    .extend(expanded_names);
597            }
598
599            let status = if discovered_count > 0 {
600                LogicalGroupStatus::Ok
601            } else if had_invalid_path {
602                LogicalGroupStatus::InvalidPath
603            } else {
604                LogicalGroupStatus::Empty
605            };
606
607            // Merge into existing draft if the user declared the same parent
608            // name twice. Concatenates `auto_discover`, dedupes `children`
609            // against the existing set so a duplicate declaration discovering
610            // the same child does not double-count via `file_count` lookup,
611            // preserves the FIRST `source_zone_index` and `original_zone_root`,
612            // shifts the new batch's `child_source_indices` by the existing
613            // `auto_discover.len()` so they continue to address the
614            // post-concatenation array (and drops indices for children
615            // already present, since attribution belongs to the first
616            // producer), and appends the new `source_zone_index` to
617            // `merged_from` so the duplicate is visible in JSON output.
618            if let Some(existing) = group_drafts.iter_mut().find(|d| d.name == group_name) {
619                tracing::warn!(
620                    "boundary zone '{}' is declared multiple times with autoDiscover; merging discovered children",
621                    group_name
622                );
623                let auto_discover_offset = existing.auto_discover.len();
624                existing.auto_discover.extend(raw_auto_discover);
625                let existing_children: rustc_hash::FxHashSet<String> =
626                    existing.children.iter().cloned().collect();
627                for (idx, name) in child_names_only.iter().enumerate() {
628                    if existing_children.contains(name) {
629                        continue;
630                    }
631                    existing.children.push(name.clone());
632                    existing
633                        .child_source_indices
634                        .push(discovered_source_indices[idx] + auto_discover_offset);
635                }
636                if existing.fallback_zone.is_none() {
637                    existing.fallback_zone = fallback_zone;
638                }
639                existing.status = merge_status(existing.status, status);
640                let chain = existing
641                    .merged_from
642                    .get_or_insert_with(|| vec![existing.source_zone_index]);
643                chain.push(source_zone_index);
644            } else {
645                group_drafts.push(LogicalGroupDraft {
646                    name: group_name,
647                    children: child_names_only,
648                    auto_discover: raw_auto_discover,
649                    fallback_zone,
650                    source_zone_index,
651                    status,
652                    merged_from: None,
653                    original_zone_root,
654                    child_source_indices: discovered_source_indices,
655                });
656            }
657        }
658
659        self.zones = expanded_zones;
660
661        // Index draft names so we can look up the authored rule per logical
662        // group regardless of whether the group produced any children.
663        // Groups whose discovery was Empty / InvalidPath contribute NO entry
664        // to `group_expansions` (no children means no rule expansion), but
665        // their authored rule still belongs on the surfaced LogicalGroup so
666        // consumers see the user's intent even when discovery turned up
667        // empty.
668        let draft_names: rustc_hash::FxHashSet<&str> =
669            group_drafts.iter().map(|d| d.name.as_str()).collect();
670
671        // Capture authored rules BEFORE `original_rules` is consumed below.
672        // The match-up is by `rule.from == group_name`; the last matching
673        // rule wins to mirror `dedupe_rules_keep_last` semantics.
674        let original_rules = std::mem::take(&mut self.rules);
675        let authored_rules: rustc_hash::FxHashMap<&str, AuthoredRule> = original_rules
676            .iter()
677            .filter(|rule| draft_names.contains(rule.from.as_str()))
678            .map(|rule| {
679                (
680                    rule.from.as_str(),
681                    AuthoredRule {
682                        allow: rule.allow.clone(),
683                        allow_type_only: rule.allow_type_only.clone(),
684                    },
685                )
686            })
687            .collect();
688
689        let logical_groups: Vec<LogicalGroup> = group_drafts
690            .into_iter()
691            .map(|draft| {
692                // `child_source_indices` is only signal-bearing when the
693                // parent has two or more `auto_discover` paths; with one
694                // path every child trivially has index 0. Skip the noise
695                // on the common case so the JSON stays tight; the field
696                // is `#[serde(skip_serializing_if = "Vec::is_empty")]`.
697                let child_source_indices = if draft.auto_discover.len() > 1 {
698                    draft.child_source_indices
699                } else {
700                    Vec::new()
701                };
702                LogicalGroup {
703                    authored_rule: authored_rules.get(draft.name.as_str()).cloned(),
704                    name: draft.name,
705                    children: draft.children,
706                    auto_discover: draft.auto_discover,
707                    fallback_zone: draft.fallback_zone,
708                    source_zone_index: draft.source_zone_index,
709                    status: draft.status,
710                    merged_from: draft.merged_from,
711                    original_zone_root: draft.original_zone_root,
712                    child_source_indices,
713                }
714            })
715            .collect();
716
717        if group_expansions.is_empty() {
718            // No groups produced any children, so rule expansion is a no-op;
719            // restore the rules verbatim. `logical_groups` still carries the
720            // Empty / InvalidPath drafts so consumers can render the user's
721            // grouping intent and act on the "discovery turned up nothing"
722            // signal.
723            self.rules = original_rules;
724            return logical_groups;
725        }
726
727        self.rules = expand_rules_for_groups(original_rules, &group_expansions);
728        logical_groups
729    }
730}
731
732/// Merge a discovered (or fallback) zone into the post-expansion zones
733/// vector by name. A naive `expanded_zones.push(zone)` duplicates entries
734/// when the user declared the same parent name twice (each iteration of the
735/// outer expansion loop re-runs discovery on its own `autoDiscover` paths
736/// and would push the same child names again, producing duplicates in
737/// `zones[]` AND triggering the `file_count` summation in
738/// `compute_boundary_data` to double-count each child). Merging by name
739/// keeps `zones[]` unique and unifies the patterns from both declarations
740/// on the same `BoundaryZone`. Existing patterns are preserved verbatim;
741/// only NEW patterns are appended.
742fn merge_zone_by_name(expanded_zones: &mut Vec<BoundaryZone>, zone: BoundaryZone) {
743    if let Some(existing) = expanded_zones.iter_mut().find(|z| z.name == zone.name) {
744        for pattern in zone.patterns {
745            if !existing.patterns.contains(&pattern) {
746                existing.patterns.push(pattern);
747            }
748        }
749    } else {
750        expanded_zones.push(zone);
751    }
752}
753
754/// Rewrite the user's pre-expansion rules to reference the discovered child
755/// zones in place of the logical parent. Three rule shapes are produced:
756///
757/// 1. Rules whose `from` is the parent group expand into one explicit rule
758///    per child (or one for the parent fallback when the parent kept its
759///    `patterns`).
760/// 2. Rules whose `allow` references a group expand to allow every child
761///    of that group.
762/// 3. Rules untouched by group expansion pass through unchanged.
763///
764/// Extracted out of [`BoundaryConfig::expand_auto_discover`] so the
765/// orchestrator stays under the SIG unit-size threshold; the body itself
766/// is unchanged from the pre-#373 inline form.
767fn expand_rules_for_groups(
768    original_rules: Vec<BoundaryRule>,
769    group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
770) -> Vec<BoundaryRule> {
771    let mut generated_rules = Vec::new();
772    let mut explicit_rules = Vec::new();
773    for rule in original_rules {
774        let allow = expand_rule_allow(&rule.allow, group_expansions);
775        let allow_type_only = expand_rule_allow(&rule.allow_type_only, group_expansions);
776
777        if let Some(from_zones) = group_expansions.get(&rule.from) {
778            for from in from_zones {
779                let (allow, allow_type_only) = if from == &rule.from {
780                    (
781                        expand_parent_fallback_allow(&allow, from_zones, &rule.from),
782                        allow_type_only.clone(),
783                    )
784                } else {
785                    (
786                        expand_generated_child_allow(&rule.allow, group_expansions, &rule.from),
787                        expand_generated_child_allow(
788                            &rule.allow_type_only,
789                            group_expansions,
790                            &rule.from,
791                        ),
792                    )
793                };
794                let expanded_rule = BoundaryRule {
795                    from: from.clone(),
796                    allow,
797                    allow_type_only,
798                };
799                if from == &rule.from {
800                    explicit_rules.push(expanded_rule);
801                } else {
802                    generated_rules.push(expanded_rule);
803                }
804            }
805        } else {
806            explicit_rules.push(BoundaryRule {
807                from: rule.from,
808                allow,
809                allow_type_only,
810            });
811        }
812    }
813
814    let mut expanded_rules = dedupe_rules_keep_last(generated_rules);
815    expanded_rules.extend(dedupe_rules_keep_last(explicit_rules));
816    dedupe_rules_keep_last(expanded_rules)
817}
818
819impl BoundaryConfig {
820    /// Return the preset name if one is configured but not yet expanded.
821    #[must_use]
822    pub fn preset_name(&self) -> Option<&str> {
823        self.preset.as_ref().map(|p| match p {
824            BoundaryPreset::Layered => "layered",
825            BoundaryPreset::Hexagonal => "hexagonal",
826            BoundaryPreset::FeatureSliced => "feature-sliced",
827            BoundaryPreset::Bulletproof => "bulletproof",
828        })
829    }
830
831    /// Validate that no zone's pattern redundantly includes its `root`
832    /// prefix. Returns a list of error messages tagged with
833    /// `FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX`. Patterns are resolved
834    /// relative to the zone root, so prefixing the pattern with the same
835    /// root double-prefixes the path and never matches.
836    #[must_use]
837    pub fn validate_root_prefixes(&self) -> Vec<String> {
838        let mut errors = Vec::new();
839        for zone in &self.zones {
840            let Some(raw_root) = zone.root.as_deref() else {
841                continue;
842            };
843            let normalized = normalize_zone_root(raw_root);
844            // Skip empty-root zones: `""`, `"."`, and `"./"` all normalize to
845            // `""`, which behaves as no root at classification time. Without
846            // this guard `starts_with("")` is always true and every pattern
847            // produces a spurious redundant-prefix error.
848            if normalized.is_empty() {
849                continue;
850            }
851            for pattern in &zone.patterns {
852                let normalized_pattern = pattern.replace('\\', "/");
853                let stripped = normalized_pattern
854                    .strip_prefix("./")
855                    .unwrap_or(&normalized_pattern);
856                if stripped.starts_with(&normalized) {
857                    errors.push(format!(
858                        "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.",
859                        zone.name, pattern, normalized
860                    ));
861                }
862            }
863        }
864        errors
865    }
866
867    /// Validate that all zone names referenced in rules are defined in `zones`.
868    /// Returns a list of (rule_index, undefined_zone_name) pairs.
869    ///
870    /// Walks every zone-reference surface on `BoundaryRule`: `from`, `allow`,
871    /// and `allow_type_only`. An unknown zone in `allow_type_only` silently
872    /// behaves as "not allowed" at runtime, so it MUST surface here for parity
873    /// with the existing `allow`-side diagnostic.
874    #[must_use]
875    pub fn validate_zone_references(&self) -> Vec<(usize, &str)> {
876        let zone_names: rustc_hash::FxHashSet<&str> =
877            self.zones.iter().map(|z| z.name.as_str()).collect();
878
879        let mut errors = Vec::new();
880        for (i, rule) in self.rules.iter().enumerate() {
881            if !zone_names.contains(rule.from.as_str()) {
882                errors.push((i, rule.from.as_str()));
883            }
884            for allowed in &rule.allow {
885                if !zone_names.contains(allowed.as_str()) {
886                    errors.push((i, allowed.as_str()));
887                }
888            }
889            for allowed_type_only in &rule.allow_type_only {
890                if !zone_names.contains(allowed_type_only.as_str()) {
891                    errors.push((i, allowed_type_only.as_str()));
892                }
893            }
894        }
895        errors
896    }
897
898    /// Resolve into compiled form with pre-built glob matchers.
899    /// Invalid glob patterns are logged and skipped.
900    #[must_use]
901    pub fn resolve(&self) -> ResolvedBoundaryConfig {
902        let zones = self
903            .zones
904            .iter()
905            .map(|zone| {
906                let matchers = zone
907                    .patterns
908                    .iter()
909                    .filter_map(|pattern| match Glob::new(pattern) {
910                        Ok(glob) => Some(glob.compile_matcher()),
911                        Err(e) => {
912                            tracing::warn!(
913                                "invalid boundary zone glob pattern '{}' in zone '{}': {e}",
914                                pattern,
915                                zone.name
916                            );
917                            None
918                        }
919                    })
920                    .collect();
921                let root = zone.root.as_deref().map(normalize_zone_root);
922                ResolvedZone {
923                    name: zone.name.clone(),
924                    matchers,
925                    root,
926                }
927            })
928            .collect();
929
930        let rules = self
931            .rules
932            .iter()
933            .map(|rule| ResolvedBoundaryRule {
934                from_zone: rule.from.clone(),
935                allowed_zones: rule.allow.clone(),
936                allow_type_only_zones: rule.allow_type_only.clone(),
937            })
938            .collect();
939
940        ResolvedBoundaryConfig {
941            zones,
942            rules,
943            // `expand_auto_discover` is the only producer; the resolution
944            // pipeline (`crates/config/src/config/resolution.rs`) assigns the
945            // returned `Vec<LogicalGroup>` onto the resolved boundaries after
946            // `resolve()` runs. `resolve()` itself has no view of the
947            // pre-expansion state, so it leaves the field empty here.
948            logical_groups: Vec::new(),
949        }
950    }
951}
952
953/// Normalize a zone `root` string into the canonical form used at
954/// classification time: forward slashes, no leading `./`, always a
955/// trailing slash. Empty / `"."` / `"./"` collapse to `""` which means
956/// "subtree is the project root" and effectively behaves like no root.
957fn normalize_zone_root(raw: &str) -> String {
958    let with_slashes = raw.replace('\\', "/");
959    let trimmed = with_slashes.trim_start_matches("./");
960    let no_dot = if trimmed == "." { "" } else { trimmed };
961    if no_dot.is_empty() {
962        String::new()
963    } else if no_dot.ends_with('/') {
964        no_dot.to_owned()
965    } else {
966        format!("{no_dot}/")
967    }
968}
969
970fn normalize_auto_discover_dir(raw: &str) -> Option<String> {
971    let with_slashes = raw.replace('\\', "/");
972    let trimmed = with_slashes.trim_start_matches("./").trim_end_matches('/');
973    if trimmed.starts_with('/') || trimmed.split('/').any(|part| part == "..") {
974        None
975    } else if trimmed == "." {
976        Some(String::new())
977    } else {
978        Some(trimmed.to_owned())
979    }
980}
981
982fn join_relative_path(prefix: &str, suffix: &str) -> String {
983    match (prefix.is_empty(), suffix.is_empty()) {
984        (true, true) => String::new(),
985        (true, false) => suffix.to_owned(),
986        (false, true) => prefix.trim_end_matches('/').to_owned(),
987        (false, false) => format!("{}/{}", prefix.trim_end_matches('/'), suffix),
988    }
989}
990
991/// Discovery result for a single auto-discover zone. Carries the discovered
992/// child `BoundaryZone`s, a flag for "at least one `autoDiscover` path was
993/// malformed or unreadable" (distinguishes [`LogicalGroupStatus::InvalidPath`]
994/// from [`LogicalGroupStatus::Empty`]), and parallel-to-zones
995/// `source_indices` recording which `autoDiscover` entry produced each child
996/// (FIRST producer wins when two paths yield the same child name).
997struct DiscoveryOutcome {
998    zones: Vec<BoundaryZone>,
999    source_indices: Vec<usize>,
1000    had_invalid_path: bool,
1001}
1002
1003/// Intermediate accumulator for a [`LogicalGroup`] before its
1004/// [`AuthoredRule`] is resolved (rules are not consumed until after the zone
1005/// loop completes, so the rule lookup happens in a second pass).
1006struct LogicalGroupDraft {
1007    name: String,
1008    children: Vec<String>,
1009    auto_discover: Vec<String>,
1010    fallback_zone: Option<String>,
1011    source_zone_index: usize,
1012    status: LogicalGroupStatus,
1013    /// `None` until a second declaration with the same `name` is merged in;
1014    /// then `Some(vec![first_index, ..])` with one entry per merged
1015    /// declaration in user-declaration order.
1016    merged_from: Option<Vec<usize>>,
1017    /// Echo of the parent zone's `root` field as the user authored it
1018    /// (verbatim, not normalized). On duplicate-merge, the FIRST declaration
1019    /// wins (consistent with `source_zone_index`).
1020    original_zone_root: Option<String>,
1021    /// Parallel to `children`: for child at index `i`, the index into
1022    /// `auto_discover` of the path that produced it (FIRST producer wins on
1023    /// collisions). When merging duplicate parent declarations, indices from
1024    /// the second batch are shifted by the first batch's `auto_discover.len()`
1025    /// so they continue to address the concatenated `auto_discover` array.
1026    child_source_indices: Vec<usize>,
1027}
1028
1029/// Merge two `LogicalGroupStatus` values when a duplicate parent zone name
1030/// is encountered: `Ok` wins (at least one child was discovered),
1031/// `InvalidPath` beats `Empty` (a malformed/unreadable path is a louder
1032/// signal than "no subdirs"), and otherwise we keep the existing status.
1033const fn merge_status(existing: LogicalGroupStatus, new: LogicalGroupStatus) -> LogicalGroupStatus {
1034    match (existing, new) {
1035        (LogicalGroupStatus::Ok, _) | (_, LogicalGroupStatus::Ok) => LogicalGroupStatus::Ok,
1036        (LogicalGroupStatus::InvalidPath, _) | (_, LogicalGroupStatus::InvalidPath) => {
1037            LogicalGroupStatus::InvalidPath
1038        }
1039        (LogicalGroupStatus::Empty, LogicalGroupStatus::Empty) => LogicalGroupStatus::Empty,
1040    }
1041}
1042
1043fn discover_child_zones(project_root: &Path, zone: &BoundaryZone) -> DiscoveryOutcome {
1044    let mut zones_by_name: rustc_hash::FxHashMap<String, BoundaryZone> =
1045        rustc_hash::FxHashMap::default();
1046    // Tracks which `autoDiscover` path index FIRST produced each child zone
1047    // name. When two paths yield the same child name, the first producer
1048    // wins (the merged `BoundaryZone` accumulates patterns from both but
1049    // attribution stays stable).
1050    let mut first_source_index: rustc_hash::FxHashMap<String, usize> =
1051        rustc_hash::FxHashMap::default();
1052    let normalized_root = zone
1053        .root
1054        .as_deref()
1055        .map(normalize_zone_root)
1056        .unwrap_or_default();
1057    let mut had_invalid_path = false;
1058
1059    for (source_index, raw_dir) in zone.auto_discover.iter().enumerate() {
1060        let Some(discover_dir) = normalize_auto_discover_dir(raw_dir) else {
1061            tracing::warn!(
1062                "invalid boundary autoDiscover path '{}' in zone '{}': paths must be project-relative and must not contain '..'",
1063                raw_dir,
1064                zone.name
1065            );
1066            had_invalid_path = true;
1067            continue;
1068        };
1069
1070        let fs_relative = join_relative_path(&normalized_root, &discover_dir);
1071        let absolute_dir = if fs_relative.is_empty() {
1072            project_root.to_path_buf()
1073        } else {
1074            project_root.join(&fs_relative)
1075        };
1076        let Ok(entries) = std::fs::read_dir(&absolute_dir) else {
1077            tracing::warn!(
1078                "boundary zone '{}' autoDiscover path '{}' did not resolve to a readable directory",
1079                zone.name,
1080                raw_dir
1081            );
1082            had_invalid_path = true;
1083            continue;
1084        };
1085
1086        let mut children: Vec<_> = entries
1087            .filter_map(Result::ok)
1088            .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_dir()))
1089            .collect();
1090        children.sort_by_key(|entry| entry.file_name());
1091
1092        for child in children {
1093            let child_name = child.file_name().to_string_lossy().to_string();
1094            if child_name.is_empty() {
1095                continue;
1096            }
1097
1098            let zone_name = format!("{}/{}", zone.name, child_name);
1099            let child_pattern = format!("{}/**", join_relative_path(&discover_dir, &child_name));
1100            let entry = zones_by_name
1101                .entry(zone_name.clone())
1102                .or_insert_with(|| BoundaryZone {
1103                    name: zone_name.clone(),
1104                    patterns: vec![],
1105                    auto_discover: vec![],
1106                    root: zone.root.clone(),
1107                });
1108            if !entry
1109                .patterns
1110                .iter()
1111                .any(|pattern| pattern == &child_pattern)
1112            {
1113                entry.patterns.push(child_pattern);
1114            }
1115            first_source_index.entry(zone_name).or_insert(source_index);
1116        }
1117    }
1118
1119    let mut zones: Vec<_> = zones_by_name.into_values().collect();
1120    zones.sort_by(|a, b| a.name.cmp(&b.name));
1121    let source_indices: Vec<usize> = zones
1122        .iter()
1123        .map(|z| {
1124            // Every entry inserted into `zones_by_name` was also inserted
1125            // into `first_source_index` in the same loop body, so this lookup
1126            // is infallible. Fall back to 0 defensively for any future
1127            // refactor that decouples the two maps.
1128            first_source_index
1129                .get(z.name.as_str())
1130                .copied()
1131                .unwrap_or(0)
1132        })
1133        .collect();
1134    DiscoveryOutcome {
1135        zones,
1136        source_indices,
1137        had_invalid_path,
1138    }
1139}
1140
1141fn expand_rule_allow(
1142    allow: &[String],
1143    group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
1144) -> Vec<String> {
1145    let mut expanded = Vec::new();
1146    for zone in allow {
1147        if let Some(expansion) = group_expansions.get(zone) {
1148            expanded.extend(expansion.iter().cloned());
1149        } else {
1150            expanded.push(zone.clone());
1151        }
1152    }
1153    dedupe_preserving_order(expanded)
1154}
1155
1156fn expand_parent_fallback_allow(
1157    allow: &[String],
1158    from_zones: &[String],
1159    parent_name: &str,
1160) -> Vec<String> {
1161    let mut expanded = allow.to_vec();
1162    expanded.extend(
1163        from_zones
1164            .iter()
1165            .filter(|from_zone| from_zone.as_str() != parent_name)
1166            .cloned(),
1167    );
1168    dedupe_preserving_order(expanded)
1169}
1170
1171fn expand_generated_child_allow(
1172    allow: &[String],
1173    group_expansions: &rustc_hash::FxHashMap<String, Vec<String>>,
1174    source_group: &str,
1175) -> Vec<String> {
1176    let mut expanded = Vec::new();
1177    for zone in allow {
1178        if zone == source_group {
1179            if group_expansions
1180                .get(source_group)
1181                .is_some_and(|from_zones| from_zones.iter().any(|from_zone| from_zone == zone))
1182            {
1183                expanded.push(zone.clone());
1184            }
1185        } else if let Some(expansion) = group_expansions.get(zone) {
1186            expanded.extend(expansion.iter().cloned());
1187        } else {
1188            expanded.push(zone.clone());
1189        }
1190    }
1191    dedupe_preserving_order(expanded)
1192}
1193
1194fn dedupe_preserving_order(values: Vec<String>) -> Vec<String> {
1195    let mut seen = rustc_hash::FxHashSet::default();
1196    values
1197        .into_iter()
1198        .filter(|value| seen.insert(value.clone()))
1199        .collect()
1200}
1201
1202fn dedupe_rules_keep_last(rules: Vec<BoundaryRule>) -> Vec<BoundaryRule> {
1203    let mut seen = rustc_hash::FxHashSet::default();
1204    let mut deduped: Vec<_> = rules
1205        .into_iter()
1206        .rev()
1207        .filter(|rule| seen.insert(rule.from.clone()))
1208        .collect();
1209    deduped.reverse();
1210    deduped
1211}
1212
1213impl ResolvedBoundaryConfig {
1214    /// Whether any boundaries are configured.
1215    ///
1216    /// Considers `logical_groups` too: when every `autoDiscover` zone
1217    /// produced zero children, `zones` is empty but the user authored a
1218    /// boundaries section that should still be surfaced (so `fallow list
1219    /// --boundaries` can render the `Empty` / `InvalidPath` status to the
1220    /// user). Without this, the whole boundaries block silently disappears
1221    /// from the output the moment discovery finds nothing.
1222    #[must_use]
1223    pub fn is_empty(&self) -> bool {
1224        self.zones.is_empty() && self.logical_groups.is_empty()
1225    }
1226
1227    /// Classify a file path into a zone. Returns the first matching zone name.
1228    /// Path should be relative to the project root with forward slashes.
1229    ///
1230    /// When a zone declares a `root` (subtree scope), the path must start
1231    /// with that prefix and the prefix is stripped before glob matching;
1232    /// otherwise the zone is skipped. Zones without a `root` keep
1233    /// project-root-relative behavior.
1234    #[must_use]
1235    pub fn classify_zone(&self, relative_path: &str) -> Option<&str> {
1236        for zone in &self.zones {
1237            let candidate: &str = match zone.root.as_deref() {
1238                Some(root) if !root.is_empty() => {
1239                    let Some(stripped) = relative_path.strip_prefix(root) else {
1240                        continue;
1241                    };
1242                    stripped
1243                }
1244                _ => relative_path,
1245            };
1246            if zone.matchers.iter().any(|m| m.is_match(candidate)) {
1247                return Some(&zone.name);
1248            }
1249        }
1250        None
1251    }
1252
1253    /// Check if an import from `from_zone` to `to_zone` is allowed.
1254    /// Returns `true` if the import is permitted.
1255    #[must_use]
1256    pub fn is_import_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
1257        // Self-imports are always allowed.
1258        if from_zone == to_zone {
1259            return true;
1260        }
1261
1262        // Find the rule for the source zone.
1263        let rule = self.rules.iter().find(|r| r.from_zone == from_zone);
1264
1265        match rule {
1266            // Zone has no rule entry — unrestricted.
1267            None => true,
1268            // Zone has a rule — check the allowlist.
1269            Some(r) => r.allowed_zones.iter().any(|z| z == to_zone),
1270        }
1271    }
1272
1273    /// Check whether a type-only import from `from_zone` to `to_zone` is
1274    /// permitted by the rule's `allowTypeOnly` list. Only consulted by the
1275    /// boundary detector after `is_import_allowed` has already returned
1276    /// `false`; the caller is responsible for verifying the import is in
1277    /// fact type-only (all symbols on the edge carry the type-only flag).
1278    /// Returns `false` when no rule exists for `from_zone`, since rule-less
1279    /// zones are unrestricted and `is_import_allowed` short-circuits before
1280    /// this is called.
1281    #[must_use]
1282    pub fn is_type_only_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
1283        let Some(rule) = self.rules.iter().find(|r| r.from_zone == from_zone) else {
1284            return false;
1285        };
1286        rule.allow_type_only_zones.iter().any(|z| z == to_zone)
1287    }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::*;
1293
1294    #[test]
1295    fn empty_config() {
1296        let config = BoundaryConfig::default();
1297        assert!(config.is_empty());
1298        assert!(config.validate_zone_references().is_empty());
1299    }
1300
1301    #[test]
1302    fn deserialize_json() {
1303        let json = r#"{
1304            "zones": [
1305                { "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
1306                { "name": "db", "patterns": ["src/db/**"] },
1307                { "name": "shared", "patterns": ["src/shared/**"] }
1308            ],
1309            "rules": [
1310                { "from": "ui", "allow": ["shared"] },
1311                { "from": "db", "allow": ["shared"] }
1312            ]
1313        }"#;
1314        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1315        assert_eq!(config.zones.len(), 3);
1316        assert_eq!(config.rules.len(), 2);
1317        assert_eq!(config.zones[0].name, "ui");
1318        assert_eq!(
1319            config.zones[0].patterns,
1320            vec!["src/components/**", "src/pages/**"]
1321        );
1322        assert_eq!(config.rules[0].from, "ui");
1323        assert_eq!(config.rules[0].allow, vec!["shared"]);
1324    }
1325
1326    #[test]
1327    fn deserialize_toml() {
1328        let toml_str = r#"
1329[[zones]]
1330name = "ui"
1331patterns = ["src/components/**"]
1332
1333[[zones]]
1334name = "db"
1335patterns = ["src/db/**"]
1336
1337[[rules]]
1338from = "ui"
1339allow = ["db"]
1340"#;
1341        let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
1342        assert_eq!(config.zones.len(), 2);
1343        assert_eq!(config.rules.len(), 1);
1344    }
1345
1346    #[test]
1347    fn auto_discover_expands_child_zones_and_parent_rules() {
1348        let temp = tempfile::tempdir().unwrap();
1349        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1350        std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1351
1352        let mut config = BoundaryConfig {
1353            preset: None,
1354            zones: vec![
1355                BoundaryZone {
1356                    name: "app".to_string(),
1357                    patterns: vec!["src/app/**".to_string()],
1358                    auto_discover: vec![],
1359                    root: None,
1360                },
1361                BoundaryZone {
1362                    name: "features".to_string(),
1363                    patterns: vec![],
1364                    auto_discover: vec!["src/features".to_string()],
1365                    root: None,
1366                },
1367            ],
1368            rules: vec![
1369                BoundaryRule {
1370                    from: "app".to_string(),
1371                    allow: vec!["features".to_string()],
1372                    allow_type_only: vec![],
1373                },
1374                BoundaryRule {
1375                    from: "features".to_string(),
1376                    allow: vec![],
1377                    allow_type_only: vec![],
1378                },
1379            ],
1380        };
1381
1382        config.expand_auto_discover(temp.path());
1383
1384        let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
1385        assert_eq!(zone_names, vec!["app", "features/auth", "features/billing"]);
1386        assert_eq!(
1387            config.zones[1].patterns,
1388            vec!["src/features/auth/**".to_string()]
1389        );
1390        assert_eq!(
1391            config.zones[2].patterns,
1392            vec!["src/features/billing/**".to_string()]
1393        );
1394        let app_rule = config
1395            .rules
1396            .iter()
1397            .find(|rule| rule.from == "app")
1398            .expect("app rule should be preserved");
1399        assert_eq!(
1400            app_rule.allow,
1401            vec!["features/auth".to_string(), "features/billing".to_string()]
1402        );
1403        assert!(
1404            config
1405                .rules
1406                .iter()
1407                .any(|rule| rule.from == "features/auth" && rule.allow.is_empty())
1408        );
1409        assert!(
1410            config
1411                .rules
1412                .iter()
1413                .any(|rule| rule.from == "features/billing" && rule.allow.is_empty())
1414        );
1415        assert!(config.validate_zone_references().is_empty());
1416    }
1417
1418    #[test]
1419    fn auto_discover_parent_fallback_allows_children_without_relaxing_child_rules() {
1420        let temp = tempfile::tempdir().unwrap();
1421        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1422        std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1423
1424        let mut config = BoundaryConfig {
1425            preset: None,
1426            zones: vec![
1427                BoundaryZone {
1428                    name: "app".to_string(),
1429                    patterns: vec!["src/app/**".to_string()],
1430                    auto_discover: vec![],
1431                    root: None,
1432                },
1433                BoundaryZone {
1434                    name: "features".to_string(),
1435                    patterns: vec!["src/features/**".to_string()],
1436                    auto_discover: vec!["src/features".to_string()],
1437                    root: None,
1438                },
1439                BoundaryZone {
1440                    name: "shared".to_string(),
1441                    patterns: vec!["src/shared/**".to_string()],
1442                    auto_discover: vec![],
1443                    root: None,
1444                },
1445            ],
1446            rules: vec![
1447                BoundaryRule {
1448                    from: "app".to_string(),
1449                    allow: vec!["features".to_string(), "shared".to_string()],
1450                    allow_type_only: vec![],
1451                },
1452                BoundaryRule {
1453                    from: "features".to_string(),
1454                    allow: vec!["shared".to_string()],
1455                    allow_type_only: vec![],
1456                },
1457            ],
1458        };
1459
1460        config.expand_auto_discover(temp.path());
1461
1462        let zone_names: Vec<_> = config.zones.iter().map(|zone| zone.name.as_str()).collect();
1463        assert_eq!(
1464            zone_names,
1465            vec![
1466                "app",
1467                "features/auth",
1468                "features/billing",
1469                "features",
1470                "shared"
1471            ]
1472        );
1473
1474        let app_rule = config
1475            .rules
1476            .iter()
1477            .find(|rule| rule.from == "app")
1478            .expect("app rule should be preserved");
1479        assert_eq!(
1480            app_rule.allow,
1481            vec![
1482                "features/auth".to_string(),
1483                "features/billing".to_string(),
1484                "features".to_string(),
1485                "shared".to_string()
1486            ]
1487        );
1488
1489        let parent_rule = config
1490            .rules
1491            .iter()
1492            .find(|rule| rule.from == "features")
1493            .expect("parent fallback rule should be preserved");
1494        assert_eq!(
1495            parent_rule.allow,
1496            vec![
1497                "shared".to_string(),
1498                "features/auth".to_string(),
1499                "features/billing".to_string()
1500            ]
1501        );
1502
1503        let auth_rule = config
1504            .rules
1505            .iter()
1506            .find(|rule| rule.from == "features/auth")
1507            .expect("auth child rule should be generated");
1508        assert_eq!(auth_rule.allow, vec!["shared".to_string()]);
1509
1510        let billing_rule = config
1511            .rules
1512            .iter()
1513            .find(|rule| rule.from == "features/billing")
1514            .expect("billing child rule should be generated");
1515        assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1516        assert!(config.validate_zone_references().is_empty());
1517    }
1518
1519    #[test]
1520    fn auto_discover_explicit_child_rule_wins_over_generated_parent_rule() {
1521        let temp = tempfile::tempdir().unwrap();
1522        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1523        std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1524
1525        for explicit_child_first in [true, false] {
1526            let explicit_child_rule = BoundaryRule {
1527                from: "features/auth".to_string(),
1528                allow: vec!["shared".to_string(), "features/billing".to_string()],
1529                allow_type_only: vec![],
1530            };
1531            let parent_rule = BoundaryRule {
1532                from: "features".to_string(),
1533                allow: vec!["shared".to_string()],
1534                allow_type_only: vec![],
1535            };
1536            let rules = if explicit_child_first {
1537                vec![explicit_child_rule, parent_rule]
1538            } else {
1539                vec![parent_rule, explicit_child_rule]
1540            };
1541
1542            let mut config = BoundaryConfig {
1543                preset: None,
1544                zones: vec![
1545                    BoundaryZone {
1546                        name: "features".to_string(),
1547                        patterns: vec![],
1548                        auto_discover: vec!["src/features".to_string()],
1549                        root: None,
1550                    },
1551                    BoundaryZone {
1552                        name: "shared".to_string(),
1553                        patterns: vec!["src/shared/**".to_string()],
1554                        auto_discover: vec![],
1555                        root: None,
1556                    },
1557                ],
1558                rules,
1559            };
1560
1561            config.expand_auto_discover(temp.path());
1562
1563            let auth_rule = config
1564                .rules
1565                .iter()
1566                .find(|rule| rule.from == "features/auth")
1567                .expect("explicit child rule should remain");
1568            assert_eq!(
1569                auth_rule.allow,
1570                vec!["shared".to_string(), "features/billing".to_string()],
1571                "explicit child rule should win regardless of rule order"
1572            );
1573
1574            let billing_rule = config
1575                .rules
1576                .iter()
1577                .find(|rule| rule.from == "features/billing")
1578                .expect("parent rule should still generate sibling child rule");
1579            assert_eq!(billing_rule.allow, vec!["shared".to_string()]);
1580            assert!(config.validate_zone_references().is_empty());
1581        }
1582    }
1583
1584    // ── LogicalGroup return value (issue #373) ──────────────────
1585
1586    #[test]
1587    fn logical_groups_returned_for_simple_auto_discover_zone() {
1588        let temp = tempfile::tempdir().unwrap();
1589        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1590        std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1591
1592        let mut config = BoundaryConfig {
1593            preset: None,
1594            zones: vec![
1595                BoundaryZone {
1596                    name: "app".to_string(),
1597                    patterns: vec!["src/app/**".to_string()],
1598                    auto_discover: vec![],
1599                    root: None,
1600                },
1601                BoundaryZone {
1602                    name: "features".to_string(),
1603                    patterns: vec![],
1604                    auto_discover: vec!["src/features".to_string()],
1605                    root: None,
1606                },
1607            ],
1608            rules: vec![BoundaryRule {
1609                from: "features".to_string(),
1610                allow: vec!["app".to_string()],
1611                allow_type_only: vec![],
1612            }],
1613        };
1614
1615        let groups = config.expand_auto_discover(temp.path());
1616        assert_eq!(groups.len(), 1);
1617        let g = &groups[0];
1618        assert_eq!(g.name, "features");
1619        assert_eq!(g.children, vec!["features/auth", "features/billing"]);
1620        assert_eq!(g.auto_discover, vec!["src/features"]);
1621        assert_eq!(g.source_zone_index, 1);
1622        assert_eq!(g.status, LogicalGroupStatus::Ok);
1623        // Parent had no explicit patterns → not retained as fallback.
1624        assert!(g.fallback_zone.is_none());
1625        let rule = g
1626            .authored_rule
1627            .as_ref()
1628            .expect("authored rule preserved verbatim");
1629        assert_eq!(rule.allow, vec!["app"]);
1630        assert!(rule.allow_type_only.is_empty());
1631    }
1632
1633    #[test]
1634    fn logical_groups_preserve_verbatim_auto_discover_strings() {
1635        let temp = tempfile::tempdir().unwrap();
1636        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1637
1638        let mut config = BoundaryConfig {
1639            preset: None,
1640            zones: vec![BoundaryZone {
1641                name: "features".to_string(),
1642                patterns: vec![],
1643                // Trailing slash + leading `./` are normalized during discovery
1644                // but the logical group must echo the user's literal string so
1645                // round-trip config tooling does not introduce spurious diffs.
1646                auto_discover: vec!["./src/features/".to_string()],
1647                root: None,
1648            }],
1649            rules: vec![],
1650        };
1651
1652        let groups = config.expand_auto_discover(temp.path());
1653        assert_eq!(groups.len(), 1);
1654        assert_eq!(groups[0].auto_discover, vec!["./src/features/"]);
1655        assert_eq!(groups[0].children, vec!["features/auth"]);
1656    }
1657
1658    #[test]
1659    fn logical_groups_bulletproof_keeps_fallback_zone_cross_reference() {
1660        let temp = tempfile::tempdir().unwrap();
1661        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1662
1663        let mut config = BoundaryConfig {
1664            preset: None,
1665            zones: vec![BoundaryZone {
1666                // Bulletproof shape: parent carries BOTH patterns AND
1667                // autoDiscover, so the parent stays in zones[] as a fallback
1668                // classifier while ALSO becoming a logical group.
1669                name: "features".to_string(),
1670                patterns: vec!["src/features/**".to_string()],
1671                auto_discover: vec!["src/features".to_string()],
1672                root: None,
1673            }],
1674            rules: vec![],
1675        };
1676
1677        let groups = config.expand_auto_discover(temp.path());
1678        assert_eq!(groups.len(), 1);
1679        assert_eq!(groups[0].fallback_zone.as_deref(), Some("features"));
1680        // Parent zone is still present in zones[] as the fallback classifier.
1681        assert!(config.zones.iter().any(|z| z.name == "features"));
1682    }
1683
1684    #[test]
1685    fn logical_groups_status_empty_when_no_child_dirs() {
1686        let temp = tempfile::tempdir().unwrap();
1687        std::fs::create_dir_all(temp.path().join("src/features")).unwrap();
1688        // No child subdirs created.
1689
1690        let mut config = BoundaryConfig {
1691            preset: None,
1692            zones: vec![BoundaryZone {
1693                name: "features".to_string(),
1694                patterns: vec![],
1695                auto_discover: vec!["src/features".to_string()],
1696                root: None,
1697            }],
1698            rules: vec![],
1699        };
1700
1701        let groups = config.expand_auto_discover(temp.path());
1702        assert_eq!(groups.len(), 1);
1703        assert_eq!(groups[0].status, LogicalGroupStatus::Empty);
1704        assert!(groups[0].children.is_empty());
1705    }
1706
1707    #[test]
1708    fn logical_groups_status_invalid_path_when_dir_missing() {
1709        let temp = tempfile::tempdir().unwrap();
1710        // src/features intentionally not created.
1711
1712        let mut config = BoundaryConfig {
1713            preset: None,
1714            zones: vec![BoundaryZone {
1715                name: "features".to_string(),
1716                patterns: vec![],
1717                auto_discover: vec!["src/features".to_string()],
1718                root: None,
1719            }],
1720            rules: vec![],
1721        };
1722
1723        let groups = config.expand_auto_discover(temp.path());
1724        assert_eq!(groups.len(), 1);
1725        assert_eq!(groups[0].status, LogicalGroupStatus::InvalidPath);
1726        assert!(groups[0].children.is_empty());
1727    }
1728
1729    #[test]
1730    fn logical_groups_status_ok_wins_over_invalid_when_mixed() {
1731        let temp = tempfile::tempdir().unwrap();
1732        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1733        // src/modules intentionally not created (invalid path).
1734
1735        let mut config = BoundaryConfig {
1736            preset: None,
1737            zones: vec![BoundaryZone {
1738                name: "features".to_string(),
1739                patterns: vec![],
1740                auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
1741                root: None,
1742            }],
1743            rules: vec![],
1744        };
1745
1746        let groups = config.expand_auto_discover(temp.path());
1747        assert_eq!(groups.len(), 1);
1748        // One path produced children → status is Ok even though another path
1749        // was invalid. The InvalidPath warning still surfaces via tracing.
1750        assert_eq!(groups[0].status, LogicalGroupStatus::Ok);
1751        assert_eq!(groups[0].children, vec!["features/auth"]);
1752    }
1753
1754    #[test]
1755    fn logical_groups_preserve_declaration_order() {
1756        let temp = tempfile::tempdir().unwrap();
1757        std::fs::create_dir_all(temp.path().join("src/zeta/a")).unwrap();
1758        std::fs::create_dir_all(temp.path().join("src/alpha/a")).unwrap();
1759        std::fs::create_dir_all(temp.path().join("src/mid/a")).unwrap();
1760
1761        let mut config = BoundaryConfig {
1762            preset: None,
1763            zones: vec![
1764                BoundaryZone {
1765                    name: "zeta".to_string(),
1766                    patterns: vec![],
1767                    auto_discover: vec!["src/zeta".to_string()],
1768                    root: None,
1769                },
1770                BoundaryZone {
1771                    name: "alpha".to_string(),
1772                    patterns: vec![],
1773                    auto_discover: vec!["src/alpha".to_string()],
1774                    root: None,
1775                },
1776                BoundaryZone {
1777                    name: "mid".to_string(),
1778                    patterns: vec![],
1779                    auto_discover: vec!["src/mid".to_string()],
1780                    root: None,
1781                },
1782            ],
1783            rules: vec![],
1784        };
1785
1786        let groups = config.expand_auto_discover(temp.path());
1787        // Insertion order is preserved; not alphabetized.
1788        let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
1789        assert_eq!(names, vec!["zeta", "alpha", "mid"]);
1790    }
1791
1792    #[test]
1793    fn logical_groups_merged_from_records_duplicate_indices() {
1794        // The single-declaration path leaves merged_from None; the
1795        // duplicate-merge path populates it with every contributing index.
1796        let temp = tempfile::tempdir().unwrap();
1797        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1798        std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
1799
1800        let mut config = BoundaryConfig {
1801            preset: None,
1802            zones: vec![
1803                BoundaryZone {
1804                    name: "features".to_string(),
1805                    patterns: vec![],
1806                    auto_discover: vec!["src/features".to_string()],
1807                    root: None,
1808                },
1809                BoundaryZone {
1810                    name: "other".to_string(),
1811                    patterns: vec!["src/other/**".to_string()],
1812                    auto_discover: vec![],
1813                    root: None,
1814                },
1815                BoundaryZone {
1816                    name: "features".to_string(),
1817                    patterns: vec![],
1818                    auto_discover: vec!["src/extra".to_string()],
1819                    root: None,
1820                },
1821            ],
1822            rules: vec![],
1823        };
1824        let groups = config.expand_auto_discover(temp.path());
1825        assert_eq!(groups.len(), 1);
1826        // merged_from holds both contributing zone indices in declaration
1827        // order: position 0 and position 2 (the "other" zone at position 1
1828        // is unrelated).
1829        assert_eq!(groups[0].merged_from.as_deref(), Some(&[0_usize, 2][..]));
1830        // The first index also wins source_zone_index.
1831        assert_eq!(groups[0].source_zone_index, 0);
1832    }
1833
1834    #[test]
1835    fn logical_groups_merged_from_none_on_single_declaration() {
1836        let temp = tempfile::tempdir().unwrap();
1837        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1838
1839        let mut config = BoundaryConfig {
1840            preset: None,
1841            zones: vec![BoundaryZone {
1842                name: "features".to_string(),
1843                patterns: vec![],
1844                auto_discover: vec!["src/features".to_string()],
1845                root: None,
1846            }],
1847            rules: vec![],
1848        };
1849        let groups = config.expand_auto_discover(temp.path());
1850        // Common case: no duplicate, no merged_from.
1851        assert!(groups[0].merged_from.is_none());
1852    }
1853
1854    #[test]
1855    fn logical_groups_echo_original_zone_root() {
1856        let temp = tempfile::tempdir().unwrap();
1857        std::fs::create_dir_all(temp.path().join("packages/app/src/features/auth")).unwrap();
1858
1859        let mut config = BoundaryConfig {
1860            preset: None,
1861            zones: vec![BoundaryZone {
1862                name: "features".to_string(),
1863                patterns: vec![],
1864                auto_discover: vec!["src/features".to_string()],
1865                // Monorepo subtree scope on the parent; should round-trip
1866                // verbatim to logical_groups[0].original_zone_root so
1867                // patcher tools can distinguish parent-set vs per-child root.
1868                root: Some("packages/app/".to_string()),
1869            }],
1870            rules: vec![],
1871        };
1872        let groups = config.expand_auto_discover(temp.path());
1873        assert_eq!(
1874            groups[0].original_zone_root.as_deref(),
1875            Some("packages/app/")
1876        );
1877    }
1878
1879    #[test]
1880    fn logical_groups_original_zone_root_none_when_unset() {
1881        let temp = tempfile::tempdir().unwrap();
1882        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1883
1884        let mut config = BoundaryConfig {
1885            preset: None,
1886            zones: vec![BoundaryZone {
1887                name: "features".to_string(),
1888                patterns: vec![],
1889                auto_discover: vec!["src/features".to_string()],
1890                root: None,
1891            }],
1892            rules: vec![],
1893        };
1894        let groups = config.expand_auto_discover(temp.path());
1895        assert!(groups[0].original_zone_root.is_none());
1896    }
1897
1898    #[test]
1899    fn logical_groups_child_source_indices_populated_for_multi_path() {
1900        let temp = tempfile::tempdir().unwrap();
1901        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1902        std::fs::create_dir_all(temp.path().join("src/modules/billing")).unwrap();
1903
1904        let mut config = BoundaryConfig {
1905            preset: None,
1906            zones: vec![BoundaryZone {
1907                name: "features".to_string(),
1908                patterns: vec![],
1909                // Two paths: each produces one child. Children are
1910                // alphabetically sorted across paths, so auth (from index 0)
1911                // sorts before billing (from index 1).
1912                auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
1913                root: None,
1914            }],
1915            rules: vec![],
1916        };
1917        let groups = config.expand_auto_discover(temp.path());
1918        assert_eq!(
1919            groups[0].children,
1920            vec!["features/auth", "features/billing"]
1921        );
1922        assert_eq!(groups[0].child_source_indices, vec![0, 1]);
1923    }
1924
1925    #[test]
1926    fn logical_groups_child_source_indices_empty_for_single_path() {
1927        let temp = tempfile::tempdir().unwrap();
1928        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1929        std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
1930
1931        let mut config = BoundaryConfig {
1932            preset: None,
1933            zones: vec![BoundaryZone {
1934                name: "features".to_string(),
1935                patterns: vec![],
1936                auto_discover: vec!["src/features".to_string()],
1937                root: None,
1938            }],
1939            rules: vec![],
1940        };
1941        let groups = config.expand_auto_discover(temp.path());
1942        // With one path, every child trivially has source index 0. The
1943        // helper field is suppressed (empty Vec) so the JSON stays tight
1944        // on the common case.
1945        assert!(groups[0].child_source_indices.is_empty());
1946    }
1947
1948    #[test]
1949    fn logical_groups_child_source_indices_after_duplicate_merge_shifted() {
1950        // When two parent declarations merge, the child indices from the
1951        // SECOND batch must be shifted by the FIRST batch's
1952        // auto_discover.len() so they continue to address the
1953        // post-concatenation `auto_discover` array correctly.
1954        let temp = tempfile::tempdir().unwrap();
1955        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
1956        std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
1957
1958        let mut config = BoundaryConfig {
1959            preset: None,
1960            zones: vec![
1961                BoundaryZone {
1962                    name: "features".to_string(),
1963                    patterns: vec![],
1964                    auto_discover: vec!["src/features".to_string()],
1965                    root: None,
1966                },
1967                BoundaryZone {
1968                    name: "features".to_string(),
1969                    patterns: vec![],
1970                    auto_discover: vec!["src/extra".to_string()],
1971                    root: None,
1972                },
1973            ],
1974            rules: vec![],
1975        };
1976        let groups = config.expand_auto_discover(temp.path());
1977        assert_eq!(groups.len(), 1);
1978        // Merged auto_discover has 2 entries; index 0 = src/features,
1979        // index 1 = src/extra. The features/billing child came from the
1980        // second batch's first path, which post-shift is index 1.
1981        assert_eq!(groups[0].auto_discover, vec!["src/features", "src/extra"]);
1982        let auth_idx = groups[0]
1983            .children
1984            .iter()
1985            .position(|c| c == "features/auth")
1986            .unwrap();
1987        let billing_idx = groups[0]
1988            .children
1989            .iter()
1990            .position(|c| c == "features/billing")
1991            .unwrap();
1992        assert_eq!(groups[0].child_source_indices[auth_idx], 0);
1993        assert_eq!(groups[0].child_source_indices[billing_idx], 1);
1994    }
1995
1996    #[test]
1997    fn logical_groups_merge_duplicate_parent_zone_declarations() {
1998        let temp = tempfile::tempdir().unwrap();
1999        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2000        std::fs::create_dir_all(temp.path().join("src/extra/billing")).unwrap();
2001
2002        let mut config = BoundaryConfig {
2003            preset: None,
2004            zones: vec![
2005                BoundaryZone {
2006                    name: "features".to_string(),
2007                    patterns: vec![],
2008                    auto_discover: vec!["src/features".to_string()],
2009                    root: None,
2010                },
2011                BoundaryZone {
2012                    name: "features".to_string(),
2013                    patterns: vec![],
2014                    auto_discover: vec!["src/extra".to_string()],
2015                    root: None,
2016                },
2017            ],
2018            rules: vec![],
2019        };
2020
2021        let groups = config.expand_auto_discover(temp.path());
2022        // The two declarations merge into a single logical group with
2023        // concatenated auto_discover paths and children.
2024        assert_eq!(groups.len(), 1);
2025        assert_eq!(groups[0].name, "features");
2026        assert_eq!(groups[0].auto_discover, vec!["src/features", "src/extra"]);
2027        assert!(groups[0].children.iter().any(|c| c == "features/auth"));
2028        assert!(groups[0].children.iter().any(|c| c == "features/billing"));
2029        assert_eq!(groups[0].source_zone_index, 0);
2030    }
2031
2032    #[test]
2033    fn logical_groups_duplicate_identical_declarations_no_double_count() {
2034        // Regression for codex parallel review (post-impl pass): two
2035        // identical `features` declarations with the same `autoDiscover`
2036        // path used to emit duplicate `zones[]` entries, duplicate
2037        // `children[]`, and double-counted `file_count` (4 for 2 real
2038        // files). `merge_zone_by_name` keeps `zones[]` unique by name and
2039        // the merge logic dedupes children against the existing set.
2040        let temp = tempfile::tempdir().unwrap();
2041        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2042        std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
2043
2044        let mut config = BoundaryConfig {
2045            preset: None,
2046            zones: vec![
2047                BoundaryZone {
2048                    name: "features".to_string(),
2049                    patterns: vec![],
2050                    auto_discover: vec!["src/features".to_string()],
2051                    root: None,
2052                },
2053                BoundaryZone {
2054                    name: "features".to_string(),
2055                    patterns: vec![],
2056                    auto_discover: vec!["src/features".to_string()],
2057                    root: None,
2058                },
2059            ],
2060            rules: vec![],
2061        };
2062
2063        let groups = config.expand_auto_discover(temp.path());
2064        assert_eq!(groups.len(), 1);
2065        // zones[] must NOT contain duplicates of features/auth or
2066        // features/billing.
2067        let zone_names: Vec<&str> = config.zones.iter().map(|z| z.name.as_str()).collect();
2068        assert_eq!(zone_names, vec!["features/auth", "features/billing"]);
2069        // children[] must NOT contain duplicates.
2070        assert_eq!(
2071            groups[0].children,
2072            vec!["features/auth", "features/billing"]
2073        );
2074        // auto_discover preserves both verbatim (the duplicate is visible
2075        // via merged_from + the warning, but the path list itself
2076        // concatenates).
2077        assert_eq!(
2078            groups[0].auto_discover,
2079            vec!["src/features", "src/features"]
2080        );
2081        // merged_from records both zone indices.
2082        assert_eq!(groups[0].merged_from.as_deref(), Some(&[0_usize, 1][..]));
2083    }
2084
2085    #[test]
2086    fn logical_groups_empty_when_no_auto_discover_present() {
2087        let temp = tempfile::tempdir().unwrap();
2088        let mut config = BoundaryConfig {
2089            preset: None,
2090            zones: vec![BoundaryZone {
2091                name: "ui".to_string(),
2092                patterns: vec!["src/components/**".to_string()],
2093                auto_discover: vec![],
2094                root: None,
2095            }],
2096            rules: vec![],
2097        };
2098        let groups = config.expand_auto_discover(temp.path());
2099        assert!(groups.is_empty());
2100    }
2101
2102    #[test]
2103    fn logical_groups_propagate_through_resolve() {
2104        // End-to-end: data populated by expand_auto_discover survives a
2105        // round trip through `BoundaryConfig::resolve()` so consumers of
2106        // `ResolvedBoundaryConfig.logical_groups` see the same content.
2107        let temp = tempfile::tempdir().unwrap();
2108        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2109
2110        let mut config = BoundaryConfig {
2111            preset: None,
2112            zones: vec![BoundaryZone {
2113                name: "features".to_string(),
2114                patterns: vec![],
2115                auto_discover: vec!["src/features".to_string()],
2116                root: None,
2117            }],
2118            rules: vec![],
2119        };
2120        let groups = config.expand_auto_discover(temp.path());
2121        let mut resolved = config.resolve();
2122        // `resolve()` itself does not have access to the pre-expansion state;
2123        // the resolution pipeline stitches the groups back on. Mirror that
2124        // here so the test exercises the same shape consumers see.
2125        resolved.logical_groups = groups;
2126        assert_eq!(resolved.logical_groups.len(), 1);
2127        assert_eq!(resolved.logical_groups[0].name, "features");
2128        assert_eq!(resolved.logical_groups[0].children, vec!["features/auth"]);
2129    }
2130
2131    #[test]
2132    fn validate_zone_references_valid() {
2133        let config = BoundaryConfig {
2134            preset: None,
2135            zones: vec![
2136                BoundaryZone {
2137                    name: "ui".to_string(),
2138                    patterns: vec![],
2139                    auto_discover: vec![],
2140                    root: None,
2141                },
2142                BoundaryZone {
2143                    name: "db".to_string(),
2144                    patterns: vec![],
2145                    auto_discover: vec![],
2146                    root: None,
2147                },
2148            ],
2149            rules: vec![BoundaryRule {
2150                from: "ui".to_string(),
2151                allow: vec!["db".to_string()],
2152                allow_type_only: vec![],
2153            }],
2154        };
2155        assert!(config.validate_zone_references().is_empty());
2156    }
2157
2158    #[test]
2159    fn validate_zone_references_invalid_from() {
2160        let config = BoundaryConfig {
2161            preset: None,
2162            zones: vec![BoundaryZone {
2163                name: "ui".to_string(),
2164                patterns: vec![],
2165                auto_discover: vec![],
2166                root: None,
2167            }],
2168            rules: vec![BoundaryRule {
2169                from: "nonexistent".to_string(),
2170                allow: vec!["ui".to_string()],
2171                allow_type_only: vec![],
2172            }],
2173        };
2174        let errors = config.validate_zone_references();
2175        assert_eq!(errors.len(), 1);
2176        assert_eq!(errors[0].1, "nonexistent");
2177    }
2178
2179    #[test]
2180    fn validate_zone_references_invalid_allow() {
2181        let config = BoundaryConfig {
2182            preset: None,
2183            zones: vec![BoundaryZone {
2184                name: "ui".to_string(),
2185                patterns: vec![],
2186                auto_discover: vec![],
2187                root: None,
2188            }],
2189            rules: vec![BoundaryRule {
2190                from: "ui".to_string(),
2191                allow: vec!["nonexistent".to_string()],
2192                allow_type_only: vec![],
2193            }],
2194        };
2195        let errors = config.validate_zone_references();
2196        assert_eq!(errors.len(), 1);
2197        assert_eq!(errors[0].1, "nonexistent");
2198    }
2199
2200    #[test]
2201    fn validate_zone_references_invalid_allow_type_only() {
2202        // An undefined zone in `allowTypeOnly` silently behaves as "not
2203        // allowed" at runtime, which the user almost always meant as a typo
2204        // for an existing zone. Surface the same diagnostic as `allow`.
2205        let config = BoundaryConfig {
2206            preset: None,
2207            zones: vec![BoundaryZone {
2208                name: "ui".to_string(),
2209                patterns: vec![],
2210                auto_discover: vec![],
2211                root: None,
2212            }],
2213            rules: vec![BoundaryRule {
2214                from: "ui".to_string(),
2215                allow: vec![],
2216                allow_type_only: vec!["nonexistent_type_zone".to_string()],
2217            }],
2218        };
2219        let errors = config.validate_zone_references();
2220        assert_eq!(errors.len(), 1, "got: {errors:?}");
2221        assert_eq!(errors[0].1, "nonexistent_type_zone");
2222    }
2223
2224    #[test]
2225    fn resolve_and_classify() {
2226        let config = BoundaryConfig {
2227            preset: None,
2228            zones: vec![
2229                BoundaryZone {
2230                    name: "ui".to_string(),
2231                    patterns: vec!["src/components/**".to_string()],
2232                    auto_discover: vec![],
2233                    root: None,
2234                },
2235                BoundaryZone {
2236                    name: "db".to_string(),
2237                    patterns: vec!["src/db/**".to_string()],
2238                    auto_discover: vec![],
2239                    root: None,
2240                },
2241            ],
2242            rules: vec![],
2243        };
2244        let resolved = config.resolve();
2245        assert_eq!(
2246            resolved.classify_zone("src/components/Button.tsx"),
2247            Some("ui")
2248        );
2249        assert_eq!(resolved.classify_zone("src/db/queries.ts"), Some("db"));
2250        assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
2251    }
2252
2253    #[test]
2254    fn first_match_wins() {
2255        let config = BoundaryConfig {
2256            preset: None,
2257            zones: vec![
2258                BoundaryZone {
2259                    name: "specific".to_string(),
2260                    patterns: vec!["src/shared/db-utils/**".to_string()],
2261                    auto_discover: vec![],
2262                    root: None,
2263                },
2264                BoundaryZone {
2265                    name: "shared".to_string(),
2266                    patterns: vec!["src/shared/**".to_string()],
2267                    auto_discover: vec![],
2268                    root: None,
2269                },
2270            ],
2271            rules: vec![],
2272        };
2273        let resolved = config.resolve();
2274        assert_eq!(
2275            resolved.classify_zone("src/shared/db-utils/pool.ts"),
2276            Some("specific")
2277        );
2278        assert_eq!(
2279            resolved.classify_zone("src/shared/helpers.ts"),
2280            Some("shared")
2281        );
2282    }
2283
2284    #[test]
2285    fn self_import_always_allowed() {
2286        let config = BoundaryConfig {
2287            preset: None,
2288            zones: vec![BoundaryZone {
2289                name: "ui".to_string(),
2290                patterns: vec![],
2291                auto_discover: vec![],
2292                root: None,
2293            }],
2294            rules: vec![BoundaryRule {
2295                from: "ui".to_string(),
2296                allow: vec![],
2297                allow_type_only: vec![],
2298            }],
2299        };
2300        let resolved = config.resolve();
2301        assert!(resolved.is_import_allowed("ui", "ui"));
2302    }
2303
2304    #[test]
2305    fn unrestricted_zone_allows_all() {
2306        let config = BoundaryConfig {
2307            preset: None,
2308            zones: vec![
2309                BoundaryZone {
2310                    name: "shared".to_string(),
2311                    patterns: vec![],
2312                    auto_discover: vec![],
2313                    root: None,
2314                },
2315                BoundaryZone {
2316                    name: "db".to_string(),
2317                    patterns: vec![],
2318                    auto_discover: vec![],
2319                    root: None,
2320                },
2321            ],
2322            rules: vec![],
2323        };
2324        let resolved = config.resolve();
2325        assert!(resolved.is_import_allowed("shared", "db"));
2326    }
2327
2328    #[test]
2329    fn restricted_zone_blocks_unlisted() {
2330        let config = BoundaryConfig {
2331            preset: None,
2332            zones: vec![
2333                BoundaryZone {
2334                    name: "ui".to_string(),
2335                    patterns: vec![],
2336                    auto_discover: vec![],
2337                    root: None,
2338                },
2339                BoundaryZone {
2340                    name: "db".to_string(),
2341                    patterns: vec![],
2342                    auto_discover: vec![],
2343                    root: None,
2344                },
2345                BoundaryZone {
2346                    name: "shared".to_string(),
2347                    patterns: vec![],
2348                    auto_discover: vec![],
2349                    root: None,
2350                },
2351            ],
2352            rules: vec![BoundaryRule {
2353                from: "ui".to_string(),
2354                allow: vec!["shared".to_string()],
2355                allow_type_only: vec![],
2356            }],
2357        };
2358        let resolved = config.resolve();
2359        assert!(resolved.is_import_allowed("ui", "shared"));
2360        assert!(!resolved.is_import_allowed("ui", "db"));
2361    }
2362
2363    #[test]
2364    fn empty_allow_blocks_all_except_self() {
2365        let config = BoundaryConfig {
2366            preset: None,
2367            zones: vec![
2368                BoundaryZone {
2369                    name: "isolated".to_string(),
2370                    patterns: vec![],
2371                    auto_discover: vec![],
2372                    root: None,
2373                },
2374                BoundaryZone {
2375                    name: "other".to_string(),
2376                    patterns: vec![],
2377                    auto_discover: vec![],
2378                    root: None,
2379                },
2380            ],
2381            rules: vec![BoundaryRule {
2382                from: "isolated".to_string(),
2383                allow: vec![],
2384                allow_type_only: vec![],
2385            }],
2386        };
2387        let resolved = config.resolve();
2388        assert!(resolved.is_import_allowed("isolated", "isolated"));
2389        assert!(!resolved.is_import_allowed("isolated", "other"));
2390    }
2391
2392    #[test]
2393    fn zone_root_filters_classification_to_subtree() {
2394        let config = BoundaryConfig {
2395            preset: None,
2396            zones: vec![
2397                BoundaryZone {
2398                    name: "ui".to_string(),
2399                    patterns: vec!["src/**".to_string()],
2400                    auto_discover: vec![],
2401                    root: Some("packages/app/".to_string()),
2402                },
2403                BoundaryZone {
2404                    name: "domain".to_string(),
2405                    patterns: vec!["src/**".to_string()],
2406                    auto_discover: vec![],
2407                    root: Some("packages/core/".to_string()),
2408                },
2409            ],
2410            rules: vec![],
2411        };
2412        let resolved = config.resolve();
2413        // Files inside packages/app/ classify as ui
2414        assert_eq!(
2415            resolved.classify_zone("packages/app/src/login.tsx"),
2416            Some("ui")
2417        );
2418        // Files inside packages/core/ classify as domain (same pattern, different root)
2419        assert_eq!(
2420            resolved.classify_zone("packages/core/src/order.ts"),
2421            Some("domain")
2422        );
2423        // Files outside either subtree do not match
2424        assert_eq!(resolved.classify_zone("src/login.tsx"), None);
2425        assert_eq!(resolved.classify_zone("packages/utils/src/x.ts"), None);
2426    }
2427
2428    /// Case-sensitivity contract: `root` matching is case-sensitive,
2429    /// matching the existing globset case-sensitivity for `patterns`. On
2430    /// case-insensitive filesystems (HFS+, NTFS) two files differing only
2431    /// in case still classify only when the configured `root` exactly
2432    /// matches the path's case as fallow recorded it. Locking this down
2433    /// prevents silent platform-divergent classification.
2434    #[test]
2435    fn zone_root_is_case_sensitive() {
2436        let config = BoundaryConfig {
2437            preset: None,
2438            zones: vec![BoundaryZone {
2439                name: "ui".to_string(),
2440                patterns: vec!["src/**".to_string()],
2441                auto_discover: vec![],
2442                root: Some("packages/app/".to_string()),
2443            }],
2444            rules: vec![],
2445        };
2446        let resolved = config.resolve();
2447        assert_eq!(
2448            resolved.classify_zone("packages/app/src/login.tsx"),
2449            Some("ui"),
2450            "exact-case path classifies"
2451        );
2452        assert_eq!(
2453            resolved.classify_zone("packages/App/src/login.tsx"),
2454            None,
2455            "case-different path does not classify (root is case-sensitive)"
2456        );
2457        assert_eq!(
2458            resolved.classify_zone("Packages/app/src/login.tsx"),
2459            None,
2460            "case-different prefix does not classify"
2461        );
2462    }
2463
2464    #[test]
2465    fn zone_root_normalizes_trailing_slash_and_dot_prefix() {
2466        let config = BoundaryConfig {
2467            preset: None,
2468            zones: vec![
2469                BoundaryZone {
2470                    name: "no-slash".to_string(),
2471                    patterns: vec!["src/**".to_string()],
2472                    auto_discover: vec![],
2473                    root: Some("packages/app".to_string()),
2474                },
2475                BoundaryZone {
2476                    name: "dot-prefixed".to_string(),
2477                    patterns: vec!["src/**".to_string()],
2478                    auto_discover: vec![],
2479                    root: Some("./packages/lib/".to_string()),
2480                },
2481            ],
2482            rules: vec![],
2483        };
2484        let resolved = config.resolve();
2485        assert_eq!(resolved.zones[0].root.as_deref(), Some("packages/app/"));
2486        assert_eq!(resolved.zones[1].root.as_deref(), Some("packages/lib/"));
2487        assert_eq!(
2488            resolved.classify_zone("packages/app/src/x.ts"),
2489            Some("no-slash")
2490        );
2491        assert_eq!(
2492            resolved.classify_zone("packages/lib/src/x.ts"),
2493            Some("dot-prefixed")
2494        );
2495    }
2496
2497    #[test]
2498    fn validate_root_prefixes_flags_redundant_pattern() {
2499        let config = BoundaryConfig {
2500            preset: None,
2501            zones: vec![BoundaryZone {
2502                name: "ui".to_string(),
2503                patterns: vec!["packages/app/src/**".to_string()],
2504                auto_discover: vec![],
2505                root: Some("packages/app/".to_string()),
2506            }],
2507            rules: vec![],
2508        };
2509        let errors = config.validate_root_prefixes();
2510        assert_eq!(errors.len(), 1, "expected one redundant-prefix error");
2511        assert!(
2512            errors[0].contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"),
2513            "error should be tagged: {}",
2514            errors[0]
2515        );
2516        assert!(
2517            errors[0].contains("zone 'ui'"),
2518            "error should name the zone: {}",
2519            errors[0]
2520        );
2521        assert!(
2522            errors[0].contains("packages/app/src/**"),
2523            "error should quote the pattern: {}",
2524            errors[0]
2525        );
2526    }
2527
2528    #[test]
2529    fn validate_root_prefixes_handles_unnormalized_root() {
2530        // Root without trailing slash + pattern with leading "./" should
2531        // still be detected as redundant after normalization.
2532        let config = BoundaryConfig {
2533            preset: None,
2534            zones: vec![BoundaryZone {
2535                name: "ui".to_string(),
2536                patterns: vec!["./packages/app/src/**".to_string()],
2537                auto_discover: vec![],
2538                root: Some("packages/app".to_string()),
2539            }],
2540            rules: vec![],
2541        };
2542        let errors = config.validate_root_prefixes();
2543        assert_eq!(errors.len(), 1);
2544    }
2545
2546    #[test]
2547    fn validate_root_prefixes_empty_when_no_overlap() {
2548        let config = BoundaryConfig {
2549            preset: None,
2550            zones: vec![BoundaryZone {
2551                name: "ui".to_string(),
2552                patterns: vec!["src/**".to_string()],
2553                auto_discover: vec![],
2554                root: Some("packages/app/".to_string()),
2555            }],
2556            rules: vec![],
2557        };
2558        assert!(config.validate_root_prefixes().is_empty());
2559    }
2560
2561    #[test]
2562    fn validate_root_prefixes_skips_zones_without_root() {
2563        let json = r#"{
2564            "zones": [{ "name": "ui", "patterns": ["src/**"] }],
2565            "rules": []
2566        }"#;
2567        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2568        assert!(config.validate_root_prefixes().is_empty());
2569    }
2570
2571    /// Regression: an empty `root` (or `"."`/`"./"`, both of which normalize
2572    /// to `""`) used to make `starts_with("")` always true, producing a
2573    /// spurious FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX error for every
2574    /// pattern in the zone. The validation must skip empty-normalized roots
2575    /// the same way `classify_zone` does.
2576    #[test]
2577    fn validate_root_prefixes_skips_empty_root() {
2578        for raw_root in ["", ".", "./"] {
2579            let config = BoundaryConfig {
2580                preset: None,
2581                zones: vec![BoundaryZone {
2582                    name: "ui".to_string(),
2583                    patterns: vec!["src/**".to_string(), "lib/**".to_string()],
2584                    auto_discover: vec![],
2585                    root: Some(raw_root.to_string()),
2586                }],
2587                rules: vec![],
2588            };
2589            let errors = config.validate_root_prefixes();
2590            assert!(
2591                errors.is_empty(),
2592                "empty-normalized root {raw_root:?} produced spurious errors: {errors:?}"
2593            );
2594        }
2595    }
2596
2597    #[test]
2598    fn deserialize_zone_with_root() {
2599        let json = r#"{
2600            "zones": [
2601                { "name": "ui", "patterns": ["src/**"], "root": "packages/app/" }
2602            ],
2603            "rules": []
2604        }"#;
2605        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2606        assert_eq!(config.zones[0].root.as_deref(), Some("packages/app/"));
2607    }
2608
2609    // ── Preset deserialization ─────────────────────────────────
2610
2611    #[test]
2612    fn deserialize_preset_json() {
2613        let json = r#"{ "preset": "layered" }"#;
2614        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2615        assert_eq!(config.preset, Some(BoundaryPreset::Layered));
2616        assert!(config.zones.is_empty());
2617    }
2618
2619    #[test]
2620    fn deserialize_preset_hexagonal_json() {
2621        let json = r#"{ "preset": "hexagonal" }"#;
2622        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2623        assert_eq!(config.preset, Some(BoundaryPreset::Hexagonal));
2624    }
2625
2626    #[test]
2627    fn deserialize_preset_feature_sliced_json() {
2628        let json = r#"{ "preset": "feature-sliced" }"#;
2629        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
2630        assert_eq!(config.preset, Some(BoundaryPreset::FeatureSliced));
2631    }
2632
2633    #[test]
2634    fn deserialize_preset_toml() {
2635        let toml_str = r#"preset = "layered""#;
2636        let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
2637        assert_eq!(config.preset, Some(BoundaryPreset::Layered));
2638    }
2639
2640    #[test]
2641    fn deserialize_invalid_preset_rejected() {
2642        let json = r#"{ "preset": "invalid_preset" }"#;
2643        let result: Result<BoundaryConfig, _> = serde_json::from_str(json);
2644        assert!(result.is_err());
2645    }
2646
2647    #[test]
2648    fn preset_absent_by_default() {
2649        let config = BoundaryConfig::default();
2650        assert!(config.preset.is_none());
2651        assert!(config.is_empty());
2652    }
2653
2654    #[test]
2655    fn preset_makes_config_non_empty() {
2656        let config = BoundaryConfig {
2657            preset: Some(BoundaryPreset::Layered),
2658            zones: vec![],
2659            rules: vec![],
2660        };
2661        assert!(!config.is_empty());
2662    }
2663
2664    // ── Preset expansion ───────────────────────────────────────
2665
2666    #[test]
2667    fn expand_layered_produces_four_zones() {
2668        let mut config = BoundaryConfig {
2669            preset: Some(BoundaryPreset::Layered),
2670            zones: vec![],
2671            rules: vec![],
2672        };
2673        config.expand("src");
2674        assert_eq!(config.zones.len(), 4);
2675        assert_eq!(config.rules.len(), 4);
2676        assert!(config.preset.is_none(), "preset cleared after expand");
2677        assert_eq!(config.zones[0].name, "presentation");
2678        assert_eq!(config.zones[0].patterns, vec!["src/presentation/**"]);
2679    }
2680
2681    #[test]
2682    fn expand_layered_rules_correct() {
2683        let mut config = BoundaryConfig {
2684            preset: Some(BoundaryPreset::Layered),
2685            zones: vec![],
2686            rules: vec![],
2687        };
2688        config.expand("src");
2689        // presentation → application only
2690        let pres_rule = config
2691            .rules
2692            .iter()
2693            .find(|r| r.from == "presentation")
2694            .unwrap();
2695        assert_eq!(pres_rule.allow, vec!["application"]);
2696        // application → domain only
2697        let app_rule = config
2698            .rules
2699            .iter()
2700            .find(|r| r.from == "application")
2701            .unwrap();
2702        assert_eq!(app_rule.allow, vec!["domain"]);
2703        // domain → nothing
2704        let dom_rule = config.rules.iter().find(|r| r.from == "domain").unwrap();
2705        assert!(dom_rule.allow.is_empty());
2706        // infrastructure → domain + application (DI-friendly)
2707        let infra_rule = config
2708            .rules
2709            .iter()
2710            .find(|r| r.from == "infrastructure")
2711            .unwrap();
2712        assert_eq!(infra_rule.allow, vec!["domain", "application"]);
2713    }
2714
2715    #[test]
2716    fn expand_hexagonal_produces_three_zones() {
2717        let mut config = BoundaryConfig {
2718            preset: Some(BoundaryPreset::Hexagonal),
2719            zones: vec![],
2720            rules: vec![],
2721        };
2722        config.expand("src");
2723        assert_eq!(config.zones.len(), 3);
2724        assert_eq!(config.rules.len(), 3);
2725        assert_eq!(config.zones[0].name, "adapters");
2726        assert_eq!(config.zones[1].name, "ports");
2727        assert_eq!(config.zones[2].name, "domain");
2728    }
2729
2730    #[test]
2731    fn expand_feature_sliced_produces_six_zones() {
2732        let mut config = BoundaryConfig {
2733            preset: Some(BoundaryPreset::FeatureSliced),
2734            zones: vec![],
2735            rules: vec![],
2736        };
2737        config.expand("src");
2738        assert_eq!(config.zones.len(), 6);
2739        assert_eq!(config.rules.len(), 6);
2740        // app can import everything below
2741        let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
2742        assert_eq!(
2743            app_rule.allow,
2744            vec!["pages", "widgets", "features", "entities", "shared"]
2745        );
2746        // shared imports nothing
2747        let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
2748        assert!(shared_rule.allow.is_empty());
2749        // entities → shared only
2750        let ent_rule = config.rules.iter().find(|r| r.from == "entities").unwrap();
2751        assert_eq!(ent_rule.allow, vec!["shared"]);
2752    }
2753
2754    #[test]
2755    fn expand_bulletproof_produces_four_zones() {
2756        let mut config = BoundaryConfig {
2757            preset: Some(BoundaryPreset::Bulletproof),
2758            zones: vec![],
2759            rules: vec![],
2760        };
2761        config.expand("src");
2762        assert_eq!(config.zones.len(), 4);
2763        assert_eq!(config.rules.len(), 4);
2764        assert_eq!(config.zones[0].name, "app");
2765        assert_eq!(config.zones[1].name, "features");
2766        assert_eq!(config.zones[2].name, "shared");
2767        assert_eq!(config.zones[3].name, "server");
2768        // shared zone has multiple patterns
2769        assert!(config.zones[2].patterns.len() > 1);
2770        assert!(
2771            config.zones[2]
2772                .patterns
2773                .contains(&"src/components/**".to_string())
2774        );
2775        assert!(
2776            config.zones[2]
2777                .patterns
2778                .contains(&"src/hooks/**".to_string())
2779        );
2780        assert!(config.zones[2].patterns.contains(&"src/lib/**".to_string()));
2781        assert!(
2782            config.zones[2]
2783                .patterns
2784                .contains(&"src/providers/**".to_string())
2785        );
2786    }
2787
2788    #[test]
2789    fn expand_bulletproof_rules_correct() {
2790        let mut config = BoundaryConfig {
2791            preset: Some(BoundaryPreset::Bulletproof),
2792            zones: vec![],
2793            rules: vec![],
2794        };
2795        config.expand("src");
2796        // app → features, shared, server
2797        let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
2798        assert_eq!(app_rule.allow, vec!["features", "shared", "server"]);
2799        // features → shared, server
2800        let feat_rule = config.rules.iter().find(|r| r.from == "features").unwrap();
2801        assert_eq!(feat_rule.allow, vec!["shared", "server"]);
2802        // server → shared
2803        let srv_rule = config.rules.iter().find(|r| r.from == "server").unwrap();
2804        assert_eq!(srv_rule.allow, vec!["shared"]);
2805        // shared → nothing (isolated)
2806        let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
2807        assert!(shared_rule.allow.is_empty());
2808    }
2809
2810    #[test]
2811    fn expand_bulletproof_then_resolve_classifies() {
2812        // `expand()` alone (without `expand_auto_discover`) does not produce
2813        // the per-feature child zones yet, but the parent `features` fallback
2814        // still classifies top-level and nested `src/features/...` files.
2815        let mut config = BoundaryConfig {
2816            preset: Some(BoundaryPreset::Bulletproof),
2817            zones: vec![],
2818            rules: vec![],
2819        };
2820        config.expand("src");
2821        let resolved = config.resolve();
2822        assert_eq!(
2823            resolved.classify_zone("src/app/dashboard/page.tsx"),
2824            Some("app")
2825        );
2826        assert_eq!(
2827            resolved.classify_zone("src/features/auth/hooks/useAuth.ts"),
2828            Some("features"),
2829            "without expand_auto_discover, src/features/... falls back to the parent zone"
2830        );
2831        assert_eq!(
2832            resolved.classify_zone("src/components/Button/Button.tsx"),
2833            Some("shared")
2834        );
2835        assert_eq!(
2836            resolved.classify_zone("src/hooks/useFormatters.ts"),
2837            Some("shared")
2838        );
2839        assert_eq!(
2840            resolved.classify_zone("src/server/db/schema/users.ts"),
2841            Some("server")
2842        );
2843        // features cannot import shared directly — only via allowed rules
2844        assert!(resolved.is_import_allowed("features", "shared"));
2845        assert!(resolved.is_import_allowed("features", "server"));
2846        assert!(!resolved.is_import_allowed("features", "app"));
2847        assert!(!resolved.is_import_allowed("shared", "features"));
2848        assert!(!resolved.is_import_allowed("server", "features"));
2849    }
2850
2851    /// Regression for the bulletproof barrel pattern: a top-level
2852    /// `src/features/index.ts` barrel re-exporting child features must NOT
2853    /// trigger `features → features/<child>` boundary violations. The parent
2854    /// fallback rule allows discovered children while generated child rules
2855    /// still enforce sibling isolation.
2856    #[test]
2857    fn bulletproof_features_barrel_can_import_children() {
2858        let temp = tempfile::tempdir().unwrap();
2859        std::fs::create_dir_all(temp.path().join("src/features/auth")).unwrap();
2860        std::fs::create_dir_all(temp.path().join("src/features/billing")).unwrap();
2861
2862        let mut config = BoundaryConfig {
2863            preset: Some(BoundaryPreset::Bulletproof),
2864            zones: vec![],
2865            rules: vec![],
2866        };
2867        config.expand("src");
2868        config.expand_auto_discover(temp.path());
2869        let resolved = config.resolve();
2870
2871        // Top-level barrel inside src/features falls back to the parent zone.
2872        assert_eq!(
2873            resolved.classify_zone("src/features/index.ts"),
2874            Some("features"),
2875            "src/features/index.ts barrel should classify as the parent features zone"
2876        );
2877        // Discovered child zones still classify normally.
2878        assert_eq!(
2879            resolved.classify_zone("src/features/auth/login.ts"),
2880            Some("features/auth")
2881        );
2882        assert_eq!(
2883            resolved.classify_zone("src/features/billing/invoice.ts"),
2884            Some("features/billing")
2885        );
2886        // Parent barrels can re-export child features.
2887        assert!(resolved.is_import_allowed("features", "features/auth"));
2888        assert!(resolved.is_import_allowed("features", "features/billing"));
2889        // Sibling-feature import is still a cross-zone violation.
2890        assert!(!resolved.is_import_allowed("features/auth", "features/billing"));
2891    }
2892
2893    #[test]
2894    fn expand_uses_custom_source_root() {
2895        let mut config = BoundaryConfig {
2896            preset: Some(BoundaryPreset::Hexagonal),
2897            zones: vec![],
2898            rules: vec![],
2899        };
2900        config.expand("lib");
2901        assert_eq!(config.zones[0].patterns, vec!["lib/adapters/**"]);
2902        assert_eq!(config.zones[2].patterns, vec!["lib/domain/**"]);
2903    }
2904
2905    // ── Preset merge behavior ──────────────────────────────────
2906
2907    #[test]
2908    fn user_zone_replaces_preset_zone() {
2909        let mut config = BoundaryConfig {
2910            preset: Some(BoundaryPreset::Hexagonal),
2911            zones: vec![BoundaryZone {
2912                name: "domain".to_string(),
2913                patterns: vec!["src/core/**".to_string()],
2914                auto_discover: vec![],
2915                root: None,
2916            }],
2917            rules: vec![],
2918        };
2919        config.expand("src");
2920        // 3 zones total: adapters + ports from preset, domain from user
2921        assert_eq!(config.zones.len(), 3);
2922        let domain = config.zones.iter().find(|z| z.name == "domain").unwrap();
2923        assert_eq!(domain.patterns, vec!["src/core/**"]);
2924    }
2925
2926    #[test]
2927    fn user_zone_adds_to_preset() {
2928        let mut config = BoundaryConfig {
2929            preset: Some(BoundaryPreset::Hexagonal),
2930            zones: vec![BoundaryZone {
2931                name: "shared".to_string(),
2932                patterns: vec!["src/shared/**".to_string()],
2933                auto_discover: vec![],
2934                root: None,
2935            }],
2936            rules: vec![],
2937        };
2938        config.expand("src");
2939        assert_eq!(config.zones.len(), 4); // 3 preset + 1 user
2940        assert!(config.zones.iter().any(|z| z.name == "shared"));
2941    }
2942
2943    #[test]
2944    fn user_rule_replaces_preset_rule() {
2945        let mut config = BoundaryConfig {
2946            preset: Some(BoundaryPreset::Hexagonal),
2947            zones: vec![],
2948            rules: vec![BoundaryRule {
2949                from: "adapters".to_string(),
2950                allow: vec!["ports".to_string(), "domain".to_string()],
2951                allow_type_only: vec![],
2952            }],
2953        };
2954        config.expand("src");
2955        let adapter_rule = config.rules.iter().find(|r| r.from == "adapters").unwrap();
2956        // User rule allows both ports and domain (preset only allowed ports)
2957        assert_eq!(adapter_rule.allow, vec!["ports", "domain"]);
2958        // Other preset rules untouched
2959        assert_eq!(
2960            config.rules.iter().filter(|r| r.from == "adapters").count(),
2961            1
2962        );
2963    }
2964
2965    #[test]
2966    fn expand_without_preset_is_noop() {
2967        let mut config = BoundaryConfig {
2968            preset: None,
2969            zones: vec![BoundaryZone {
2970                name: "ui".to_string(),
2971                patterns: vec!["src/ui/**".to_string()],
2972                auto_discover: vec![],
2973                root: None,
2974            }],
2975            rules: vec![],
2976        };
2977        config.expand("src");
2978        assert_eq!(config.zones.len(), 1);
2979        assert_eq!(config.zones[0].name, "ui");
2980    }
2981
2982    #[test]
2983    fn expand_then_validate_succeeds() {
2984        let mut config = BoundaryConfig {
2985            preset: Some(BoundaryPreset::Layered),
2986            zones: vec![],
2987            rules: vec![],
2988        };
2989        config.expand("src");
2990        assert!(config.validate_zone_references().is_empty());
2991    }
2992
2993    #[test]
2994    fn expand_then_resolve_classifies() {
2995        let mut config = BoundaryConfig {
2996            preset: Some(BoundaryPreset::Hexagonal),
2997            zones: vec![],
2998            rules: vec![],
2999        };
3000        config.expand("src");
3001        let resolved = config.resolve();
3002        assert_eq!(
3003            resolved.classify_zone("src/adapters/http/handler.ts"),
3004            Some("adapters")
3005        );
3006        assert_eq!(resolved.classify_zone("src/domain/user.ts"), Some("domain"));
3007        assert!(!resolved.is_import_allowed("adapters", "domain"));
3008        assert!(resolved.is_import_allowed("adapters", "ports"));
3009    }
3010
3011    #[test]
3012    fn preset_name_returns_correct_string() {
3013        let config = BoundaryConfig {
3014            preset: Some(BoundaryPreset::FeatureSliced),
3015            zones: vec![],
3016            rules: vec![],
3017        };
3018        assert_eq!(config.preset_name(), Some("feature-sliced"));
3019
3020        let empty = BoundaryConfig::default();
3021        assert_eq!(empty.preset_name(), None);
3022    }
3023
3024    #[test]
3025    fn preset_name_all_variants() {
3026        let cases = [
3027            (BoundaryPreset::Layered, "layered"),
3028            (BoundaryPreset::Hexagonal, "hexagonal"),
3029            (BoundaryPreset::FeatureSliced, "feature-sliced"),
3030            (BoundaryPreset::Bulletproof, "bulletproof"),
3031        ];
3032        for (preset, expected_name) in cases {
3033            let config = BoundaryConfig {
3034                preset: Some(preset),
3035                zones: vec![],
3036                rules: vec![],
3037            };
3038            assert_eq!(
3039                config.preset_name(),
3040                Some(expected_name),
3041                "preset_name() mismatch for variant"
3042            );
3043        }
3044    }
3045
3046    // ── ResolvedBoundaryConfig::is_empty ────────────────────────────
3047
3048    #[test]
3049    fn resolved_boundary_config_empty() {
3050        let resolved = ResolvedBoundaryConfig::default();
3051        assert!(resolved.is_empty());
3052    }
3053
3054    #[test]
3055    fn resolved_boundary_config_with_zones_not_empty() {
3056        let config = BoundaryConfig {
3057            preset: None,
3058            zones: vec![BoundaryZone {
3059                name: "ui".to_string(),
3060                patterns: vec!["src/ui/**".to_string()],
3061                auto_discover: vec![],
3062                root: None,
3063            }],
3064            rules: vec![],
3065        };
3066        let resolved = config.resolve();
3067        assert!(!resolved.is_empty());
3068    }
3069
3070    #[test]
3071    fn resolved_boundary_config_with_only_logical_groups_not_empty() {
3072        // Regression for issue #373 smoke: a config whose every autoDiscover
3073        // zone produced zero children ends up with empty `zones[]` but a
3074        // populated `logical_groups[]`. The boundaries section must still
3075        // surface so `fallow list --boundaries` can render the Empty /
3076        // InvalidPath status (otherwise the whole block silently disappears
3077        // and the user has no signal that discovery turned up nothing).
3078        let resolved = ResolvedBoundaryConfig {
3079            zones: vec![],
3080            rules: vec![],
3081            logical_groups: vec![LogicalGroup {
3082                name: "features".to_string(),
3083                children: vec![],
3084                auto_discover: vec!["src/features".to_string()],
3085                authored_rule: None,
3086                fallback_zone: None,
3087                source_zone_index: 0,
3088                status: LogicalGroupStatus::Empty,
3089                merged_from: None,
3090                original_zone_root: None,
3091                child_source_indices: vec![],
3092            }],
3093        };
3094        assert!(!resolved.is_empty());
3095    }
3096
3097    // ── BoundaryConfig::is_empty edge cases ─────────────────────────
3098
3099    #[test]
3100    fn boundary_config_with_only_rules_is_empty() {
3101        // Having rules but no zones/preset is still "empty" since rules without zones
3102        // cannot produce boundary violations.
3103        let config = BoundaryConfig {
3104            preset: None,
3105            zones: vec![],
3106            rules: vec![BoundaryRule {
3107                from: "ui".to_string(),
3108                allow: vec!["db".to_string()],
3109                allow_type_only: vec![],
3110            }],
3111        };
3112        assert!(config.is_empty());
3113    }
3114
3115    #[test]
3116    fn boundary_config_with_zones_not_empty() {
3117        let config = BoundaryConfig {
3118            preset: None,
3119            zones: vec![BoundaryZone {
3120                name: "ui".to_string(),
3121                patterns: vec![],
3122                auto_discover: vec![],
3123                root: None,
3124            }],
3125            rules: vec![],
3126        };
3127        assert!(!config.is_empty());
3128    }
3129
3130    // ── Multiple zone patterns ──────────────────────────────────────
3131
3132    #[test]
3133    fn zone_with_multiple_patterns_matches_any() {
3134        let config = BoundaryConfig {
3135            preset: None,
3136            zones: vec![BoundaryZone {
3137                name: "ui".to_string(),
3138                patterns: vec![
3139                    "src/components/**".to_string(),
3140                    "src/pages/**".to_string(),
3141                    "src/views/**".to_string(),
3142                ],
3143                auto_discover: vec![],
3144                root: None,
3145            }],
3146            rules: vec![],
3147        };
3148        let resolved = config.resolve();
3149        assert_eq!(
3150            resolved.classify_zone("src/components/Button.tsx"),
3151            Some("ui")
3152        );
3153        assert_eq!(resolved.classify_zone("src/pages/Home.tsx"), Some("ui"));
3154        assert_eq!(
3155            resolved.classify_zone("src/views/Dashboard.tsx"),
3156            Some("ui")
3157        );
3158        assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
3159    }
3160
3161    // ── validate_zone_references with multiple errors ───────────────
3162
3163    #[test]
3164    fn validate_zone_references_multiple_errors() {
3165        let config = BoundaryConfig {
3166            preset: None,
3167            zones: vec![BoundaryZone {
3168                name: "ui".to_string(),
3169                patterns: vec![],
3170                auto_discover: vec![],
3171                root: None,
3172            }],
3173            rules: vec![
3174                BoundaryRule {
3175                    from: "nonexistent_from".to_string(),
3176                    allow: vec!["nonexistent_allow".to_string()],
3177                    allow_type_only: vec![],
3178                },
3179                BoundaryRule {
3180                    from: "ui".to_string(),
3181                    allow: vec!["also_nonexistent".to_string()],
3182                    allow_type_only: vec![],
3183                },
3184            ],
3185        };
3186        let errors = config.validate_zone_references();
3187        // Rule 0: invalid "from" + invalid "allow" = 2 errors
3188        // Rule 1: valid "from", invalid "allow" = 1 error
3189        assert_eq!(errors.len(), 3);
3190    }
3191
3192    // ── Preset expansion with custom source root ────────────────────
3193
3194    #[test]
3195    fn expand_feature_sliced_with_custom_root() {
3196        let mut config = BoundaryConfig {
3197            preset: Some(BoundaryPreset::FeatureSliced),
3198            zones: vec![],
3199            rules: vec![],
3200        };
3201        config.expand("lib");
3202        assert_eq!(config.zones[0].patterns, vec!["lib/app/**"]);
3203        assert_eq!(config.zones[5].patterns, vec!["lib/shared/**"]);
3204    }
3205
3206    // ── is_import_allowed for zone not in rules (unrestricted) ──────
3207
3208    #[test]
3209    fn zone_not_in_rules_is_unrestricted() {
3210        let config = BoundaryConfig {
3211            preset: None,
3212            zones: vec![
3213                BoundaryZone {
3214                    name: "a".to_string(),
3215                    patterns: vec![],
3216                    auto_discover: vec![],
3217                    root: None,
3218                },
3219                BoundaryZone {
3220                    name: "b".to_string(),
3221                    patterns: vec![],
3222                    auto_discover: vec![],
3223                    root: None,
3224                },
3225                BoundaryZone {
3226                    name: "c".to_string(),
3227                    patterns: vec![],
3228                    auto_discover: vec![],
3229                    root: None,
3230                },
3231            ],
3232            rules: vec![BoundaryRule {
3233                from: "a".to_string(),
3234                allow: vec!["b".to_string()],
3235                allow_type_only: vec![],
3236            }],
3237        };
3238        let resolved = config.resolve();
3239        // "a" is restricted: can import from "b" but not "c"
3240        assert!(resolved.is_import_allowed("a", "b"));
3241        assert!(!resolved.is_import_allowed("a", "c"));
3242        // "b" has no rule entry: unrestricted
3243        assert!(resolved.is_import_allowed("b", "a"));
3244        assert!(resolved.is_import_allowed("b", "c"));
3245        // "c" has no rule entry: unrestricted
3246        assert!(resolved.is_import_allowed("c", "a"));
3247    }
3248
3249    // ── Preset serialization/deserialization roundtrip ───────────────
3250
3251    #[test]
3252    fn boundary_preset_json_roundtrip() {
3253        let presets = [
3254            BoundaryPreset::Layered,
3255            BoundaryPreset::Hexagonal,
3256            BoundaryPreset::FeatureSliced,
3257            BoundaryPreset::Bulletproof,
3258        ];
3259        for preset in presets {
3260            let json = serde_json::to_string(&preset).unwrap();
3261            let restored: BoundaryPreset = serde_json::from_str(&json).unwrap();
3262            assert_eq!(restored, preset);
3263        }
3264    }
3265
3266    #[test]
3267    fn deserialize_preset_bulletproof_json() {
3268        let json = r#"{ "preset": "bulletproof" }"#;
3269        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
3270        assert_eq!(config.preset, Some(BoundaryPreset::Bulletproof));
3271    }
3272
3273    // ── Zone with invalid glob ──────────────────────────────────────
3274
3275    #[test]
3276    fn resolve_skips_invalid_zone_glob() {
3277        let config = BoundaryConfig {
3278            preset: None,
3279            zones: vec![BoundaryZone {
3280                name: "broken".to_string(),
3281                patterns: vec!["[invalid".to_string()],
3282                auto_discover: vec![],
3283                root: None,
3284            }],
3285            rules: vec![],
3286        };
3287        let resolved = config.resolve();
3288        // Zone exists but has no valid matchers, so no file can be classified into it
3289        assert!(!resolved.is_empty());
3290        assert_eq!(resolved.classify_zone("anything.ts"), None);
3291    }
3292}