1use std::collections::HashSet;
15
16use serde::{Deserialize, Serialize};
17
18use crate::types::{Component, ComponentKind, Dependency};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct PatternScore {
23 pub name: String,
24 pub confidence: f64,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct PatternDetection {
31 pub patterns: Vec<PatternScore>,
33 pub top_pattern: String,
35 pub top_confidence: f64,
37}
38
39struct Signals {
41 pkg_count: usize,
42 has_domain_layer: bool,
43 has_app_layer: bool,
44 has_infra_layer: bool,
45 layer_name_count: usize,
46 total_interfaces: usize,
47 domain_interfaces: usize,
48 domain_structs: usize,
49 domain_is_imported: bool,
50 domain_imports_nothing: bool,
51 has_any_internal_deps: bool,
52}
53
54pub fn detect_patterns(components: &[Component], dependencies: &[Dependency]) -> PatternDetection {
56 let signals = extract_signals(components, dependencies);
57
58 let mut patterns = vec![
59 PatternScore {
60 name: "ddd-hexagonal".to_string(),
61 confidence: ddd_hexagonal(&signals),
62 },
63 PatternScore {
64 name: "active-record".to_string(),
65 confidence: active_record(&signals),
66 },
67 PatternScore {
68 name: "flat-crud".to_string(),
69 confidence: flat_crud(&signals),
70 },
71 PatternScore {
72 name: "anemic-domain".to_string(),
73 confidence: anemic_domain(&signals),
74 },
75 PatternScore {
76 name: "service-layer".to_string(),
77 confidence: service_layer(&signals),
78 },
79 ];
80
81 patterns.sort_by(|a, b| {
84 b.confidence
85 .partial_cmp(&a.confidence)
86 .unwrap_or(std::cmp::Ordering::Equal)
87 });
88
89 let top_pattern = patterns
90 .first()
91 .map(|p| p.name.clone())
92 .unwrap_or_else(|| "unknown".to_string());
93 let top_confidence = patterns.first().map(|p| p.confidence).unwrap_or(0.0);
94
95 patterns.sort_by(|a, b| a.name.cmp(&b.name));
97
98 PatternDetection {
99 patterns,
100 top_pattern,
101 top_confidence,
102 }
103}
104
105fn pkg_from_id(id: &str) -> &str {
108 id.split("::").next().unwrap_or("")
109}
110
111fn path_contains_layer(path: &str, layer: &str) -> bool {
115 path.split(['/', '.', ':']).any(|seg| seg == layer)
116}
117
118fn import_layer(path: &str) -> Option<&'static str> {
120 ["domain", "application", "infrastructure", "app", "infra"]
122 .iter()
123 .find(|&&layer| path_contains_layer(path, layer))
124 .copied()
125}
126
127fn canonical_layer(layer: &str) -> &str {
129 match layer {
130 "app" => "application",
131 "infra" => "infrastructure",
132 other => other,
133 }
134}
135
136fn extract_signals(components: &[Component], dependencies: &[Dependency]) -> Signals {
137 let mut pkg_paths: HashSet<String> = HashSet::new();
139 for comp in components {
140 let pkg = pkg_from_id(&comp.id.0);
141 if !pkg.is_empty() {
142 pkg_paths.insert(pkg.to_string());
143 }
144 }
145 let pkg_count = pkg_paths.len();
146
147 let has_domain_layer = pkg_paths.iter().any(|p| path_contains_layer(p, "domain"));
152 let has_app_layer = pkg_paths
153 .iter()
154 .any(|p| path_contains_layer(p, "application") || path_contains_layer(p, "app"));
155 let has_infra_layer = pkg_paths
156 .iter()
157 .any(|p| path_contains_layer(p, "infrastructure") || path_contains_layer(p, "infra"));
158 let layer_name_count = [has_domain_layer, has_app_layer, has_infra_layer]
159 .iter()
160 .filter(|&&x| x)
161 .count();
162
163 let mut total_interfaces = 0usize;
165 let mut domain_interfaces = 0usize;
166 let mut domain_structs = 0usize;
167
168 for comp in components {
169 let full_pkg = pkg_from_id(&comp.id.0);
170 let in_domain = path_contains_layer(full_pkg, "domain");
171 let is_interface = matches!(comp.kind, ComponentKind::Port(_));
172 if is_interface {
173 total_interfaces += 1;
174 }
175 if in_domain {
176 if is_interface {
177 domain_interfaces += 1;
178 } else {
179 domain_structs += 1;
180 }
181 }
182 }
183
184 let mut has_any_internal_deps = false;
188 let mut domain_has_afferent = false; let mut domain_has_efferent = false; for dep in dependencies {
192 let from_pkg = pkg_from_id(&dep.from.0);
193 let Some(from_layer) = import_layer(from_pkg) else {
194 continue;
195 };
196 let Some(to_layer) = dep.import_path.as_deref().and_then(import_layer) else {
197 continue;
198 };
199
200 let from_c = canonical_layer(from_layer);
201 let to_c = canonical_layer(to_layer);
202 if from_c == to_c {
203 continue; }
205
206 has_any_internal_deps = true;
207 if to_c == "domain" {
208 domain_has_afferent = true;
209 }
210 if from_c == "domain" {
211 domain_has_efferent = true;
212 }
213 }
214
215 let domain_is_imported = domain_has_afferent;
216 let domain_imports_nothing = !domain_has_efferent;
217
218 Signals {
219 pkg_count,
220 has_domain_layer,
221 has_app_layer,
222 has_infra_layer,
223 layer_name_count,
224 total_interfaces,
225 domain_interfaces,
226 domain_structs,
227 domain_is_imported,
228 domain_imports_nothing,
229 has_any_internal_deps,
230 }
231}
232
233fn ddd_hexagonal(s: &Signals) -> f64 {
237 let mut score = 0.0_f64;
238 if s.has_domain_layer {
239 score += 0.20;
240 }
241 if s.has_app_layer {
242 score += 0.15;
243 }
244 if s.has_infra_layer {
245 score += 0.15;
246 }
247 if s.domain_interfaces > 0 {
248 score += 0.20;
249 }
250 if s.domain_is_imported && s.domain_imports_nothing {
251 score += 0.20;
252 }
253 let total_domain = s.domain_interfaces + s.domain_structs;
254 if total_domain > 0 {
255 let ratio = s.domain_interfaces as f64 / total_domain as f64;
256 if ratio >= 0.25 {
257 score += 0.10;
258 }
259 }
260 score.clamp(0.0, 1.0)
261}
262
263fn active_record(_s: &Signals) -> f64 {
266 0.0
267}
268
269fn flat_crud(s: &Signals) -> f64 {
271 let mut score = 0.0_f64;
272 if s.pkg_count == 1 {
273 score += 0.55;
274 } else if s.pkg_count == 2 && !s.has_any_internal_deps {
275 score += 0.05;
277 }
278 if s.total_interfaces == 0 {
279 score += 0.20;
280 }
281 if s.layer_name_count == 0 {
282 score += 0.15;
283 }
284 score.clamp(0.0, 1.0)
285}
286
287fn anemic_domain(s: &Signals) -> f64 {
290 if !s.has_domain_layer {
291 return 0.0;
292 }
293 let mut score = 0.0_f64;
294 if s.domain_interfaces == 0 && s.domain_structs > 0 {
295 score += 0.40; }
297 if s.domain_is_imported {
298 score += 0.30; }
300 if s.total_interfaces == 0 {
301 score += 0.20; }
303 if !s.has_app_layer {
304 score += 0.10; }
306 score.clamp(0.0, 1.0)
307}
308
309fn service_layer(s: &Signals) -> f64 {
311 let mut score = 0.0_f64;
312 if s.pkg_count >= 2 {
313 score += 0.20;
314 }
315 if s.has_any_internal_deps {
316 score += 0.30;
317 }
318 if s.total_interfaces == 0 && s.has_any_internal_deps {
319 score += 0.20; }
321 if !s.has_domain_layer && !s.has_infra_layer && s.pkg_count >= 2 {
322 score += 0.10; }
324 score.clamp(0.0, 1.0)
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use crate::types::{
331 ArchLayer, ArchitectureMode, ComponentId, ComponentKind, DependencyKind, EntityInfo,
332 PortInfo, SourceLocation,
333 };
334 use std::path::PathBuf;
335
336 fn make_interface(id: &str) -> Component {
337 Component {
338 id: ComponentId(id.to_string()),
339 name: id.split("::").last().unwrap_or(id).to_string(),
340 kind: ComponentKind::Port(PortInfo {
341 name: id.to_string(),
342 methods: vec![],
343 }),
344 layer: None,
345 location: SourceLocation {
346 file: PathBuf::from("test.go"),
347 line: 1,
348 column: 1,
349 },
350 is_cross_cutting: false,
351 architecture_mode: ArchitectureMode::Ddd,
352 }
353 }
354
355 fn make_struct(id: &str) -> Component {
356 Component {
357 id: ComponentId(id.to_string()),
358 name: id.split("::").last().unwrap_or(id).to_string(),
359 kind: ComponentKind::Entity(EntityInfo {
360 name: id.to_string(),
361 fields: vec![],
362 methods: vec![],
363 is_active_record: false,
364 is_anemic_domain_model: false,
365 }),
366 layer: Some(ArchLayer::Domain),
367 location: SourceLocation {
368 file: PathBuf::from("test.go"),
369 line: 1,
370 column: 1,
371 },
372 is_cross_cutting: false,
373 architecture_mode: ArchitectureMode::Ddd,
374 }
375 }
376
377 fn make_dep(from: &str, to: &str, import_path: &str) -> Dependency {
378 Dependency {
379 from: ComponentId(from.to_string()),
380 to: ComponentId(to.to_string()),
381 kind: DependencyKind::Import,
382 location: SourceLocation {
383 file: PathBuf::from("test.go"),
384 line: 1,
385 column: 1,
386 },
387 import_path: Some(import_path.to_string()),
388 }
389 }
390
391 #[test]
392 fn ddd_hexagonal_confidence_high_for_layered_project_with_ports() {
393 let components = vec![
395 make_interface("/project/domain::UserRepository"),
396 make_struct("/project/domain::User"),
397 make_struct("/project/application::UserService"),
398 make_struct("/project/infrastructure::UserRepo"),
399 ];
400 let deps = vec![
401 make_dep(
402 "/project/application::UserService",
403 "/project/domain::UserRepository",
404 "example/domain",
405 ),
406 make_dep(
407 "/project/infrastructure::UserRepo",
408 "/project/domain::UserRepository",
409 "example/domain",
410 ),
411 ];
412 let pd = detect_patterns(&components, &deps);
413 let conf = pd
414 .patterns
415 .iter()
416 .find(|p| p.name == "ddd-hexagonal")
417 .map(|p| p.confidence)
418 .unwrap();
419 assert!(
420 conf >= 0.5,
421 "ddd-hexagonal should be >= 0.5 for a layered project, got {conf}"
422 );
423 assert_eq!(pd.top_pattern, "ddd-hexagonal");
424 }
425
426 #[test]
427 fn flat_crud_confidence_high_for_single_package() {
428 let components = vec![
429 make_struct("/project/flat::Product"),
430 make_struct("/project/flat::Order"),
431 make_struct("/project/flat::Customer"),
432 ];
433 let pd = detect_patterns(&components, &[]);
434 let conf = pd
435 .patterns
436 .iter()
437 .find(|p| p.name == "flat-crud")
438 .map(|p| p.confidence)
439 .unwrap();
440 assert!(
441 conf >= 0.5,
442 "flat-crud should be >= 0.5 for a single-package all-concrete project, got {conf}"
443 );
444 }
445
446 #[test]
447 fn anemic_domain_confidence_high_when_domain_has_no_interfaces() {
448 let components = vec![
450 make_struct("/project/domain::Order"),
451 make_struct("/project/domain::Customer"),
452 make_struct("/project/services::OrderService"),
453 ];
454 let deps = vec![make_dep(
455 "/project/services::OrderService",
456 "/project/domain::Order",
457 "example/domain",
458 )];
459 let pd = detect_patterns(&components, &deps);
460 let conf = pd
461 .patterns
462 .iter()
463 .find(|p| p.name == "anemic-domain")
464 .map(|p| p.confidence)
465 .unwrap();
466 assert!(
467 conf >= 0.5,
468 "anemic-domain should be >= 0.5 for a domain with no interfaces, got {conf}"
469 );
470 }
471
472 #[test]
473 fn all_confidences_below_threshold_for_structurally_neutral_project() {
474 let components = vec![
476 make_struct("/project/alpha::Foo"),
477 make_struct("/project/alpha::Bar"),
478 make_struct("/project/beta::Qux"),
479 make_struct("/project/beta::Baz"),
480 ];
481 let pd = detect_patterns(&components, &[]);
482 let max_conf = pd
483 .patterns
484 .iter()
485 .map(|p| p.confidence)
486 .fold(0.0_f64, f64::max);
487 assert!(
488 max_conf < 0.5,
489 "all confidences should be < 0.5 for a structurally neutral project, got max {max_conf}"
490 );
491 }
492
493 #[test]
494 fn transition_project_has_multiple_nonzero_patterns() {
495 let components = vec![
497 make_struct("/project/domain::Order"),
498 make_struct("/project/domain::Customer"),
499 make_struct("/project/domain::Product"),
500 make_struct("/project/infrastructure::OrderRepo"),
501 make_struct("/project/infrastructure::CustomerRepo"),
502 ];
503 let deps = vec![
504 make_dep(
505 "/project/infrastructure::OrderRepo",
506 "/project/domain::Order",
507 "example/domain",
508 ),
509 make_dep(
510 "/project/infrastructure::CustomerRepo",
511 "/project/domain::Customer",
512 "example/domain",
513 ),
514 ];
515 let pd = detect_patterns(&components, &deps);
516 let nonzero = pd.patterns.iter().filter(|p| p.confidence > 0.0).count();
517 assert!(
518 nonzero > 1,
519 "a transition project should have more than one pattern above 0.0, got {nonzero}"
520 );
521 }
522
523 #[test]
524 fn output_always_contains_all_five_patterns() {
525 let pd = detect_patterns(&[], &[]);
526 let names: Vec<&str> = pd.patterns.iter().map(|p| p.name.as_str()).collect();
527 for expected in [
528 "ddd-hexagonal",
529 "active-record",
530 "flat-crud",
531 "anemic-domain",
532 "service-layer",
533 ] {
534 assert!(names.contains(&expected), "missing pattern '{expected}'");
535 }
536 }
537
538 #[test]
539 fn all_confidence_values_in_range() {
540 let pd = detect_patterns(&[], &[]);
541 for p in &pd.patterns {
542 assert!(
543 (0.0..=1.0).contains(&p.confidence),
544 "confidence for '{}' out of range: {}",
545 p.name,
546 p.confidence
547 );
548 }
549 }
550}