Skip to main content

fallow_config/config/
boundaries.rs

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