Skip to main content

boundary_core/
layer.rs

1use globset::{Glob, GlobSet, GlobSetBuilder};
2
3use crate::config::LayersConfig;
4use crate::types::{ArchLayer, ArchitectureMode};
5
6/// A compiled per-module layer override.
7struct LayerOverride {
8    scope: GlobSet,
9    domain: GlobSet,
10    application: GlobSet,
11    infrastructure: GlobSet,
12    presentation: GlobSet,
13    has_domain: bool,
14    has_application: bool,
15    has_infrastructure: bool,
16    has_presentation: bool,
17    architecture_mode: Option<ArchitectureMode>,
18}
19
20/// Classifies file paths into architectural layers using glob patterns.
21pub struct LayerClassifier {
22    domain: GlobSet,
23    application: GlobSet,
24    infrastructure: GlobSet,
25    presentation: GlobSet,
26    overrides: Vec<LayerOverride>,
27    cross_cutting: GlobSet,
28    default_mode: ArchitectureMode,
29}
30
31fn build_globset(patterns: &[String]) -> GlobSet {
32    let mut builder = GlobSetBuilder::new();
33    for pattern in patterns {
34        if let Ok(glob) = Glob::new(pattern) {
35            builder.add(glob);
36        }
37    }
38    builder
39        .build()
40        .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
41}
42
43impl LayerClassifier {
44    pub fn new(config: &LayersConfig) -> Self {
45        let overrides = config
46            .overrides
47            .iter()
48            .map(|o| LayerOverride {
49                scope: build_globset(std::slice::from_ref(&o.scope)),
50                domain: build_globset(&o.domain),
51                application: build_globset(&o.application),
52                infrastructure: build_globset(&o.infrastructure),
53                presentation: build_globset(&o.presentation),
54                has_domain: !o.domain.is_empty(),
55                has_application: !o.application.is_empty(),
56                has_infrastructure: !o.infrastructure.is_empty(),
57                has_presentation: !o.presentation.is_empty(),
58                architecture_mode: o.architecture_mode,
59            })
60            .collect();
61
62        Self {
63            domain: build_globset(&config.domain),
64            application: build_globset(&config.application),
65            infrastructure: build_globset(&config.infrastructure),
66            presentation: build_globset(&config.presentation),
67            overrides,
68            cross_cutting: build_globset(&config.cross_cutting),
69            default_mode: config.architecture_mode,
70        }
71    }
72
73    /// Classify a file path into an architectural layer.
74    pub fn classify(&self, path: &str) -> Option<ArchLayer> {
75        let normalized = path.replace('\\', "/");
76        let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
77
78        // Check overrides first (first matching scope wins)
79        for ovr in &self.overrides {
80            if ovr.scope.is_match(normalized) {
81                return self.classify_with_override(ovr, normalized);
82            }
83        }
84
85        // No override matched — use global patterns
86        self.classify_global(normalized)
87    }
88
89    /// Get the architecture mode for a given file path.
90    /// Checks overrides first (first scope match wins), falls back to global default.
91    pub fn architecture_mode(&self, path: &str) -> ArchitectureMode {
92        let normalized = path.replace('\\', "/");
93        let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
94        for ovr in &self.overrides {
95            if ovr.scope.is_match(normalized) {
96                if let Some(mode) = ovr.architecture_mode {
97                    return mode;
98                }
99                return self.default_mode;
100            }
101        }
102        self.default_mode
103    }
104
105    /// Check if a path matches cross-cutting concern patterns.
106    pub fn is_cross_cutting(&self, path: &str) -> bool {
107        let normalized = path.replace('\\', "/");
108        let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
109        self.cross_cutting.is_match(normalized)
110    }
111
112    /// Check if an import path matches cross-cutting concern patterns.
113    /// Generates candidate paths to bridge Go-style import paths with file-path-style globs.
114    pub fn is_cross_cutting_import(&self, import_path: &str) -> bool {
115        let normalized = import_path.replace('\\', "/");
116        let candidates = [
117            normalized.clone(),
118            format!("**/{normalized}"),
119            format!("{normalized}/**"),
120        ];
121        candidates.iter().any(|c| self.cross_cutting.is_match(c))
122    }
123
124    /// Classify an import path string into an architectural layer.
125    pub fn classify_import(&self, import_path: &str) -> Option<ArchLayer> {
126        let candidates = [
127            import_path.to_string(),
128            format!("**/{import_path}"),
129            format!("{import_path}/**"),
130        ];
131        for candidate in &candidates {
132            if let Some(layer) = self.classify(candidate) {
133                return Some(layer);
134            }
135        }
136        // Fallback: heuristic based on path segments.
137        // Also matches bare package aliases (e.g. "infrastructure" from init() call sites).
138        let lower = import_path.to_lowercase();
139        let last = lower.split('/').next_back().unwrap_or(&lower);
140        if lower.contains("/domain")
141            || lower.contains("/entity")
142            || lower.contains("/model")
143            || matches!(last, "domain" | "entity" | "model")
144        {
145            Some(ArchLayer::Domain)
146        } else if lower.contains("/application")
147            || lower.contains("/usecase")
148            || lower.contains("/service")
149            || matches!(last, "application" | "usecase" | "service")
150        {
151            Some(ArchLayer::Application)
152        } else if lower.contains("/infrastructure")
153            || lower.contains("/adapter")
154            || lower.contains("/repository")
155            || lower.contains("/persistence")
156            || matches!(
157                last,
158                "infrastructure" | "adapter" | "repository" | "persistence"
159            )
160        {
161            Some(ArchLayer::Infrastructure)
162        } else if lower.contains("/presentation")
163            || lower.contains("/handler")
164            || lower.contains("/api/")
165            || lower.contains("/cmd")
166            || matches!(last, "presentation" | "handler" | "cmd")
167        {
168            Some(ArchLayer::Presentation)
169        } else {
170            None
171        }
172    }
173
174    /// Classify using global patterns only.
175    fn classify_global(&self, normalized: &str) -> Option<ArchLayer> {
176        if self.domain.is_match(normalized) {
177            Some(ArchLayer::Domain)
178        } else if self.application.is_match(normalized) {
179            Some(ArchLayer::Application)
180        } else if self.infrastructure.is_match(normalized) {
181            Some(ArchLayer::Infrastructure)
182        } else if self.presentation.is_match(normalized) {
183            Some(ArchLayer::Presentation)
184        } else {
185            None
186        }
187    }
188
189    /// Classify using an override's patterns, falling back to global for layers
190    /// the override doesn't define.
191    fn classify_with_override(&self, ovr: &LayerOverride, normalized: &str) -> Option<ArchLayer> {
192        // For each layer, use override patterns if defined, else global
193        let domain_match = if ovr.has_domain {
194            ovr.domain.is_match(normalized)
195        } else {
196            self.domain.is_match(normalized)
197        };
198        if domain_match {
199            return Some(ArchLayer::Domain);
200        }
201
202        let app_match = if ovr.has_application {
203            ovr.application.is_match(normalized)
204        } else {
205            self.application.is_match(normalized)
206        };
207        if app_match {
208            return Some(ArchLayer::Application);
209        }
210
211        let infra_match = if ovr.has_infrastructure {
212            ovr.infrastructure.is_match(normalized)
213        } else {
214            self.infrastructure.is_match(normalized)
215        };
216        if infra_match {
217            return Some(ArchLayer::Infrastructure);
218        }
219
220        let pres_match = if ovr.has_presentation {
221            ovr.presentation.is_match(normalized)
222        } else {
223            self.presentation.is_match(normalized)
224        };
225        if pres_match {
226            return Some(ArchLayer::Presentation);
227        }
228
229        None
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::config::{LayerOverrideConfig, LayersConfig};
237
238    fn config_with_overrides(overrides: Vec<LayerOverrideConfig>) -> LayersConfig {
239        LayersConfig {
240            overrides,
241            ..LayersConfig::default()
242        }
243    }
244
245    #[test]
246    fn test_classify_default_patterns() {
247        let classifier = LayerClassifier::new(&LayersConfig::default());
248
249        assert_eq!(
250            classifier.classify("internal/domain/user/entity.go"),
251            Some(ArchLayer::Domain)
252        );
253        assert_eq!(
254            classifier.classify("internal/application/user/service.go"),
255            Some(ArchLayer::Application)
256        );
257        assert_eq!(
258            classifier.classify("internal/infrastructure/postgres/repo.go"),
259            Some(ArchLayer::Infrastructure)
260        );
261        assert_eq!(
262            classifier.classify("internal/handler/http.go"),
263            Some(ArchLayer::Presentation)
264        );
265        assert_eq!(classifier.classify("main.go"), None);
266    }
267
268    #[test]
269    fn test_classify_import() {
270        let classifier = LayerClassifier::new(&LayersConfig::default());
271
272        assert_eq!(
273            classifier.classify_import("github.com/example/app/internal/domain/user"),
274            Some(ArchLayer::Domain)
275        );
276        assert_eq!(
277            classifier.classify_import("github.com/example/app/internal/infrastructure/postgres"),
278            Some(ArchLayer::Infrastructure)
279        );
280    }
281
282    #[test]
283    fn test_override_scoped_classification() {
284        let config = config_with_overrides(vec![LayerOverrideConfig {
285            scope: "services/auth/**".to_string(),
286            domain: vec!["services/auth/core/**".to_string()],
287            infrastructure: vec![
288                "services/auth/server/**".to_string(),
289                "services/auth/adapters/**".to_string(),
290            ],
291            application: vec![],
292            presentation: vec![],
293            architecture_mode: None,
294        }]);
295        let classifier = LayerClassifier::new(&config);
296
297        // Within scope: override patterns apply
298        assert_eq!(
299            classifier.classify("services/auth/core/user.go"),
300            Some(ArchLayer::Domain)
301        );
302        assert_eq!(
303            classifier.classify("services/auth/server/http.go"),
304            Some(ArchLayer::Infrastructure)
305        );
306        assert_eq!(
307            classifier.classify("services/auth/adapters/pg.go"),
308            Some(ArchLayer::Infrastructure)
309        );
310    }
311
312    #[test]
313    fn test_paths_outside_override_use_global() {
314        let config = config_with_overrides(vec![LayerOverrideConfig {
315            scope: "services/auth/**".to_string(),
316            domain: vec!["services/auth/core/**".to_string()],
317            infrastructure: vec![],
318            application: vec![],
319            presentation: vec![],
320            architecture_mode: None,
321        }]);
322        let classifier = LayerClassifier::new(&config);
323
324        // Outside scope: global patterns apply
325        assert_eq!(
326            classifier.classify("internal/domain/user/entity.go"),
327            Some(ArchLayer::Domain)
328        );
329        assert_eq!(
330            classifier.classify("internal/infrastructure/postgres/repo.go"),
331            Some(ArchLayer::Infrastructure)
332        );
333    }
334
335    #[test]
336    fn test_override_omitted_layers_fall_back_to_global() {
337        // Override only defines domain; application/infrastructure/presentation
338        // should fall back to global defaults.
339        let config = config_with_overrides(vec![LayerOverrideConfig {
340            scope: "services/billing/**".to_string(),
341            domain: vec!["services/billing/core/**".to_string()],
342            application: vec![],
343            infrastructure: vec![],
344            presentation: vec![],
345            architecture_mode: None,
346        }]);
347        let classifier = LayerClassifier::new(&config);
348
349        // domain uses override pattern
350        assert_eq!(
351            classifier.classify("services/billing/core/invoice.go"),
352            Some(ArchLayer::Domain)
353        );
354        // infrastructure falls back to global pattern
355        assert_eq!(
356            classifier.classify("services/billing/infrastructure/stripe.go"),
357            Some(ArchLayer::Infrastructure)
358        );
359    }
360
361    #[test]
362    fn test_first_matching_override_wins() {
363        let config = config_with_overrides(vec![
364            LayerOverrideConfig {
365                scope: "services/auth/**".to_string(),
366                domain: vec!["services/auth/core/**".to_string()],
367                infrastructure: vec![],
368                application: vec![],
369                presentation: vec![],
370                architecture_mode: None,
371            },
372            LayerOverrideConfig {
373                scope: "services/**".to_string(),
374                domain: vec!["services/*/models/**".to_string()],
375                infrastructure: vec![],
376                application: vec![],
377                presentation: vec![],
378                architecture_mode: None,
379            },
380        ]);
381        let classifier = LayerClassifier::new(&config);
382
383        // First override matches services/auth/**, so its domain pattern is used
384        assert_eq!(
385            classifier.classify("services/auth/core/user.go"),
386            Some(ArchLayer::Domain)
387        );
388        // The second override's pattern would NOT match this because first wins
389        assert_eq!(
390            classifier.classify("services/auth/models/user.go"),
391            None // Not domain because first override's domain is core/**
392        );
393    }
394
395    #[test]
396    fn test_import_classification_respects_overrides() {
397        let config = config_with_overrides(vec![LayerOverrideConfig {
398            scope: "services/auth/**".to_string(),
399            domain: vec!["services/auth/core/**".to_string()],
400            infrastructure: vec![],
401            application: vec![],
402            presentation: vec![],
403            architecture_mode: None,
404        }]);
405        let classifier = LayerClassifier::new(&config);
406
407        assert_eq!(
408            classifier.classify_import("services/auth/core/user"),
409            Some(ArchLayer::Domain)
410        );
411    }
412
413    #[test]
414    fn test_is_cross_cutting_matches() {
415        let config = LayersConfig {
416            cross_cutting: vec![
417                "common/utils/**".to_string(),
418                "pkg/logger/**".to_string(),
419                "pkg/errors/**".to_string(),
420            ],
421            ..LayersConfig::default()
422        };
423        let classifier = LayerClassifier::new(&config);
424
425        assert!(classifier.is_cross_cutting("common/utils/helpers.go"));
426        assert!(classifier.is_cross_cutting("pkg/logger/zap.go"));
427        assert!(classifier.is_cross_cutting("pkg/errors/wrap.go"));
428    }
429
430    #[test]
431    fn test_is_cross_cutting_globstar_patterns() {
432        let config = LayersConfig {
433            cross_cutting: vec![
434                "**/methods/**".to_string(),
435                "**/observability/**".to_string(),
436                "**/uptime/**".to_string(),
437            ],
438            ..LayersConfig::default()
439        };
440        let classifier = LayerClassifier::new(&config);
441
442        assert!(classifier.is_cross_cutting("common/modules/billing/methods/payment_method.go"));
443        assert!(classifier.is_cross_cutting("common/modules/billing/observability/metrics.go"));
444        assert!(classifier.is_cross_cutting("common/modules/billing/uptime/calc.go"));
445        assert!(!classifier.is_cross_cutting("common/modules/billing/domain/models/payment.go"));
446        // Also works with short relative paths (when analyzing a subdir)
447        assert!(classifier.is_cross_cutting("methods/payment_method.go"));
448        assert!(classifier.is_cross_cutting("observability/metrics.go"));
449        assert!(classifier.is_cross_cutting("uptime/calc.go"));
450    }
451
452    #[test]
453    fn test_is_cross_cutting_no_match() {
454        let config = LayersConfig {
455            cross_cutting: vec!["common/utils/**".to_string()],
456            ..LayersConfig::default()
457        };
458        let classifier = LayerClassifier::new(&config);
459
460        assert!(!classifier.is_cross_cutting("internal/domain/user.go"));
461        assert!(!classifier.is_cross_cutting("pkg/auth/service.go"));
462    }
463
464    #[test]
465    fn test_cross_cutting_empty_patterns() {
466        let config = LayersConfig::default();
467        let classifier = LayerClassifier::new(&config);
468
469        assert!(!classifier.is_cross_cutting("common/utils/helpers.go"));
470        assert!(!classifier.is_cross_cutting("any/path.go"));
471    }
472
473    #[test]
474    fn test_architecture_mode_default() {
475        let classifier = LayerClassifier::new(&LayersConfig::default());
476        assert_eq!(
477            classifier.architecture_mode("any/path.go"),
478            ArchitectureMode::Ddd
479        );
480    }
481
482    #[test]
483    fn test_architecture_mode_global_override() {
484        let config = LayersConfig {
485            architecture_mode: ArchitectureMode::ActiveRecord,
486            ..LayersConfig::default()
487        };
488        let classifier = LayerClassifier::new(&config);
489        assert_eq!(
490            classifier.architecture_mode("any/path.go"),
491            ArchitectureMode::ActiveRecord
492        );
493    }
494
495    #[test]
496    fn test_architecture_mode_scope_override() {
497        let config = LayersConfig {
498            overrides: vec![LayerOverrideConfig {
499                scope: "services/legacy/**".to_string(),
500                domain: vec![],
501                application: vec![],
502                infrastructure: vec![],
503                presentation: vec![],
504                architecture_mode: Some(ArchitectureMode::ServiceOriented),
505            }],
506            ..LayersConfig::default()
507        };
508        let classifier = LayerClassifier::new(&config);
509
510        assert_eq!(
511            classifier.architecture_mode("services/legacy/handler.go"),
512            ArchitectureMode::ServiceOriented
513        );
514        // Outside scope falls back to global default (Ddd)
515        assert_eq!(
516            classifier.architecture_mode("other/handler.go"),
517            ArchitectureMode::Ddd
518        );
519    }
520
521    #[test]
522    fn test_architecture_mode_override_without_mode_uses_global() {
523        let config = LayersConfig {
524            architecture_mode: ArchitectureMode::ActiveRecord,
525            overrides: vec![LayerOverrideConfig {
526                scope: "services/auth/**".to_string(),
527                domain: vec!["services/auth/core/**".to_string()],
528                application: vec![],
529                infrastructure: vec![],
530                presentation: vec![],
531                architecture_mode: None, // no mode override
532            }],
533            ..LayersConfig::default()
534        };
535        let classifier = LayerClassifier::new(&config);
536
537        // Scope matches but no mode override → falls back to global (ActiveRecord)
538        assert_eq!(
539            classifier.architecture_mode("services/auth/core/user.go"),
540            ArchitectureMode::ActiveRecord
541        );
542    }
543
544    #[test]
545    fn test_is_cross_cutting_import_go_paths() {
546        let config = LayersConfig {
547            cross_cutting: vec![
548                "**/observability/**".to_string(),
549                "**/auth/**".to_string(),
550                "**/utils/**".to_string(),
551            ],
552            ..LayersConfig::default()
553        };
554        let classifier = LayerClassifier::new(&config);
555
556        // Go import paths (no trailing segment after package name)
557        assert!(classifier.is_cross_cutting_import("github.com/example/app/observability"));
558        assert!(classifier.is_cross_cutting_import("github.com/example/app/auth"));
559
560        // Paths with subpackages (raw path has trailing segment)
561        assert!(classifier.is_cross_cutting_import("github.com/example/app/utils/log"));
562
563        // Non-matching paths
564        assert!(!classifier.is_cross_cutting_import("github.com/example/app/domain/user"));
565        assert!(!classifier.is_cross_cutting_import("github.com/stripe/stripe-go"));
566
567        // File paths still work
568        assert!(classifier.is_cross_cutting_import("observability/metrics.go"));
569    }
570
571    #[test]
572    fn test_cross_cutting_independent_of_layer() {
573        let config = LayersConfig {
574            cross_cutting: vec!["**/domain/**".to_string()],
575            ..LayersConfig::default()
576        };
577        let classifier = LayerClassifier::new(&config);
578
579        // Path matches both domain layer and cross-cutting
580        assert_eq!(
581            classifier.classify("internal/domain/user.go"),
582            Some(ArchLayer::Domain)
583        );
584        assert!(classifier.is_cross_cutting("internal/domain/user.go"));
585    }
586}