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