use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::types::{Component, ComponentKind, Dependency};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatternScore {
pub name: String,
pub confidence: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatternDetection {
pub patterns: Vec<PatternScore>,
pub top_pattern: String,
pub top_confidence: f64,
}
struct Signals {
pkg_count: usize,
has_domain_layer: bool,
has_app_layer: bool,
has_infra_layer: bool,
layer_name_count: usize,
total_interfaces: usize,
domain_interfaces: usize,
domain_structs: usize,
domain_is_imported: bool,
domain_imports_nothing: bool,
has_any_internal_deps: bool,
}
pub fn detect_patterns(components: &[Component], dependencies: &[Dependency]) -> PatternDetection {
let signals = extract_signals(components, dependencies);
let mut patterns = vec![
PatternScore {
name: "ddd-hexagonal".to_string(),
confidence: ddd_hexagonal(&signals),
},
PatternScore {
name: "active-record".to_string(),
confidence: active_record(&signals),
},
PatternScore {
name: "flat-crud".to_string(),
confidence: flat_crud(&signals),
},
PatternScore {
name: "anemic-domain".to_string(),
confidence: anemic_domain(&signals),
},
PatternScore {
name: "service-layer".to_string(),
confidence: service_layer(&signals),
},
];
patterns.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
let top_pattern = patterns
.first()
.map(|p| p.name.clone())
.unwrap_or_else(|| "unknown".to_string());
let top_confidence = patterns.first().map(|p| p.confidence).unwrap_or(0.0);
patterns.sort_by(|a, b| a.name.cmp(&b.name));
PatternDetection {
patterns,
top_pattern,
top_confidence,
}
}
fn pkg_from_id(id: &str) -> &str {
id.split("::").next().unwrap_or("")
}
fn path_contains_layer(path: &str, layer: &str) -> bool {
path.split(['/', '.', ':']).any(|seg| seg == layer)
}
fn import_layer(path: &str) -> Option<&'static str> {
["domain", "application", "infrastructure", "app", "infra"]
.iter()
.find(|&&layer| path_contains_layer(path, layer))
.copied()
}
fn canonical_layer(layer: &str) -> &str {
match layer {
"app" => "application",
"infra" => "infrastructure",
other => other,
}
}
fn extract_signals(components: &[Component], dependencies: &[Dependency]) -> Signals {
let mut pkg_paths: HashSet<String> = HashSet::new();
for comp in components {
let pkg = pkg_from_id(&comp.id.0);
if !pkg.is_empty() {
pkg_paths.insert(pkg.to_string());
}
}
let pkg_count = pkg_paths.len();
let has_domain_layer = pkg_paths.iter().any(|p| path_contains_layer(p, "domain"));
let has_app_layer = pkg_paths
.iter()
.any(|p| path_contains_layer(p, "application") || path_contains_layer(p, "app"));
let has_infra_layer = pkg_paths
.iter()
.any(|p| path_contains_layer(p, "infrastructure") || path_contains_layer(p, "infra"));
let layer_name_count = [has_domain_layer, has_app_layer, has_infra_layer]
.iter()
.filter(|&&x| x)
.count();
let mut total_interfaces = 0usize;
let mut domain_interfaces = 0usize;
let mut domain_structs = 0usize;
for comp in components {
let full_pkg = pkg_from_id(&comp.id.0);
let in_domain = path_contains_layer(full_pkg, "domain");
let is_interface = matches!(comp.kind, ComponentKind::Port(_));
if is_interface {
total_interfaces += 1;
}
if in_domain {
if is_interface {
domain_interfaces += 1;
} else {
domain_structs += 1;
}
}
}
let mut has_any_internal_deps = false;
let mut domain_has_afferent = false; let mut domain_has_efferent = false;
for dep in dependencies {
let from_pkg = pkg_from_id(&dep.from.0);
let Some(from_layer) = import_layer(from_pkg) else {
continue;
};
let Some(to_layer) = dep.import_path.as_deref().and_then(import_layer) else {
continue;
};
let from_c = canonical_layer(from_layer);
let to_c = canonical_layer(to_layer);
if from_c == to_c {
continue; }
has_any_internal_deps = true;
if to_c == "domain" {
domain_has_afferent = true;
}
if from_c == "domain" {
domain_has_efferent = true;
}
}
let domain_is_imported = domain_has_afferent;
let domain_imports_nothing = !domain_has_efferent;
Signals {
pkg_count,
has_domain_layer,
has_app_layer,
has_infra_layer,
layer_name_count,
total_interfaces,
domain_interfaces,
domain_structs,
domain_is_imported,
domain_imports_nothing,
has_any_internal_deps,
}
}
fn ddd_hexagonal(s: &Signals) -> f64 {
let mut score = 0.0_f64;
if s.has_domain_layer {
score += 0.20;
}
if s.has_app_layer {
score += 0.15;
}
if s.has_infra_layer {
score += 0.15;
}
if s.domain_interfaces > 0 {
score += 0.20;
}
if s.domain_is_imported && s.domain_imports_nothing {
score += 0.20;
}
let total_domain = s.domain_interfaces + s.domain_structs;
if total_domain > 0 {
let ratio = s.domain_interfaces as f64 / total_domain as f64;
if ratio >= 0.25 {
score += 0.10;
}
}
score.clamp(0.0, 1.0)
}
fn active_record(_s: &Signals) -> f64 {
0.0
}
fn flat_crud(s: &Signals) -> f64 {
let mut score = 0.0_f64;
if s.pkg_count == 1 {
score += 0.55;
} else if s.pkg_count == 2 && !s.has_any_internal_deps {
score += 0.05;
}
if s.total_interfaces == 0 {
score += 0.20;
}
if s.layer_name_count == 0 {
score += 0.15;
}
score.clamp(0.0, 1.0)
}
fn anemic_domain(s: &Signals) -> f64 {
if !s.has_domain_layer {
return 0.0;
}
let mut score = 0.0_f64;
if s.domain_interfaces == 0 && s.domain_structs > 0 {
score += 0.40; }
if s.domain_is_imported {
score += 0.30; }
if s.total_interfaces == 0 {
score += 0.20; }
if !s.has_app_layer {
score += 0.10; }
score.clamp(0.0, 1.0)
}
fn service_layer(s: &Signals) -> f64 {
let mut score = 0.0_f64;
if s.pkg_count >= 2 {
score += 0.20;
}
if s.has_any_internal_deps {
score += 0.30;
}
if s.total_interfaces == 0 && s.has_any_internal_deps {
score += 0.20; }
if !s.has_domain_layer && !s.has_infra_layer && s.pkg_count >= 2 {
score += 0.10; }
score.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
ArchLayer, ArchitectureMode, ComponentId, ComponentKind, DependencyKind, EntityInfo,
PortInfo, SourceLocation,
};
use std::path::PathBuf;
fn make_interface(id: &str) -> Component {
Component {
id: ComponentId(id.to_string()),
name: id.split("::").last().unwrap_or(id).to_string(),
kind: ComponentKind::Port(PortInfo {
name: id.to_string(),
methods: vec![],
}),
layer: None,
location: SourceLocation {
file: PathBuf::from("test.go"),
line: 1,
column: 1,
},
is_cross_cutting: false,
architecture_mode: ArchitectureMode::Ddd,
}
}
fn make_struct(id: &str) -> Component {
Component {
id: ComponentId(id.to_string()),
name: id.split("::").last().unwrap_or(id).to_string(),
kind: ComponentKind::Entity(EntityInfo {
name: id.to_string(),
fields: vec![],
methods: vec![],
is_active_record: false,
}),
layer: Some(ArchLayer::Domain),
location: SourceLocation {
file: PathBuf::from("test.go"),
line: 1,
column: 1,
},
is_cross_cutting: false,
architecture_mode: ArchitectureMode::Ddd,
}
}
fn make_dep(from: &str, to: &str, import_path: &str) -> Dependency {
Dependency {
from: ComponentId(from.to_string()),
to: ComponentId(to.to_string()),
kind: DependencyKind::Import,
location: SourceLocation {
file: PathBuf::from("test.go"),
line: 1,
column: 1,
},
import_path: Some(import_path.to_string()),
}
}
#[test]
fn ddd_hexagonal_confidence_high_for_layered_project_with_ports() {
let components = vec![
make_interface("/project/domain::UserRepository"),
make_struct("/project/domain::User"),
make_struct("/project/application::UserService"),
make_struct("/project/infrastructure::UserRepo"),
];
let deps = vec![
make_dep(
"/project/application::UserService",
"/project/domain::UserRepository",
"example/domain",
),
make_dep(
"/project/infrastructure::UserRepo",
"/project/domain::UserRepository",
"example/domain",
),
];
let pd = detect_patterns(&components, &deps);
let conf = pd
.patterns
.iter()
.find(|p| p.name == "ddd-hexagonal")
.map(|p| p.confidence)
.unwrap();
assert!(
conf >= 0.5,
"ddd-hexagonal should be >= 0.5 for a layered project, got {conf}"
);
assert_eq!(pd.top_pattern, "ddd-hexagonal");
}
#[test]
fn flat_crud_confidence_high_for_single_package() {
let components = vec![
make_struct("/project/flat::Product"),
make_struct("/project/flat::Order"),
make_struct("/project/flat::Customer"),
];
let pd = detect_patterns(&components, &[]);
let conf = pd
.patterns
.iter()
.find(|p| p.name == "flat-crud")
.map(|p| p.confidence)
.unwrap();
assert!(
conf >= 0.5,
"flat-crud should be >= 0.5 for a single-package all-concrete project, got {conf}"
);
}
#[test]
fn anemic_domain_confidence_high_when_domain_has_no_interfaces() {
let components = vec![
make_struct("/project/domain::Order"),
make_struct("/project/domain::Customer"),
make_struct("/project/services::OrderService"),
];
let deps = vec![make_dep(
"/project/services::OrderService",
"/project/domain::Order",
"example/domain",
)];
let pd = detect_patterns(&components, &deps);
let conf = pd
.patterns
.iter()
.find(|p| p.name == "anemic-domain")
.map(|p| p.confidence)
.unwrap();
assert!(
conf >= 0.5,
"anemic-domain should be >= 0.5 for a domain with no interfaces, got {conf}"
);
}
#[test]
fn all_confidences_below_threshold_for_structurally_neutral_project() {
let components = vec![
make_struct("/project/alpha::Foo"),
make_struct("/project/alpha::Bar"),
make_struct("/project/beta::Qux"),
make_struct("/project/beta::Baz"),
];
let pd = detect_patterns(&components, &[]);
let max_conf = pd
.patterns
.iter()
.map(|p| p.confidence)
.fold(0.0_f64, f64::max);
assert!(
max_conf < 0.5,
"all confidences should be < 0.5 for a structurally neutral project, got max {max_conf}"
);
}
#[test]
fn transition_project_has_multiple_nonzero_patterns() {
let components = vec![
make_struct("/project/domain::Order"),
make_struct("/project/domain::Customer"),
make_struct("/project/domain::Product"),
make_struct("/project/infrastructure::OrderRepo"),
make_struct("/project/infrastructure::CustomerRepo"),
];
let deps = vec![
make_dep(
"/project/infrastructure::OrderRepo",
"/project/domain::Order",
"example/domain",
),
make_dep(
"/project/infrastructure::CustomerRepo",
"/project/domain::Customer",
"example/domain",
),
];
let pd = detect_patterns(&components, &deps);
let nonzero = pd.patterns.iter().filter(|p| p.confidence > 0.0).count();
assert!(
nonzero > 1,
"a transition project should have more than one pattern above 0.0, got {nonzero}"
);
}
#[test]
fn output_always_contains_all_five_patterns() {
let pd = detect_patterns(&[], &[]);
let names: Vec<&str> = pd.patterns.iter().map(|p| p.name.as_str()).collect();
for expected in [
"ddd-hexagonal",
"active-record",
"flat-crud",
"anemic-domain",
"service-layer",
] {
assert!(names.contains(&expected), "missing pattern '{expected}'");
}
}
#[test]
fn all_confidence_values_in_range() {
let pd = detect_patterns(&[], &[]);
for p in &pd.patterns {
assert!(
(0.0..=1.0).contains(&p.confidence),
"confidence for '{}' out of range: {}",
p.name,
p.confidence
);
}
}
}