1use globset::{Glob, GlobSet, GlobSetBuilder};
2
3use crate::config::LayersConfig;
4use crate::types::{ArchLayer, ArchitectureMode};
5
6struct 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
20pub 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 pub fn classify(&self, path: &str) -> Option<ArchLayer> {
75 let normalized = path.replace('\\', "/");
76 let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
77
78 for ovr in &self.overrides {
80 if ovr.scope.is_match(normalized) {
81 return self.classify_with_override(ovr, normalized);
82 }
83 }
84
85 self.classify_global(normalized)
87 }
88
89 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 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 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 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 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 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 fn classify_with_override(&self, ovr: &LayerOverride, normalized: &str) -> Option<ArchLayer> {
192 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 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 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 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 assert_eq!(
351 classifier.classify("services/billing/core/invoice.go"),
352 Some(ArchLayer::Domain)
353 );
354 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 assert_eq!(
385 classifier.classify("services/auth/core/user.go"),
386 Some(ArchLayer::Domain)
387 );
388 assert_eq!(
390 classifier.classify("services/auth/models/user.go"),
391 None );
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 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 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, }],
533 ..LayersConfig::default()
534 };
535 let classifier = LayerClassifier::new(&config);
536
537 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 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 assert!(classifier.is_cross_cutting_import("github.com/example/app/utils/log"));
562
563 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 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 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}