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