Skip to main content

fallow_config/config/
boundaries.rs

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