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