Skip to main content

fallow_config/config/
boundaries.rs

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