Skip to main content

fallow_config/config/
boundaries.rs

1//! Architecture boundary zone and rule definitions.
2
3use globset::Glob;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// Built-in architecture presets.
8///
9/// Each preset expands into a set of zones and import rules for a common
10/// architecture pattern. User-defined zones and rules merge on top of the
11/// preset defaults (zones with the same name replace the preset zone;
12/// rules with the same `from` replace the preset rule).
13///
14/// # Examples
15///
16/// ```
17/// use fallow_config::BoundaryPreset;
18///
19/// let preset: BoundaryPreset = serde_json::from_str(r#""layered""#).unwrap();
20/// assert!(matches!(preset, BoundaryPreset::Layered));
21/// ```
22#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
23#[serde(rename_all = "kebab-case")]
24pub enum BoundaryPreset {
25    /// Classic layered architecture: presentation → application → domain ← infrastructure.
26    /// Infrastructure may also import from application (common in DI frameworks).
27    Layered,
28    /// Hexagonal / ports-and-adapters: adapters → ports → domain.
29    Hexagonal,
30    /// Feature-Sliced Design: app > pages > widgets > features > entities > shared.
31    /// Each layer may only import from layers below it.
32    FeatureSliced,
33    /// Bulletproof React: app → features → shared + server.
34    /// Feature modules are isolated from each other; shared utilities and server
35    /// infrastructure form the base layers.
36    Bulletproof,
37}
38
39impl BoundaryPreset {
40    /// Expand the preset into default zones and rules.
41    ///
42    /// `source_root` is the directory prefix for zone patterns (e.g., `"src"`, `"lib"`).
43    /// Patterns are generated as `{source_root}/{zone_name}/**`.
44    #[must_use]
45    pub fn default_config(&self, source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
46        match self {
47            Self::Layered => Self::layered_config(source_root),
48            Self::Hexagonal => Self::hexagonal_config(source_root),
49            Self::FeatureSliced => Self::feature_sliced_config(source_root),
50            Self::Bulletproof => Self::bulletproof_config(source_root),
51        }
52    }
53
54    fn zone(name: &str, source_root: &str) -> BoundaryZone {
55        BoundaryZone {
56            name: name.to_owned(),
57            patterns: vec![format!("{source_root}/{name}/**")],
58            root: None,
59        }
60    }
61
62    fn rule(from: &str, allow: &[&str]) -> BoundaryRule {
63        BoundaryRule {
64            from: from.to_owned(),
65            allow: allow.iter().map(|s| (*s).to_owned()).collect(),
66        }
67    }
68
69    fn layered_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
70        let zones = vec![
71            Self::zone("presentation", source_root),
72            Self::zone("application", source_root),
73            Self::zone("domain", source_root),
74            Self::zone("infrastructure", source_root),
75        ];
76        let rules = vec![
77            Self::rule("presentation", &["application"]),
78            Self::rule("application", &["domain"]),
79            Self::rule("domain", &[]),
80            Self::rule("infrastructure", &["domain", "application"]),
81        ];
82        (zones, rules)
83    }
84
85    fn hexagonal_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
86        let zones = vec![
87            Self::zone("adapters", source_root),
88            Self::zone("ports", source_root),
89            Self::zone("domain", source_root),
90        ];
91        let rules = vec![
92            Self::rule("adapters", &["ports"]),
93            Self::rule("ports", &["domain"]),
94            Self::rule("domain", &[]),
95        ];
96        (zones, rules)
97    }
98
99    fn feature_sliced_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
100        let layer_names = ["app", "pages", "widgets", "features", "entities", "shared"];
101        let zones = layer_names
102            .iter()
103            .map(|name| Self::zone(name, source_root))
104            .collect();
105        let rules = layer_names
106            .iter()
107            .enumerate()
108            .map(|(i, name)| {
109                let below: Vec<&str> = layer_names[i + 1..].to_vec();
110                Self::rule(name, &below)
111            })
112            .collect();
113        (zones, rules)
114    }
115
116    fn bulletproof_config(source_root: &str) -> (Vec<BoundaryZone>, Vec<BoundaryRule>) {
117        let zones = vec![
118            Self::zone("app", source_root),
119            Self::zone("features", source_root),
120            BoundaryZone {
121                name: "shared".to_owned(),
122                patterns: [
123                    "components",
124                    "hooks",
125                    "lib",
126                    "utils",
127                    "utilities",
128                    "providers",
129                    "shared",
130                    "types",
131                    "styles",
132                    "i18n",
133                ]
134                .iter()
135                .map(|dir| format!("{source_root}/{dir}/**"))
136                .collect(),
137                root: None,
138            },
139            Self::zone("server", source_root),
140        ];
141        let rules = vec![
142            Self::rule("app", &["features", "shared", "server"]),
143            Self::rule("features", &["shared", "server"]),
144            Self::rule("server", &["shared"]),
145            Self::rule("shared", &[]),
146        ];
147        (zones, rules)
148    }
149}
150
151/// Architecture boundary configuration.
152///
153/// Defines zones (directory groupings) and rules (which zones may import from which).
154/// Optionally uses a built-in preset as a starting point.
155///
156/// # Examples
157///
158/// ```
159/// use fallow_config::BoundaryConfig;
160///
161/// let json = r#"{
162///     "zones": [
163///         { "name": "ui", "patterns": ["src/components/**"] },
164///         { "name": "db", "patterns": ["src/db/**"] }
165///     ],
166///     "rules": [
167///         { "from": "ui", "allow": ["db"] }
168///     ]
169/// }"#;
170/// let config: BoundaryConfig = serde_json::from_str(json).unwrap();
171/// assert_eq!(config.zones.len(), 2);
172/// assert_eq!(config.rules.len(), 1);
173/// ```
174///
175/// Using a preset:
176///
177/// ```
178/// use fallow_config::BoundaryConfig;
179///
180/// let json = r#"{ "preset": "layered" }"#;
181/// let mut config: BoundaryConfig = serde_json::from_str(json).unwrap();
182/// config.expand("src");
183/// assert_eq!(config.zones.len(), 4);
184/// assert_eq!(config.rules.len(), 4);
185/// ```
186#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
187#[serde(rename_all = "camelCase")]
188pub struct BoundaryConfig {
189    /// Built-in architecture preset. When set, expands into default zones and rules.
190    /// User-defined zones and rules merge on top: zones with the same name replace
191    /// the preset zone; rules with the same `from` replace the preset rule.
192    /// Preset patterns use `{rootDir}/{zone}/**` where rootDir is auto-detected
193    /// from tsconfig.json (falls back to `src`).
194    /// Note: preset patterns are flat (`src/<zone>/**`). For monorepos with
195    /// per-package source directories, define zones explicitly instead.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub preset: Option<BoundaryPreset>,
198    /// Named zones mapping directory patterns to architectural layers.
199    #[serde(default)]
200    pub zones: Vec<BoundaryZone>,
201    /// Import rules between zones. A zone with a rule entry can only import
202    /// from the listed zones (plus itself). A zone without a rule entry is unrestricted.
203    #[serde(default)]
204    pub rules: Vec<BoundaryRule>,
205}
206
207/// A named zone grouping files by directory pattern.
208#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
209#[serde(rename_all = "camelCase")]
210pub struct BoundaryZone {
211    /// Zone identifier referenced in rules (e.g., `"ui"`, `"database"`, `"shared"`).
212    pub name: String,
213    /// Glob patterns (relative to project root) that define zone membership.
214    /// A file belongs to the first zone whose pattern matches.
215    pub patterns: Vec<String>,
216    /// Optional subtree scope for monorepo per-package boundaries.
217    ///
218    /// When set, the zone's `patterns` are matched against paths *relative*
219    /// to this directory rather than the project root. At classification
220    /// time, fallow checks that a candidate path starts with `root` and
221    /// strips that prefix before glob-matching the patterns against the
222    /// remainder. Files outside the subtree never match the zone.
223    ///
224    /// Useful for monorepos where each package has the same internal
225    /// directory layout: instead of writing `packages/app/src/**` and
226    /// `packages/core/src/**` (which collide on shared zone names), set
227    /// `root: "packages/app/"` and `patterns: ["src/**"]` per package.
228    ///
229    /// Trailing slash and leading `./` are normalized; backslashes are
230    /// converted to forward slashes. Patterns must NOT redundantly include
231    /// the root prefix: `root: "packages/app/"` with
232    /// `patterns: ["packages/app/src/**"]` is rejected with
233    /// `FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX` because patterns are
234    /// resolved relative to the root.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub root: Option<String>,
237}
238
239/// An import rule between zones.
240#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
241#[serde(rename_all = "camelCase")]
242pub struct BoundaryRule {
243    /// The zone this rule applies to (the importing side).
244    pub from: String,
245    /// Zones that `from` is allowed to import from. Self-imports are always allowed.
246    /// An empty list means the zone may not import from any other zone.
247    #[serde(default)]
248    pub allow: Vec<String>,
249}
250
251/// Resolved boundary config with pre-compiled glob matchers.
252#[derive(Debug, Default)]
253pub struct ResolvedBoundaryConfig {
254    /// Zones with compiled glob matchers for fast file classification.
255    pub zones: Vec<ResolvedZone>,
256    /// Rules indexed by source zone name.
257    pub rules: Vec<ResolvedBoundaryRule>,
258}
259
260/// A zone with pre-compiled glob matchers.
261#[derive(Debug)]
262pub struct ResolvedZone {
263    /// Zone identifier.
264    pub name: String,
265    /// Pre-compiled glob matchers for zone membership.
266    /// When `root` is set, matchers are applied to the path with the
267    /// `root` prefix stripped (subtree-relative patterns).
268    pub matchers: Vec<globset::GlobMatcher>,
269    /// Normalized subtree scope (e.g. `"packages/app/"`). When present,
270    /// only paths starting with this prefix can match this zone, and the
271    /// prefix is stripped before glob matching. Forward slashes only,
272    /// always trailing slash. `None` means patterns are matched against
273    /// the project-root-relative path as-is.
274    pub root: Option<String>,
275}
276
277/// A resolved boundary rule.
278#[derive(Debug)]
279pub struct ResolvedBoundaryRule {
280    /// The zone this rule restricts.
281    pub from_zone: String,
282    /// Zones that `from_zone` is allowed to import from.
283    pub allowed_zones: Vec<String>,
284}
285
286impl BoundaryConfig {
287    /// Whether any boundaries are configured (including via preset).
288    #[must_use]
289    pub fn is_empty(&self) -> bool {
290        self.preset.is_none() && self.zones.is_empty()
291    }
292
293    /// Expand the preset (if set) into zones and rules, merging user overrides on top.
294    ///
295    /// `source_root` is the directory prefix for preset zone patterns (e.g., `"src"`).
296    /// After expansion, `self.preset` is cleared and all zones/rules are explicit.
297    ///
298    /// Merge semantics:
299    /// - User zones with the same name as a preset zone **replace** the preset zone entirely.
300    /// - User rules with the same `from` as a preset rule **replace** the preset rule.
301    /// - User zones/rules with new names **add** to the preset set.
302    pub fn expand(&mut self, source_root: &str) {
303        let Some(preset) = self.preset.take() else {
304            return;
305        };
306
307        let (preset_zones, preset_rules) = preset.default_config(source_root);
308
309        // Build set of user-defined zone names for override detection.
310        let user_zone_names: rustc_hash::FxHashSet<&str> =
311            self.zones.iter().map(|z| z.name.as_str()).collect();
312
313        // Start with preset zones, replacing any that the user overrides.
314        let mut merged_zones: Vec<BoundaryZone> = preset_zones
315            .into_iter()
316            .filter(|pz| {
317                if user_zone_names.contains(pz.name.as_str()) {
318                    tracing::info!(
319                        "boundary preset: user zone '{}' replaces preset zone",
320                        pz.name
321                    );
322                    false
323                } else {
324                    true
325                }
326            })
327            .collect();
328        // Append all user zones (both overrides and additions).
329        merged_zones.append(&mut self.zones);
330        self.zones = merged_zones;
331
332        // Build set of user-defined rule `from` names for override detection.
333        let user_rule_sources: rustc_hash::FxHashSet<&str> =
334            self.rules.iter().map(|r| r.from.as_str()).collect();
335
336        let mut merged_rules: Vec<BoundaryRule> = preset_rules
337            .into_iter()
338            .filter(|pr| {
339                if user_rule_sources.contains(pr.from.as_str()) {
340                    tracing::info!(
341                        "boundary preset: user rule for '{}' replaces preset rule",
342                        pr.from
343                    );
344                    false
345                } else {
346                    true
347                }
348            })
349            .collect();
350        merged_rules.append(&mut self.rules);
351        self.rules = merged_rules;
352    }
353
354    /// Return the preset name if one is configured but not yet expanded.
355    #[must_use]
356    pub fn preset_name(&self) -> Option<&str> {
357        self.preset.as_ref().map(|p| match p {
358            BoundaryPreset::Layered => "layered",
359            BoundaryPreset::Hexagonal => "hexagonal",
360            BoundaryPreset::FeatureSliced => "feature-sliced",
361            BoundaryPreset::Bulletproof => "bulletproof",
362        })
363    }
364
365    /// Validate that no zone's pattern redundantly includes its `root`
366    /// prefix. Returns a list of error messages tagged with
367    /// `FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX`. Patterns are resolved
368    /// relative to the zone root, so prefixing the pattern with the same
369    /// root double-prefixes the path and never matches.
370    #[must_use]
371    pub fn validate_root_prefixes(&self) -> Vec<String> {
372        let mut errors = Vec::new();
373        for zone in &self.zones {
374            let Some(raw_root) = zone.root.as_deref() else {
375                continue;
376            };
377            let normalized = normalize_zone_root(raw_root);
378            // Skip empty-root zones: `""`, `"."`, and `"./"` all normalize to
379            // `""`, which behaves as no root at classification time. Without
380            // this guard `starts_with("")` is always true and every pattern
381            // produces a spurious redundant-prefix error.
382            if normalized.is_empty() {
383                continue;
384            }
385            for pattern in &zone.patterns {
386                let normalized_pattern = pattern.replace('\\', "/");
387                let stripped = normalized_pattern
388                    .strip_prefix("./")
389                    .unwrap_or(&normalized_pattern);
390                if stripped.starts_with(&normalized) {
391                    errors.push(format!(
392                        "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.",
393                        zone.name, pattern, normalized
394                    ));
395                }
396            }
397        }
398        errors
399    }
400
401    /// Validate that all zone names referenced in rules are defined in `zones`.
402    /// Returns a list of (rule_index, undefined_zone_name) pairs.
403    #[must_use]
404    pub fn validate_zone_references(&self) -> Vec<(usize, &str)> {
405        let zone_names: rustc_hash::FxHashSet<&str> =
406            self.zones.iter().map(|z| z.name.as_str()).collect();
407
408        let mut errors = Vec::new();
409        for (i, rule) in self.rules.iter().enumerate() {
410            if !zone_names.contains(rule.from.as_str()) {
411                errors.push((i, rule.from.as_str()));
412            }
413            for allowed in &rule.allow {
414                if !zone_names.contains(allowed.as_str()) {
415                    errors.push((i, allowed.as_str()));
416                }
417            }
418        }
419        errors
420    }
421
422    /// Resolve into compiled form with pre-built glob matchers.
423    /// Invalid glob patterns are logged and skipped.
424    #[must_use]
425    pub fn resolve(&self) -> ResolvedBoundaryConfig {
426        let zones = self
427            .zones
428            .iter()
429            .map(|zone| {
430                let matchers = zone
431                    .patterns
432                    .iter()
433                    .filter_map(|pattern| match Glob::new(pattern) {
434                        Ok(glob) => Some(glob.compile_matcher()),
435                        Err(e) => {
436                            tracing::warn!(
437                                "invalid boundary zone glob pattern '{}' in zone '{}': {e}",
438                                pattern,
439                                zone.name
440                            );
441                            None
442                        }
443                    })
444                    .collect();
445                let root = zone.root.as_deref().map(normalize_zone_root);
446                ResolvedZone {
447                    name: zone.name.clone(),
448                    matchers,
449                    root,
450                }
451            })
452            .collect();
453
454        let rules = self
455            .rules
456            .iter()
457            .map(|rule| ResolvedBoundaryRule {
458                from_zone: rule.from.clone(),
459                allowed_zones: rule.allow.clone(),
460            })
461            .collect();
462
463        ResolvedBoundaryConfig { zones, rules }
464    }
465}
466
467/// Normalize a zone `root` string into the canonical form used at
468/// classification time: forward slashes, no leading `./`, always a
469/// trailing slash. Empty / `"."` / `"./"` collapse to `""` which means
470/// "subtree is the project root" and effectively behaves like no root.
471fn normalize_zone_root(raw: &str) -> String {
472    let with_slashes = raw.replace('\\', "/");
473    let trimmed = with_slashes.trim_start_matches("./");
474    let no_dot = if trimmed == "." { "" } else { trimmed };
475    if no_dot.is_empty() {
476        String::new()
477    } else if no_dot.ends_with('/') {
478        no_dot.to_owned()
479    } else {
480        format!("{no_dot}/")
481    }
482}
483
484impl ResolvedBoundaryConfig {
485    /// Whether any boundaries are configured.
486    #[must_use]
487    pub fn is_empty(&self) -> bool {
488        self.zones.is_empty()
489    }
490
491    /// Classify a file path into a zone. Returns the first matching zone name.
492    /// Path should be relative to the project root with forward slashes.
493    ///
494    /// When a zone declares a `root` (subtree scope), the path must start
495    /// with that prefix and the prefix is stripped before glob matching;
496    /// otherwise the zone is skipped. Zones without a `root` keep
497    /// project-root-relative behavior.
498    #[must_use]
499    pub fn classify_zone(&self, relative_path: &str) -> Option<&str> {
500        for zone in &self.zones {
501            let candidate: &str = match zone.root.as_deref() {
502                Some(root) if !root.is_empty() => {
503                    let Some(stripped) = relative_path.strip_prefix(root) else {
504                        continue;
505                    };
506                    stripped
507                }
508                _ => relative_path,
509            };
510            if zone.matchers.iter().any(|m| m.is_match(candidate)) {
511                return Some(&zone.name);
512            }
513        }
514        None
515    }
516
517    /// Check if an import from `from_zone` to `to_zone` is allowed.
518    /// Returns `true` if the import is permitted.
519    #[must_use]
520    pub fn is_import_allowed(&self, from_zone: &str, to_zone: &str) -> bool {
521        // Self-imports are always allowed.
522        if from_zone == to_zone {
523            return true;
524        }
525
526        // Find the rule for the source zone.
527        let rule = self.rules.iter().find(|r| r.from_zone == from_zone);
528
529        match rule {
530            // Zone has no rule entry — unrestricted.
531            None => true,
532            // Zone has a rule — check the allowlist.
533            Some(r) => r.allowed_zones.iter().any(|z| z == to_zone),
534        }
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn empty_config() {
544        let config = BoundaryConfig::default();
545        assert!(config.is_empty());
546        assert!(config.validate_zone_references().is_empty());
547    }
548
549    #[test]
550    fn deserialize_json() {
551        let json = r#"{
552            "zones": [
553                { "name": "ui", "patterns": ["src/components/**", "src/pages/**"] },
554                { "name": "db", "patterns": ["src/db/**"] },
555                { "name": "shared", "patterns": ["src/shared/**"] }
556            ],
557            "rules": [
558                { "from": "ui", "allow": ["shared"] },
559                { "from": "db", "allow": ["shared"] }
560            ]
561        }"#;
562        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
563        assert_eq!(config.zones.len(), 3);
564        assert_eq!(config.rules.len(), 2);
565        assert_eq!(config.zones[0].name, "ui");
566        assert_eq!(
567            config.zones[0].patterns,
568            vec!["src/components/**", "src/pages/**"]
569        );
570        assert_eq!(config.rules[0].from, "ui");
571        assert_eq!(config.rules[0].allow, vec!["shared"]);
572    }
573
574    #[test]
575    fn deserialize_toml() {
576        let toml_str = r#"
577[[zones]]
578name = "ui"
579patterns = ["src/components/**"]
580
581[[zones]]
582name = "db"
583patterns = ["src/db/**"]
584
585[[rules]]
586from = "ui"
587allow = ["db"]
588"#;
589        let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
590        assert_eq!(config.zones.len(), 2);
591        assert_eq!(config.rules.len(), 1);
592    }
593
594    #[test]
595    fn validate_zone_references_valid() {
596        let config = BoundaryConfig {
597            preset: None,
598            zones: vec![
599                BoundaryZone {
600                    name: "ui".to_string(),
601                    patterns: vec![],
602                    root: None,
603                },
604                BoundaryZone {
605                    name: "db".to_string(),
606                    patterns: vec![],
607                    root: None,
608                },
609            ],
610            rules: vec![BoundaryRule {
611                from: "ui".to_string(),
612                allow: vec!["db".to_string()],
613            }],
614        };
615        assert!(config.validate_zone_references().is_empty());
616    }
617
618    #[test]
619    fn validate_zone_references_invalid_from() {
620        let config = BoundaryConfig {
621            preset: None,
622            zones: vec![BoundaryZone {
623                name: "ui".to_string(),
624                patterns: vec![],
625                root: None,
626            }],
627            rules: vec![BoundaryRule {
628                from: "nonexistent".to_string(),
629                allow: vec!["ui".to_string()],
630            }],
631        };
632        let errors = config.validate_zone_references();
633        assert_eq!(errors.len(), 1);
634        assert_eq!(errors[0].1, "nonexistent");
635    }
636
637    #[test]
638    fn validate_zone_references_invalid_allow() {
639        let config = BoundaryConfig {
640            preset: None,
641            zones: vec![BoundaryZone {
642                name: "ui".to_string(),
643                patterns: vec![],
644                root: None,
645            }],
646            rules: vec![BoundaryRule {
647                from: "ui".to_string(),
648                allow: vec!["nonexistent".to_string()],
649            }],
650        };
651        let errors = config.validate_zone_references();
652        assert_eq!(errors.len(), 1);
653        assert_eq!(errors[0].1, "nonexistent");
654    }
655
656    #[test]
657    fn resolve_and_classify() {
658        let config = BoundaryConfig {
659            preset: None,
660            zones: vec![
661                BoundaryZone {
662                    name: "ui".to_string(),
663                    patterns: vec!["src/components/**".to_string()],
664                    root: None,
665                },
666                BoundaryZone {
667                    name: "db".to_string(),
668                    patterns: vec!["src/db/**".to_string()],
669                    root: None,
670                },
671            ],
672            rules: vec![],
673        };
674        let resolved = config.resolve();
675        assert_eq!(
676            resolved.classify_zone("src/components/Button.tsx"),
677            Some("ui")
678        );
679        assert_eq!(resolved.classify_zone("src/db/queries.ts"), Some("db"));
680        assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
681    }
682
683    #[test]
684    fn first_match_wins() {
685        let config = BoundaryConfig {
686            preset: None,
687            zones: vec![
688                BoundaryZone {
689                    name: "specific".to_string(),
690                    patterns: vec!["src/shared/db-utils/**".to_string()],
691                    root: None,
692                },
693                BoundaryZone {
694                    name: "shared".to_string(),
695                    patterns: vec!["src/shared/**".to_string()],
696                    root: None,
697                },
698            ],
699            rules: vec![],
700        };
701        let resolved = config.resolve();
702        assert_eq!(
703            resolved.classify_zone("src/shared/db-utils/pool.ts"),
704            Some("specific")
705        );
706        assert_eq!(
707            resolved.classify_zone("src/shared/helpers.ts"),
708            Some("shared")
709        );
710    }
711
712    #[test]
713    fn self_import_always_allowed() {
714        let config = BoundaryConfig {
715            preset: None,
716            zones: vec![BoundaryZone {
717                name: "ui".to_string(),
718                patterns: vec![],
719                root: None,
720            }],
721            rules: vec![BoundaryRule {
722                from: "ui".to_string(),
723                allow: vec![],
724            }],
725        };
726        let resolved = config.resolve();
727        assert!(resolved.is_import_allowed("ui", "ui"));
728    }
729
730    #[test]
731    fn unrestricted_zone_allows_all() {
732        let config = BoundaryConfig {
733            preset: None,
734            zones: vec![
735                BoundaryZone {
736                    name: "shared".to_string(),
737                    patterns: vec![],
738                    root: None,
739                },
740                BoundaryZone {
741                    name: "db".to_string(),
742                    patterns: vec![],
743                    root: None,
744                },
745            ],
746            rules: vec![],
747        };
748        let resolved = config.resolve();
749        assert!(resolved.is_import_allowed("shared", "db"));
750    }
751
752    #[test]
753    fn restricted_zone_blocks_unlisted() {
754        let config = BoundaryConfig {
755            preset: None,
756            zones: vec![
757                BoundaryZone {
758                    name: "ui".to_string(),
759                    patterns: vec![],
760                    root: None,
761                },
762                BoundaryZone {
763                    name: "db".to_string(),
764                    patterns: vec![],
765                    root: None,
766                },
767                BoundaryZone {
768                    name: "shared".to_string(),
769                    patterns: vec![],
770                    root: None,
771                },
772            ],
773            rules: vec![BoundaryRule {
774                from: "ui".to_string(),
775                allow: vec!["shared".to_string()],
776            }],
777        };
778        let resolved = config.resolve();
779        assert!(resolved.is_import_allowed("ui", "shared"));
780        assert!(!resolved.is_import_allowed("ui", "db"));
781    }
782
783    #[test]
784    fn empty_allow_blocks_all_except_self() {
785        let config = BoundaryConfig {
786            preset: None,
787            zones: vec![
788                BoundaryZone {
789                    name: "isolated".to_string(),
790                    patterns: vec![],
791                    root: None,
792                },
793                BoundaryZone {
794                    name: "other".to_string(),
795                    patterns: vec![],
796                    root: None,
797                },
798            ],
799            rules: vec![BoundaryRule {
800                from: "isolated".to_string(),
801                allow: vec![],
802            }],
803        };
804        let resolved = config.resolve();
805        assert!(resolved.is_import_allowed("isolated", "isolated"));
806        assert!(!resolved.is_import_allowed("isolated", "other"));
807    }
808
809    #[test]
810    fn zone_root_filters_classification_to_subtree() {
811        let config = BoundaryConfig {
812            preset: None,
813            zones: vec![
814                BoundaryZone {
815                    name: "ui".to_string(),
816                    patterns: vec!["src/**".to_string()],
817                    root: Some("packages/app/".to_string()),
818                },
819                BoundaryZone {
820                    name: "domain".to_string(),
821                    patterns: vec!["src/**".to_string()],
822                    root: Some("packages/core/".to_string()),
823                },
824            ],
825            rules: vec![],
826        };
827        let resolved = config.resolve();
828        // Files inside packages/app/ classify as ui
829        assert_eq!(
830            resolved.classify_zone("packages/app/src/login.tsx"),
831            Some("ui")
832        );
833        // Files inside packages/core/ classify as domain (same pattern, different root)
834        assert_eq!(
835            resolved.classify_zone("packages/core/src/order.ts"),
836            Some("domain")
837        );
838        // Files outside either subtree do not match
839        assert_eq!(resolved.classify_zone("src/login.tsx"), None);
840        assert_eq!(resolved.classify_zone("packages/utils/src/x.ts"), None);
841    }
842
843    /// Case-sensitivity contract: `root` matching is case-sensitive,
844    /// matching the existing globset case-sensitivity for `patterns`. On
845    /// case-insensitive filesystems (HFS+, NTFS) two files differing only
846    /// in case still classify only when the configured `root` exactly
847    /// matches the path's case as fallow recorded it. Locking this down
848    /// prevents silent platform-divergent classification.
849    #[test]
850    fn zone_root_is_case_sensitive() {
851        let config = BoundaryConfig {
852            preset: None,
853            zones: vec![BoundaryZone {
854                name: "ui".to_string(),
855                patterns: vec!["src/**".to_string()],
856                root: Some("packages/app/".to_string()),
857            }],
858            rules: vec![],
859        };
860        let resolved = config.resolve();
861        assert_eq!(
862            resolved.classify_zone("packages/app/src/login.tsx"),
863            Some("ui"),
864            "exact-case path classifies"
865        );
866        assert_eq!(
867            resolved.classify_zone("packages/App/src/login.tsx"),
868            None,
869            "case-different path does not classify (root is case-sensitive)"
870        );
871        assert_eq!(
872            resolved.classify_zone("Packages/app/src/login.tsx"),
873            None,
874            "case-different prefix does not classify"
875        );
876    }
877
878    #[test]
879    fn zone_root_normalizes_trailing_slash_and_dot_prefix() {
880        let config = BoundaryConfig {
881            preset: None,
882            zones: vec![
883                BoundaryZone {
884                    name: "no-slash".to_string(),
885                    patterns: vec!["src/**".to_string()],
886                    root: Some("packages/app".to_string()),
887                },
888                BoundaryZone {
889                    name: "dot-prefixed".to_string(),
890                    patterns: vec!["src/**".to_string()],
891                    root: Some("./packages/lib/".to_string()),
892                },
893            ],
894            rules: vec![],
895        };
896        let resolved = config.resolve();
897        assert_eq!(resolved.zones[0].root.as_deref(), Some("packages/app/"));
898        assert_eq!(resolved.zones[1].root.as_deref(), Some("packages/lib/"));
899        assert_eq!(
900            resolved.classify_zone("packages/app/src/x.ts"),
901            Some("no-slash")
902        );
903        assert_eq!(
904            resolved.classify_zone("packages/lib/src/x.ts"),
905            Some("dot-prefixed")
906        );
907    }
908
909    #[test]
910    fn validate_root_prefixes_flags_redundant_pattern() {
911        let config = BoundaryConfig {
912            preset: None,
913            zones: vec![BoundaryZone {
914                name: "ui".to_string(),
915                patterns: vec!["packages/app/src/**".to_string()],
916                root: Some("packages/app/".to_string()),
917            }],
918            rules: vec![],
919        };
920        let errors = config.validate_root_prefixes();
921        assert_eq!(errors.len(), 1, "expected one redundant-prefix error");
922        assert!(
923            errors[0].contains("FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX"),
924            "error should be tagged: {}",
925            errors[0]
926        );
927        assert!(
928            errors[0].contains("zone 'ui'"),
929            "error should name the zone: {}",
930            errors[0]
931        );
932        assert!(
933            errors[0].contains("packages/app/src/**"),
934            "error should quote the pattern: {}",
935            errors[0]
936        );
937    }
938
939    #[test]
940    fn validate_root_prefixes_handles_unnormalized_root() {
941        // Root without trailing slash + pattern with leading "./" should
942        // still be detected as redundant after normalization.
943        let config = BoundaryConfig {
944            preset: None,
945            zones: vec![BoundaryZone {
946                name: "ui".to_string(),
947                patterns: vec!["./packages/app/src/**".to_string()],
948                root: Some("packages/app".to_string()),
949            }],
950            rules: vec![],
951        };
952        let errors = config.validate_root_prefixes();
953        assert_eq!(errors.len(), 1);
954    }
955
956    #[test]
957    fn validate_root_prefixes_empty_when_no_overlap() {
958        let config = BoundaryConfig {
959            preset: None,
960            zones: vec![BoundaryZone {
961                name: "ui".to_string(),
962                patterns: vec!["src/**".to_string()],
963                root: Some("packages/app/".to_string()),
964            }],
965            rules: vec![],
966        };
967        assert!(config.validate_root_prefixes().is_empty());
968    }
969
970    #[test]
971    fn validate_root_prefixes_skips_zones_without_root() {
972        let json = r#"{
973            "zones": [{ "name": "ui", "patterns": ["src/**"] }],
974            "rules": []
975        }"#;
976        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
977        assert!(config.validate_root_prefixes().is_empty());
978    }
979
980    /// Regression: an empty `root` (or `"."`/`"./"`, both of which normalize
981    /// to `""`) used to make `starts_with("")` always true, producing a
982    /// spurious FALLOW-BOUNDARY-ROOT-REDUNDANT-PREFIX error for every
983    /// pattern in the zone. The validation must skip empty-normalized roots
984    /// the same way `classify_zone` does.
985    #[test]
986    fn validate_root_prefixes_skips_empty_root() {
987        for raw_root in ["", ".", "./"] {
988            let config = BoundaryConfig {
989                preset: None,
990                zones: vec![BoundaryZone {
991                    name: "ui".to_string(),
992                    patterns: vec!["src/**".to_string(), "lib/**".to_string()],
993                    root: Some(raw_root.to_string()),
994                }],
995                rules: vec![],
996            };
997            let errors = config.validate_root_prefixes();
998            assert!(
999                errors.is_empty(),
1000                "empty-normalized root {raw_root:?} produced spurious errors: {errors:?}"
1001            );
1002        }
1003    }
1004
1005    #[test]
1006    fn deserialize_zone_with_root() {
1007        let json = r#"{
1008            "zones": [
1009                { "name": "ui", "patterns": ["src/**"], "root": "packages/app/" }
1010            ],
1011            "rules": []
1012        }"#;
1013        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1014        assert_eq!(config.zones[0].root.as_deref(), Some("packages/app/"));
1015    }
1016
1017    // ── Preset deserialization ─────────────────────────────────
1018
1019    #[test]
1020    fn deserialize_preset_json() {
1021        let json = r#"{ "preset": "layered" }"#;
1022        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1023        assert_eq!(config.preset, Some(BoundaryPreset::Layered));
1024        assert!(config.zones.is_empty());
1025    }
1026
1027    #[test]
1028    fn deserialize_preset_hexagonal_json() {
1029        let json = r#"{ "preset": "hexagonal" }"#;
1030        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1031        assert_eq!(config.preset, Some(BoundaryPreset::Hexagonal));
1032    }
1033
1034    #[test]
1035    fn deserialize_preset_feature_sliced_json() {
1036        let json = r#"{ "preset": "feature-sliced" }"#;
1037        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1038        assert_eq!(config.preset, Some(BoundaryPreset::FeatureSliced));
1039    }
1040
1041    #[test]
1042    fn deserialize_preset_toml() {
1043        let toml_str = r#"preset = "layered""#;
1044        let config: BoundaryConfig = toml::from_str(toml_str).unwrap();
1045        assert_eq!(config.preset, Some(BoundaryPreset::Layered));
1046    }
1047
1048    #[test]
1049    fn deserialize_invalid_preset_rejected() {
1050        let json = r#"{ "preset": "invalid_preset" }"#;
1051        let result: Result<BoundaryConfig, _> = serde_json::from_str(json);
1052        assert!(result.is_err());
1053    }
1054
1055    #[test]
1056    fn preset_absent_by_default() {
1057        let config = BoundaryConfig::default();
1058        assert!(config.preset.is_none());
1059        assert!(config.is_empty());
1060    }
1061
1062    #[test]
1063    fn preset_makes_config_non_empty() {
1064        let config = BoundaryConfig {
1065            preset: Some(BoundaryPreset::Layered),
1066            zones: vec![],
1067            rules: vec![],
1068        };
1069        assert!(!config.is_empty());
1070    }
1071
1072    // ── Preset expansion ───────────────────────────────────────
1073
1074    #[test]
1075    fn expand_layered_produces_four_zones() {
1076        let mut config = BoundaryConfig {
1077            preset: Some(BoundaryPreset::Layered),
1078            zones: vec![],
1079            rules: vec![],
1080        };
1081        config.expand("src");
1082        assert_eq!(config.zones.len(), 4);
1083        assert_eq!(config.rules.len(), 4);
1084        assert!(config.preset.is_none(), "preset cleared after expand");
1085        assert_eq!(config.zones[0].name, "presentation");
1086        assert_eq!(config.zones[0].patterns, vec!["src/presentation/**"]);
1087    }
1088
1089    #[test]
1090    fn expand_layered_rules_correct() {
1091        let mut config = BoundaryConfig {
1092            preset: Some(BoundaryPreset::Layered),
1093            zones: vec![],
1094            rules: vec![],
1095        };
1096        config.expand("src");
1097        // presentation → application only
1098        let pres_rule = config
1099            .rules
1100            .iter()
1101            .find(|r| r.from == "presentation")
1102            .unwrap();
1103        assert_eq!(pres_rule.allow, vec!["application"]);
1104        // application → domain only
1105        let app_rule = config
1106            .rules
1107            .iter()
1108            .find(|r| r.from == "application")
1109            .unwrap();
1110        assert_eq!(app_rule.allow, vec!["domain"]);
1111        // domain → nothing
1112        let dom_rule = config.rules.iter().find(|r| r.from == "domain").unwrap();
1113        assert!(dom_rule.allow.is_empty());
1114        // infrastructure → domain + application (DI-friendly)
1115        let infra_rule = config
1116            .rules
1117            .iter()
1118            .find(|r| r.from == "infrastructure")
1119            .unwrap();
1120        assert_eq!(infra_rule.allow, vec!["domain", "application"]);
1121    }
1122
1123    #[test]
1124    fn expand_hexagonal_produces_three_zones() {
1125        let mut config = BoundaryConfig {
1126            preset: Some(BoundaryPreset::Hexagonal),
1127            zones: vec![],
1128            rules: vec![],
1129        };
1130        config.expand("src");
1131        assert_eq!(config.zones.len(), 3);
1132        assert_eq!(config.rules.len(), 3);
1133        assert_eq!(config.zones[0].name, "adapters");
1134        assert_eq!(config.zones[1].name, "ports");
1135        assert_eq!(config.zones[2].name, "domain");
1136    }
1137
1138    #[test]
1139    fn expand_feature_sliced_produces_six_zones() {
1140        let mut config = BoundaryConfig {
1141            preset: Some(BoundaryPreset::FeatureSliced),
1142            zones: vec![],
1143            rules: vec![],
1144        };
1145        config.expand("src");
1146        assert_eq!(config.zones.len(), 6);
1147        assert_eq!(config.rules.len(), 6);
1148        // app can import everything below
1149        let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
1150        assert_eq!(
1151            app_rule.allow,
1152            vec!["pages", "widgets", "features", "entities", "shared"]
1153        );
1154        // shared imports nothing
1155        let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
1156        assert!(shared_rule.allow.is_empty());
1157        // entities → shared only
1158        let ent_rule = config.rules.iter().find(|r| r.from == "entities").unwrap();
1159        assert_eq!(ent_rule.allow, vec!["shared"]);
1160    }
1161
1162    #[test]
1163    fn expand_bulletproof_produces_four_zones() {
1164        let mut config = BoundaryConfig {
1165            preset: Some(BoundaryPreset::Bulletproof),
1166            zones: vec![],
1167            rules: vec![],
1168        };
1169        config.expand("src");
1170        assert_eq!(config.zones.len(), 4);
1171        assert_eq!(config.rules.len(), 4);
1172        assert_eq!(config.zones[0].name, "app");
1173        assert_eq!(config.zones[1].name, "features");
1174        assert_eq!(config.zones[2].name, "shared");
1175        assert_eq!(config.zones[3].name, "server");
1176        // shared zone has multiple patterns
1177        assert!(config.zones[2].patterns.len() > 1);
1178        assert!(
1179            config.zones[2]
1180                .patterns
1181                .contains(&"src/components/**".to_string())
1182        );
1183        assert!(
1184            config.zones[2]
1185                .patterns
1186                .contains(&"src/hooks/**".to_string())
1187        );
1188        assert!(config.zones[2].patterns.contains(&"src/lib/**".to_string()));
1189        assert!(
1190            config.zones[2]
1191                .patterns
1192                .contains(&"src/providers/**".to_string())
1193        );
1194    }
1195
1196    #[test]
1197    fn expand_bulletproof_rules_correct() {
1198        let mut config = BoundaryConfig {
1199            preset: Some(BoundaryPreset::Bulletproof),
1200            zones: vec![],
1201            rules: vec![],
1202        };
1203        config.expand("src");
1204        // app → features, shared, server
1205        let app_rule = config.rules.iter().find(|r| r.from == "app").unwrap();
1206        assert_eq!(app_rule.allow, vec!["features", "shared", "server"]);
1207        // features → shared, server
1208        let feat_rule = config.rules.iter().find(|r| r.from == "features").unwrap();
1209        assert_eq!(feat_rule.allow, vec!["shared", "server"]);
1210        // server → shared
1211        let srv_rule = config.rules.iter().find(|r| r.from == "server").unwrap();
1212        assert_eq!(srv_rule.allow, vec!["shared"]);
1213        // shared → nothing (isolated)
1214        let shared_rule = config.rules.iter().find(|r| r.from == "shared").unwrap();
1215        assert!(shared_rule.allow.is_empty());
1216    }
1217
1218    #[test]
1219    fn expand_bulletproof_then_resolve_classifies() {
1220        let mut config = BoundaryConfig {
1221            preset: Some(BoundaryPreset::Bulletproof),
1222            zones: vec![],
1223            rules: vec![],
1224        };
1225        config.expand("src");
1226        let resolved = config.resolve();
1227        assert_eq!(
1228            resolved.classify_zone("src/app/dashboard/page.tsx"),
1229            Some("app")
1230        );
1231        assert_eq!(
1232            resolved.classify_zone("src/features/auth/hooks/useAuth.ts"),
1233            Some("features")
1234        );
1235        assert_eq!(
1236            resolved.classify_zone("src/components/Button/Button.tsx"),
1237            Some("shared")
1238        );
1239        assert_eq!(
1240            resolved.classify_zone("src/hooks/useFormatters.ts"),
1241            Some("shared")
1242        );
1243        assert_eq!(
1244            resolved.classify_zone("src/server/db/schema/users.ts"),
1245            Some("server")
1246        );
1247        // features cannot import shared directly — only via allowed rules
1248        assert!(resolved.is_import_allowed("features", "shared"));
1249        assert!(resolved.is_import_allowed("features", "server"));
1250        assert!(!resolved.is_import_allowed("features", "app"));
1251        assert!(!resolved.is_import_allowed("shared", "features"));
1252        assert!(!resolved.is_import_allowed("server", "features"));
1253    }
1254
1255    #[test]
1256    fn expand_uses_custom_source_root() {
1257        let mut config = BoundaryConfig {
1258            preset: Some(BoundaryPreset::Hexagonal),
1259            zones: vec![],
1260            rules: vec![],
1261        };
1262        config.expand("lib");
1263        assert_eq!(config.zones[0].patterns, vec!["lib/adapters/**"]);
1264        assert_eq!(config.zones[2].patterns, vec!["lib/domain/**"]);
1265    }
1266
1267    // ── Preset merge behavior ──────────────────────────────────
1268
1269    #[test]
1270    fn user_zone_replaces_preset_zone() {
1271        let mut config = BoundaryConfig {
1272            preset: Some(BoundaryPreset::Hexagonal),
1273            zones: vec![BoundaryZone {
1274                name: "domain".to_string(),
1275                patterns: vec!["src/core/**".to_string()],
1276                root: None,
1277            }],
1278            rules: vec![],
1279        };
1280        config.expand("src");
1281        // 3 zones total: adapters + ports from preset, domain from user
1282        assert_eq!(config.zones.len(), 3);
1283        let domain = config.zones.iter().find(|z| z.name == "domain").unwrap();
1284        assert_eq!(domain.patterns, vec!["src/core/**"]);
1285    }
1286
1287    #[test]
1288    fn user_zone_adds_to_preset() {
1289        let mut config = BoundaryConfig {
1290            preset: Some(BoundaryPreset::Hexagonal),
1291            zones: vec![BoundaryZone {
1292                name: "shared".to_string(),
1293                patterns: vec!["src/shared/**".to_string()],
1294                root: None,
1295            }],
1296            rules: vec![],
1297        };
1298        config.expand("src");
1299        assert_eq!(config.zones.len(), 4); // 3 preset + 1 user
1300        assert!(config.zones.iter().any(|z| z.name == "shared"));
1301    }
1302
1303    #[test]
1304    fn user_rule_replaces_preset_rule() {
1305        let mut config = BoundaryConfig {
1306            preset: Some(BoundaryPreset::Hexagonal),
1307            zones: vec![],
1308            rules: vec![BoundaryRule {
1309                from: "adapters".to_string(),
1310                allow: vec!["ports".to_string(), "domain".to_string()],
1311            }],
1312        };
1313        config.expand("src");
1314        let adapter_rule = config.rules.iter().find(|r| r.from == "adapters").unwrap();
1315        // User rule allows both ports and domain (preset only allowed ports)
1316        assert_eq!(adapter_rule.allow, vec!["ports", "domain"]);
1317        // Other preset rules untouched
1318        assert_eq!(
1319            config.rules.iter().filter(|r| r.from == "adapters").count(),
1320            1
1321        );
1322    }
1323
1324    #[test]
1325    fn expand_without_preset_is_noop() {
1326        let mut config = BoundaryConfig {
1327            preset: None,
1328            zones: vec![BoundaryZone {
1329                name: "ui".to_string(),
1330                patterns: vec!["src/ui/**".to_string()],
1331                root: None,
1332            }],
1333            rules: vec![],
1334        };
1335        config.expand("src");
1336        assert_eq!(config.zones.len(), 1);
1337        assert_eq!(config.zones[0].name, "ui");
1338    }
1339
1340    #[test]
1341    fn expand_then_validate_succeeds() {
1342        let mut config = BoundaryConfig {
1343            preset: Some(BoundaryPreset::Layered),
1344            zones: vec![],
1345            rules: vec![],
1346        };
1347        config.expand("src");
1348        assert!(config.validate_zone_references().is_empty());
1349    }
1350
1351    #[test]
1352    fn expand_then_resolve_classifies() {
1353        let mut config = BoundaryConfig {
1354            preset: Some(BoundaryPreset::Hexagonal),
1355            zones: vec![],
1356            rules: vec![],
1357        };
1358        config.expand("src");
1359        let resolved = config.resolve();
1360        assert_eq!(
1361            resolved.classify_zone("src/adapters/http/handler.ts"),
1362            Some("adapters")
1363        );
1364        assert_eq!(resolved.classify_zone("src/domain/user.ts"), Some("domain"));
1365        assert!(!resolved.is_import_allowed("adapters", "domain"));
1366        assert!(resolved.is_import_allowed("adapters", "ports"));
1367    }
1368
1369    #[test]
1370    fn preset_name_returns_correct_string() {
1371        let config = BoundaryConfig {
1372            preset: Some(BoundaryPreset::FeatureSliced),
1373            zones: vec![],
1374            rules: vec![],
1375        };
1376        assert_eq!(config.preset_name(), Some("feature-sliced"));
1377
1378        let empty = BoundaryConfig::default();
1379        assert_eq!(empty.preset_name(), None);
1380    }
1381
1382    #[test]
1383    fn preset_name_all_variants() {
1384        let cases = [
1385            (BoundaryPreset::Layered, "layered"),
1386            (BoundaryPreset::Hexagonal, "hexagonal"),
1387            (BoundaryPreset::FeatureSliced, "feature-sliced"),
1388            (BoundaryPreset::Bulletproof, "bulletproof"),
1389        ];
1390        for (preset, expected_name) in cases {
1391            let config = BoundaryConfig {
1392                preset: Some(preset),
1393                zones: vec![],
1394                rules: vec![],
1395            };
1396            assert_eq!(
1397                config.preset_name(),
1398                Some(expected_name),
1399                "preset_name() mismatch for variant"
1400            );
1401        }
1402    }
1403
1404    // ── ResolvedBoundaryConfig::is_empty ────────────────────────────
1405
1406    #[test]
1407    fn resolved_boundary_config_empty() {
1408        let resolved = ResolvedBoundaryConfig::default();
1409        assert!(resolved.is_empty());
1410    }
1411
1412    #[test]
1413    fn resolved_boundary_config_with_zones_not_empty() {
1414        let config = BoundaryConfig {
1415            preset: None,
1416            zones: vec![BoundaryZone {
1417                name: "ui".to_string(),
1418                patterns: vec!["src/ui/**".to_string()],
1419                root: None,
1420            }],
1421            rules: vec![],
1422        };
1423        let resolved = config.resolve();
1424        assert!(!resolved.is_empty());
1425    }
1426
1427    // ── BoundaryConfig::is_empty edge cases ─────────────────────────
1428
1429    #[test]
1430    fn boundary_config_with_only_rules_is_empty() {
1431        // Having rules but no zones/preset is still "empty" since rules without zones
1432        // cannot produce boundary violations.
1433        let config = BoundaryConfig {
1434            preset: None,
1435            zones: vec![],
1436            rules: vec![BoundaryRule {
1437                from: "ui".to_string(),
1438                allow: vec!["db".to_string()],
1439            }],
1440        };
1441        assert!(config.is_empty());
1442    }
1443
1444    #[test]
1445    fn boundary_config_with_zones_not_empty() {
1446        let config = BoundaryConfig {
1447            preset: None,
1448            zones: vec![BoundaryZone {
1449                name: "ui".to_string(),
1450                patterns: vec![],
1451                root: None,
1452            }],
1453            rules: vec![],
1454        };
1455        assert!(!config.is_empty());
1456    }
1457
1458    // ── Multiple zone patterns ──────────────────────────────────────
1459
1460    #[test]
1461    fn zone_with_multiple_patterns_matches_any() {
1462        let config = BoundaryConfig {
1463            preset: None,
1464            zones: vec![BoundaryZone {
1465                name: "ui".to_string(),
1466                patterns: vec![
1467                    "src/components/**".to_string(),
1468                    "src/pages/**".to_string(),
1469                    "src/views/**".to_string(),
1470                ],
1471                root: None,
1472            }],
1473            rules: vec![],
1474        };
1475        let resolved = config.resolve();
1476        assert_eq!(
1477            resolved.classify_zone("src/components/Button.tsx"),
1478            Some("ui")
1479        );
1480        assert_eq!(resolved.classify_zone("src/pages/Home.tsx"), Some("ui"));
1481        assert_eq!(
1482            resolved.classify_zone("src/views/Dashboard.tsx"),
1483            Some("ui")
1484        );
1485        assert_eq!(resolved.classify_zone("src/utils/helpers.ts"), None);
1486    }
1487
1488    // ── validate_zone_references with multiple errors ───────────────
1489
1490    #[test]
1491    fn validate_zone_references_multiple_errors() {
1492        let config = BoundaryConfig {
1493            preset: None,
1494            zones: vec![BoundaryZone {
1495                name: "ui".to_string(),
1496                patterns: vec![],
1497                root: None,
1498            }],
1499            rules: vec![
1500                BoundaryRule {
1501                    from: "nonexistent_from".to_string(),
1502                    allow: vec!["nonexistent_allow".to_string()],
1503                },
1504                BoundaryRule {
1505                    from: "ui".to_string(),
1506                    allow: vec!["also_nonexistent".to_string()],
1507                },
1508            ],
1509        };
1510        let errors = config.validate_zone_references();
1511        // Rule 0: invalid "from" + invalid "allow" = 2 errors
1512        // Rule 1: valid "from", invalid "allow" = 1 error
1513        assert_eq!(errors.len(), 3);
1514    }
1515
1516    // ── Preset expansion with custom source root ────────────────────
1517
1518    #[test]
1519    fn expand_feature_sliced_with_custom_root() {
1520        let mut config = BoundaryConfig {
1521            preset: Some(BoundaryPreset::FeatureSliced),
1522            zones: vec![],
1523            rules: vec![],
1524        };
1525        config.expand("lib");
1526        assert_eq!(config.zones[0].patterns, vec!["lib/app/**"]);
1527        assert_eq!(config.zones[5].patterns, vec!["lib/shared/**"]);
1528    }
1529
1530    // ── is_import_allowed for zone not in rules (unrestricted) ──────
1531
1532    #[test]
1533    fn zone_not_in_rules_is_unrestricted() {
1534        let config = BoundaryConfig {
1535            preset: None,
1536            zones: vec![
1537                BoundaryZone {
1538                    name: "a".to_string(),
1539                    patterns: vec![],
1540                    root: None,
1541                },
1542                BoundaryZone {
1543                    name: "b".to_string(),
1544                    patterns: vec![],
1545                    root: None,
1546                },
1547                BoundaryZone {
1548                    name: "c".to_string(),
1549                    patterns: vec![],
1550                    root: None,
1551                },
1552            ],
1553            rules: vec![BoundaryRule {
1554                from: "a".to_string(),
1555                allow: vec!["b".to_string()],
1556            }],
1557        };
1558        let resolved = config.resolve();
1559        // "a" is restricted: can import from "b" but not "c"
1560        assert!(resolved.is_import_allowed("a", "b"));
1561        assert!(!resolved.is_import_allowed("a", "c"));
1562        // "b" has no rule entry: unrestricted
1563        assert!(resolved.is_import_allowed("b", "a"));
1564        assert!(resolved.is_import_allowed("b", "c"));
1565        // "c" has no rule entry: unrestricted
1566        assert!(resolved.is_import_allowed("c", "a"));
1567    }
1568
1569    // ── Preset serialization/deserialization roundtrip ───────────────
1570
1571    #[test]
1572    fn boundary_preset_json_roundtrip() {
1573        let presets = [
1574            BoundaryPreset::Layered,
1575            BoundaryPreset::Hexagonal,
1576            BoundaryPreset::FeatureSliced,
1577            BoundaryPreset::Bulletproof,
1578        ];
1579        for preset in presets {
1580            let json = serde_json::to_string(&preset).unwrap();
1581            let restored: BoundaryPreset = serde_json::from_str(&json).unwrap();
1582            assert_eq!(restored, preset);
1583        }
1584    }
1585
1586    #[test]
1587    fn deserialize_preset_bulletproof_json() {
1588        let json = r#"{ "preset": "bulletproof" }"#;
1589        let config: BoundaryConfig = serde_json::from_str(json).unwrap();
1590        assert_eq!(config.preset, Some(BoundaryPreset::Bulletproof));
1591    }
1592
1593    // ── Zone with invalid glob ──────────────────────────────────────
1594
1595    #[test]
1596    fn resolve_skips_invalid_zone_glob() {
1597        let config = BoundaryConfig {
1598            preset: None,
1599            zones: vec![BoundaryZone {
1600                name: "broken".to_string(),
1601                patterns: vec!["[invalid".to_string()],
1602                root: None,
1603            }],
1604            rules: vec![],
1605        };
1606        let resolved = config.resolve();
1607        // Zone exists but has no valid matchers, so no file can be classified into it
1608        assert!(!resolved.is_empty());
1609        assert_eq!(resolved.classify_zone("anything.ts"), None);
1610    }
1611}