Skip to main content

fallow_config/config/
boundaries.rs

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