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