Skip to main content

fallow_config/config/
boundaries.rs

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