Skip to main content

fallow_config/config/
boundaries.rs

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