use crate::analysis::cross_service::{CrossServiceDetector, ServiceProtocol};
use crate::storage::capability::Storage;
use crate::storage::error::Result as StorageResult;
use crate::storage::schema::escape_cypher_string;
use serde::Serialize;
const DEFAULT_ENTRY_PATTERNS: &[&str] = &["main", "Main", "__main__"];
const HOTSPOT_LIMIT: usize = 10;
const PACKAGE_TABLES: &[&str] = &[
"Function",
"Method",
"Class",
"Struct",
"Enum",
"Trait",
"Interface",
"Namespace",
];
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ArchitectureOverview {
pub languages: Vec<LanguageStat>,
pub packages: Vec<PackageStat>,
pub entry_points: Vec<EntryPoint>,
pub routes: Vec<RouteStat>,
pub hotspots: Vec<HotspotStat>,
pub module_boundaries: Vec<ModuleBoundary>,
pub dependency_directions: Vec<DepDirection>,
pub layers: Vec<LayerInfo>,
pub cross_service_deps: Vec<CrossServiceDep>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct LanguageStat {
pub language: String,
pub file_count: u32,
pub symbol_count: u32,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PackageStat {
pub package: String,
pub symbol_count: u32,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct EntryPoint {
pub name: String,
pub qualified_name: String,
pub file_path: String,
pub line: u32,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct RouteStat {
pub path: String,
pub method: String,
pub handler: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct HotspotStat {
pub name: String,
pub qualified_name: String,
pub caller_count: u32,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ModuleBoundary {
pub module_name: String,
pub members: Vec<String>,
pub incoming_deps: u32,
pub outgoing_deps: u32,
pub cohesion: f64,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DepDirection {
pub from_module: String,
pub to_module: String,
pub is_circular: bool,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct LayerInfo {
pub layer: String,
pub members: Vec<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CrossServiceDep {
pub from_module: String,
pub to_module: String,
pub protocol: String,
}
pub struct ArchitectureAnalyzer<'a> {
storage: &'a dyn Storage,
}
impl<'a> ArchitectureAnalyzer<'a> {
#[must_use]
pub fn new(storage: &'a dyn Storage) -> Self {
Self { storage }
}
pub fn overview(&self, project: &str) -> StorageResult<ArchitectureOverview> {
let languages = self.load_languages(project)?;
let packages = self.load_packages(project)?;
let entry_points = self.load_entry_points(project)?;
let routes = self.load_routes(project)?;
let hotspots = self.load_hotspots(project)?;
let module_boundaries = self.detect_module_boundaries(project)?;
let dependency_directions = self.analyze_dependency_directions(project)?;
let layers = self.detect_layers(project)?;
let cross_service_deps = self.load_cross_service_deps(project)?;
Ok(ArchitectureOverview {
languages,
packages,
entry_points,
routes,
hotspots,
module_boundaries,
dependency_directions,
layers,
cross_service_deps,
})
}
fn load_languages(&self, project: &str) -> StorageResult<Vec<LanguageStat>> {
let escaped = escape_cypher_string(project);
let file_cypher = format!(
"MATCH (f:File) WHERE f.project = '{escaped}' \
RETURN f.language AS language, f.filePath AS file_path;"
);
let file_rows = self.storage.query(&file_cypher)?;
let mut lang_file_count: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
let mut path_to_lang: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for row in file_rows {
if row.len() < 2 {
continue;
}
let language = row[0].as_str().unwrap_or_default().to_string();
let file_path = row[1].as_str().unwrap_or_default().to_string();
*lang_file_count.entry(language.clone()).or_insert(0) += 1;
path_to_lang.insert(file_path, language);
}
let mut lang_symbol_count: std::collections::HashMap<String, u32> =
std::collections::HashMap::new(); for table in &["Function", "Method"] {
let cypher = format!(
"MATCH (n:{table}) WHERE n.project = '{escaped}' \
RETURN n.filePath AS file_path;"
);
let rows = self.storage.query(&cypher)?;
for row in rows {
if let Some(path) = row.first().and_then(|v| v.as_str()) {
if let Some(lang) = path_to_lang.get(path) {
*lang_symbol_count.entry(lang.clone()).or_insert(0) += 1;
}
}
}
}
let mut result: Vec<LanguageStat> = lang_file_count
.into_iter()
.map(|(language, file_count)| LanguageStat {
file_count,
symbol_count: *lang_symbol_count.get(&language).unwrap_or(&0),
language,
})
.collect();
result.sort_by(|a, b| a.language.cmp(&b.language));
Ok(result)
}
fn load_packages(&self, project: &str) -> StorageResult<Vec<PackageStat>> {
let escaped = escape_cypher_string(project);
let mut pkg_counts: std::collections::HashMap<String, u32> =
std::collections::HashMap::new(); for table in PACKAGE_TABLES {
let cypher = format!(
"MATCH (n:{table}) WHERE n.project = '{escaped}' \
RETURN n.qualifiedName AS qualified_name;"
);
let rows = self.storage.query(&cypher)?;
for row in rows {
if let Some(qn) = row.first().and_then(|v| v.as_str()) {
if let Some(pkg) = package_prefix(qn) {
*pkg_counts.entry(pkg.to_string()).or_insert(0) += 1;
}
}
}
}
let mut result: Vec<PackageStat> = pkg_counts
.into_iter()
.map(|(package, symbol_count)| PackageStat {
package,
symbol_count,
})
.collect();
result.sort_by(|a, b| {
b.symbol_count
.cmp(&a.symbol_count)
.then_with(|| a.package.cmp(&b.package))
});
Ok(result)
}
fn load_entry_points(&self, project: &str) -> StorageResult<Vec<EntryPoint>> {
let escaped = escape_cypher_string(project);
let cypher = format!(
"MATCH (f:Function) WHERE f.project = '{escaped}' \
RETURN f.name AS name, f.qualifiedName AS qualified_name, \
f.filePath AS file_path, f.startLine AS start_line;"
);
let rows = self.storage.query(&cypher)?;
let mut result = Vec::new();
for row in rows {
if row.len() < 4 {
continue;
}
let name = row[0].as_str().unwrap_or_default().to_string();
if !DEFAULT_ENTRY_PATTERNS.contains(&name.as_str()) {
continue;
}
let qualified_name = row[1].as_str().unwrap_or_default().to_string();
let file_path = row[2].as_str().unwrap_or_default().to_string();
let line = row[3]
.as_i64()
.map(|v| v as u32)
.or_else(|| row[3].as_u64().map(|v| v as u32))
.unwrap_or(0);
result.push(EntryPoint {
name,
qualified_name,
file_path,
line,
});
}
result.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
Ok(result)
}
fn load_routes(&self, project: &str) -> StorageResult<Vec<RouteStat>> {
let escaped = escape_cypher_string(project);
let route_cypher = format!(
"MATCH (r:Route) WHERE r.project = '{escaped}' \
RETURN r.id AS id, r.path AS path, r.httpMethod AS method;"
);
let route_rows = self.storage.query(&route_cypher)?;
let mut routes: Vec<(String, String, String)> = Vec::new(); for row in route_rows {
if row.len() < 3 {
continue;
}
let id = row[0].as_str().unwrap_or_default().to_string();
let path = row[1].as_str().unwrap_or_default().to_string();
let method = row[2].as_str().unwrap_or_default().to_string();
routes.push((id, path, method));
}
let handler_cypher = format!(
"MATCH (h:Handler) WHERE h.project = '{escaped}' \
RETURN h.id AS id, h.name AS name;"
);
let handler_rows = self.storage.query(&handler_cypher)?;
let mut handlers: std::collections::HashMap<String, String> =
std::collections::HashMap::new(); for row in handler_rows {
if row.len() < 2 {
continue;
}
let id = row[0].as_str().unwrap_or_default().to_string();
let name = row[1].as_str().unwrap_or_default().to_string();
handlers.insert(id, name);
}
let edge_cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'HANDLES' AND e.project = '{escaped}' \
RETURN e.source AS source, e.target AS target;"
);
let edge_rows = self.storage.query(&edge_cypher)?;
let mut route_to_handler: std::collections::HashMap<String, String> =
std::collections::HashMap::new(); for row in edge_rows {
if row.len() < 2 {
continue;
}
let source = row[0].as_str().unwrap_or_default().to_string();
let target = row[1].as_str().unwrap_or_default().to_string();
if let Some(handler_name) = handlers.get(&source) {
route_to_handler.insert(target, handler_name.clone());
}
}
let mut result: Vec<RouteStat> = routes
.into_iter()
.map(|(id, path, method)| RouteStat {
path,
method,
handler: route_to_handler.get(&id).cloned().unwrap_or_default(),
})
.collect();
result.sort_by(|a, b| a.path.cmp(&b.path));
Ok(result)
}
fn load_hotspots(&self, project: &str) -> StorageResult<Vec<HotspotStat>> {
let escaped = escape_cypher_string(project);
let calls_cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'CALLS' AND e.project = '{escaped}' \
RETURN e.target AS target;"
);
let calls_rows = self.storage.query(&calls_cypher)?;
let mut caller_counts: std::collections::HashMap<String, u32> =
std::collections::HashMap::new(); for row in calls_rows {
if let Some(target) = row.first().and_then(|v| v.as_str()) {
*caller_counts.entry(target.to_string()).or_insert(0) += 1;
}
}
let mut id_to_info: std::collections::HashMap<String, (String, String)> =
std::collections::HashMap::new(); for table in &["Function", "Method"] {
let cypher = format!(
"MATCH (n:{table}) WHERE n.project = '{escaped}' \
RETURN n.id AS id, n.name AS name, n.qualifiedName AS qualified_name;"
);
let rows = self.storage.query(&cypher)?;
for row in rows {
if row.len() < 3 {
continue;
}
let id = row[0].as_str().unwrap_or_default().to_string();
let name = row[1].as_str().unwrap_or_default().to_string();
let qn = row[2].as_str().unwrap_or_default().to_string();
id_to_info.insert(id, (name, qn));
}
}
let mut result: Vec<HotspotStat> = caller_counts
.into_iter()
.filter_map(|(id, count)| {
id_to_info.get(&id).map(|(name, qn)| HotspotStat {
name: name.clone(),
qualified_name: qn.clone(),
caller_count: count,
})
})
.collect();
result.sort_by(|a, b| {
b.caller_count
.cmp(&a.caller_count)
.then_with(|| a.qualified_name.cmp(&b.qualified_name))
});
result.truncate(HOTSPOT_LIMIT);
Ok(result)
}
fn detect_module_boundaries(&self, project: &str) -> StorageResult<Vec<ModuleBoundary>> {
let escaped = escape_cypher_string(project);
let mut id_to_path: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut module_files: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for table in &["Function", "Method"] {
let cypher = format!(
"MATCH (n:{table}) WHERE n.project = '{escaped}' \
RETURN n.id AS id, n.filePath AS file_path;"
);
let rows = self.storage.query(&cypher)?;
for row in rows {
if row.len() < 2 {
continue;
}
let id = row[0].as_str().unwrap_or_default().to_string();
let file_path = row[1].as_str().unwrap_or_default().to_string();
id_to_path.insert(id, file_path.clone());
let module_name = module_name_from_path(&file_path);
module_files.entry(module_name).or_default().push(file_path);
}
}
for files in module_files.values_mut() {
files.sort();
files.dedup();
}
let calls_cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'CALLS' AND e.project = '{escaped}' \
RETURN e.source AS source, e.target AS target;"
);
let calls_rows = self.storage.query(&calls_cypher)?;
let mut internal: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
let mut incoming: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
let mut outgoing: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for row in calls_rows {
if row.len() < 2 {
continue;
}
let source_id = row[0].as_str().unwrap_or_default().to_string();
let target_id = row[1].as_str().unwrap_or_default().to_string();
let source_path = match id_to_path.get(&source_id) {
Some(p) => p,
None => continue,
};
let target_path = match id_to_path.get(&target_id) {
Some(p) => p,
None => continue,
};
let source_module = module_name_from_path(source_path);
let target_module = module_name_from_path(target_path);
if source_module == target_module {
*internal.entry(source_module).or_insert(0) += 1;
} else {
*outgoing.entry(source_module).or_insert(0) += 1;
*incoming.entry(target_module).or_insert(0) += 1;
}
}
let mut result: Vec<ModuleBoundary> = module_files
.into_iter()
.map(|(module_name, members)| {
let internal_count = *internal.get(&module_name).unwrap_or(&0);
let incoming_count = *incoming.get(&module_name).unwrap_or(&0);
let outgoing_count = *outgoing.get(&module_name).unwrap_or(&0);
let total = internal_count + incoming_count + outgoing_count;
let cohesion = if total == 0 {
1.0
} else {
f64::from(internal_count) / f64::from(total)
};
ModuleBoundary {
module_name,
members,
incoming_deps: incoming_count,
outgoing_deps: outgoing_count,
cohesion,
}
})
.collect();
result.sort_by(|a, b| a.module_name.cmp(&b.module_name));
Ok(result)
}
fn analyze_dependency_directions(&self, project: &str) -> StorageResult<Vec<DepDirection>> {
let escaped = escape_cypher_string(project);
let mut id_to_path: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for table in &["Function", "Method"] {
let cypher = format!(
"MATCH (n:{table}) WHERE n.project = '{escaped}' \
RETURN n.id AS id, n.filePath AS file_path;"
);
let rows = self.storage.query(&cypher)?;
for row in rows {
if row.len() < 2 {
continue;
}
let id = row[0].as_str().unwrap_or_default().to_string();
let file_path = row[1].as_str().unwrap_or_default().to_string();
id_to_path.insert(id, file_path);
}
}
let calls_cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'CALLS' AND e.project = '{escaped}' \
RETURN e.source AS source, e.target AS target;"
);
let calls_rows = self.storage.query(&calls_cypher)?;
let mut adj: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
let mut edges: std::collections::HashSet<(String, String)> =
std::collections::HashSet::new();
for row in calls_rows {
if row.len() < 2 {
continue;
}
let source_id = row[0].as_str().unwrap_or_default().to_string();
let target_id = row[1].as_str().unwrap_or_default().to_string();
let source_path = match id_to_path.get(&source_id) {
Some(p) => p,
None => continue,
};
let target_path = match id_to_path.get(&target_id) {
Some(p) => p,
None => continue,
};
let source_module = module_name_from_path(source_path);
let target_module = module_name_from_path(target_path);
if source_module == target_module {
continue;
}
adj.entry(source_module.clone())
.or_default()
.insert(target_module.clone());
edges.insert((source_module, target_module));
}
let mut result: Vec<DepDirection> = edges
.into_iter()
.map(|(from, to)| {
let is_circular = can_reach(&adj, &to, &from);
DepDirection {
from_module: from,
to_module: to,
is_circular,
}
})
.collect();
result.sort_by(|a, b| {
a.from_module
.cmp(&b.from_module)
.then_with(|| a.to_module.cmp(&b.to_module))
});
Ok(result)
}
fn detect_layers(&self, project: &str) -> StorageResult<Vec<LayerInfo>> {
let escaped = escape_cypher_string(project);
let mut func_id_to_qn: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for table in &["Function", "Method"] {
let cypher = format!(
"MATCH (n:{table}) WHERE n.project = '{escaped}' \
RETURN n.id AS id, n.qualifiedName AS qualified_name;"
);
let rows = self.storage.query(&cypher)?;
for row in rows {
if row.len() < 2 {
continue;
}
let id = row[0].as_str().unwrap_or_default().to_string();
let qn = row[1].as_str().unwrap_or_default().to_string();
func_id_to_qn.insert(id, qn);
}
}
let mut type_id_to_qn: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for table in &["Class", "Struct", "Enum", "Trait", "Interface"] {
let cypher = format!(
"MATCH (n:{table}) WHERE n.project = '{escaped}' \
RETURN n.id AS id, n.qualifiedName AS qualified_name;"
);
let rows = self.storage.query(&cypher)?;
for row in rows {
if row.len() < 2 {
continue;
}
let id = row[0].as_str().unwrap_or_default().to_string();
let qn = row[1].as_str().unwrap_or_default().to_string();
type_id_to_qn.insert(id, qn);
}
}
let handles_cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'HANDLES_ROUTE' AND e.project = '{escaped}' \
RETURN e.source AS source;"
);
let handles_rows = self.storage.query(&handles_cypher)?;
let mut controller_ids: std::collections::HashSet<String> =
std::collections::HashSet::new();
for row in handles_rows {
if let Some(source) = row.first().and_then(|v| v.as_str()) {
controller_ids.insert(source.to_string());
}
}
let calls_cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'CALLS' AND e.project = '{escaped}' \
RETURN e.source AS source, e.target AS target;"
);
let calls_rows = self.storage.query(&calls_cypher)?;
let mut calls_from: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
let mut all_calls_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for row in calls_rows {
if row.len() < 2 {
continue;
}
let source = row[0].as_str().unwrap_or_default().to_string();
let target = row[1].as_str().unwrap_or_default().to_string();
calls_from
.entry(source.clone())
.or_default()
.insert(target.clone());
all_calls_ids.insert(source);
all_calls_ids.insert(target);
}
let fetches_cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'FETCHES' AND e.project = '{escaped}' \
RETURN e.source AS source;"
);
let fetches_rows = self.storage.query(&fetches_cypher)?;
let mut repository_ids: std::collections::HashSet<String> =
std::collections::HashSet::new();
for row in fetches_rows {
if let Some(source) = row.first().and_then(|v| v.as_str()) {
repository_ids.insert(source.to_string());
}
}
let has_prop_cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'HAS_PROPERTY' AND e.project = '{escaped}' \
RETURN e.source AS source;"
);
let has_prop_rows = self.storage.query(&has_prop_cypher)?;
let mut model_candidate_ids: std::collections::HashSet<String> =
std::collections::HashSet::new();
for row in has_prop_rows {
if let Some(source) = row.first().and_then(|v| v.as_str()) {
model_candidate_ids.insert(source.to_string());
}
}
let mut called_by_controllers: std::collections::HashSet<String> =
std::collections::HashSet::new();
for controller_id in &controller_ids {
if let Some(targets) = calls_from.get(controller_id) {
for target in targets {
called_by_controllers.insert(target.clone());
}
}
}
let mut controllers: Vec<String> = Vec::new();
let mut services: Vec<String> = Vec::new();
let mut repositories: Vec<String> = Vec::new();
let mut models: Vec<String> = Vec::new();
for (id, qn) in &func_id_to_qn {
if controller_ids.contains(id) {
controllers.push(qn.clone());
} else if called_by_controllers.contains(id) {
services.push(qn.clone());
} else if repository_ids.contains(id) {
repositories.push(qn.clone());
}
}
for (id, qn) in &type_id_to_qn {
if model_candidate_ids.contains(id) && !all_calls_ids.contains(id) {
models.push(qn.clone());
}
}
controllers.sort();
services.sort();
repositories.sort();
models.sort();
let mut result = Vec::new();
if !controllers.is_empty() {
result.push(LayerInfo {
layer: "Controller".to_string(),
members: controllers,
});
}
if !services.is_empty() {
result.push(LayerInfo {
layer: "Service".to_string(),
members: services,
});
}
if !repositories.is_empty() {
result.push(LayerInfo {
layer: "Repository".to_string(),
members: repositories,
});
}
if !models.is_empty() {
result.push(LayerInfo {
layer: "Model".to_string(),
members: models,
});
}
Ok(result)
}
fn load_cross_service_deps(&self, project: &str) -> StorageResult<Vec<CrossServiceDep>> {
let detector = CrossServiceDetector::new(self.storage);
let matches = detector.detect_all(project)?;
let deps = matches
.into_iter()
.map(|m| CrossServiceDep {
from_module: m.caller,
to_module: m.callee,
protocol: protocol_to_string(&m.protocol),
})
.collect();
Ok(deps)
}
}
fn protocol_to_string(protocol: &ServiceProtocol) -> String {
match protocol {
ServiceProtocol::HttpRest => "HTTP".to_string(),
ServiceProtocol::Grpc => "gRPC".to_string(),
ServiceProtocol::GraphQL => "GraphQL".to_string(),
ServiceProtocol::MessageQueue => "MessageQueue".to_string(),
ServiceProtocol::EventBus => "EventBus".to_string(),
}
}
fn package_prefix(qualified_name: &str) -> Option<&str> {
let components: Vec<&str> = qualified_name.split('.').collect();
if components.len() < 2 {
return None;
}
let end = components[0].len() + 1 + components[1].len();
Some(&qualified_name[..end])
}
fn module_name_from_path(file_path: &str) -> String {
std::path::Path::new(file_path)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn can_reach(
adj: &std::collections::HashMap<String, std::collections::HashSet<String>>,
start: &str,
target: &str,
) -> bool {
if start == target {
return true;
}
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut stack: Vec<String> = vec![start.to_string()];
while let Some(node) = stack.pop() {
if !visited.insert(node.clone()) {
continue;
}
if let Some(neighbors) = adj.get(&node) {
for n in neighbors {
if n == target {
return true;
}
if !visited.contains(n) {
stack.push(n.clone());
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kit::{build_kit, AsyncKit, AsyncReady, KitBootstrapConfig, StorageModule};
use tempfile::TempDir;
fn fresh_db_path() -> std::path::PathBuf {
let dir = TempDir::new().unwrap();
let path = dir.path().join("arch_testdb");
std::mem::forget(dir);
path
}
fn build_kit_for_db(db: &std::path::Path) -> AsyncKit<AsyncReady> {
let config = KitBootstrapConfig::new(db.to_path_buf());
tokio::runtime::Runtime::new()
.unwrap()
.block_on(build_kit(&config))
.expect("build_kit")
}
fn storage(
kit: &AsyncKit<AsyncReady>,
) -> std::sync::Arc<dyn crate::storage::capability::Storage> {
kit.require::<StorageModule>().expect("require_storage")
}
fn create_file(
kit: &AsyncKit<AsyncReady>,
id: &str,
project: &str,
file_path: &str,
language: &str,
) {
let storage = storage(kit);
let cypher = format!(
"CREATE (:File {{id: '{}', project: '{}', name: '{}', filePath: '{}', \
language: '{}', hash: '', lineCount: 0}});",
escape_cypher_string(id),
escape_cypher_string(project),
escape_cypher_string(file_path.split('/').next_back().unwrap_or("file")),
escape_cypher_string(file_path),
escape_cypher_string(language),
);
storage.execute(&cypher).expect("create file");
}
fn create_function(
kit: &AsyncKit<AsyncReady>,
id: &str,
project: &str,
name: &str,
qn: &str,
file: &str,
line: u32,
) {
let storage = storage(kit);
let end_line = line + 10;
let cypher = format!(
"CREATE (:Function {{id: '{}', project: '{}', name: '{}', qualifiedName: '{}', \
filePath: '{}', startLine: {}, endLine: {}, signature: '', returnType: '', \
isExported: false, docstring: '', content: '', parentQn: ''}});",
escape_cypher_string(id),
escape_cypher_string(project),
escape_cypher_string(name),
escape_cypher_string(qn),
escape_cypher_string(file),
line,
end_line,
);
storage.execute(&cypher).expect("create function");
}
#[allow(clippy::too_many_arguments)]
fn create_function_with_content(
kit: &AsyncKit<AsyncReady>,
id: &str,
project: &str,
name: &str,
qn: &str,
file: &str,
line: u32,
content: &str,
) {
let storage = storage(kit);
let end_line = line + 10;
let cypher = format!(
"CREATE (:Function {{id: '{}', project: '{}', name: '{}', qualifiedName: '{}', \
filePath: '{}', startLine: {}, endLine: {}, signature: '', returnType: '', \
isExported: false, docstring: '', content: '{}', parentQn: ''}});",
escape_cypher_string(id),
escape_cypher_string(project),
escape_cypher_string(name),
escape_cypher_string(qn),
escape_cypher_string(file),
line,
end_line,
escape_cypher_string(content),
);
storage
.execute(&cypher)
.expect("create function with content");
}
fn create_method(
kit: &AsyncKit<AsyncReady>,
id: &str,
project: &str,
name: &str,
qn: &str,
file: &str,
line: u32,
) {
let storage = storage(kit);
let end_line = line + 10;
let cypher = format!(
"CREATE (:Method {{id: '{}', project: '{}', name: '{}', qualifiedName: '{}', \
filePath: '{}', startLine: {}, endLine: {}, signature: '', returnType: '', \
isExported: false, docstring: '', content: '', parameterCount: 0, parentQn: ''}});",
escape_cypher_string(id),
escape_cypher_string(project),
escape_cypher_string(name),
escape_cypher_string(qn),
escape_cypher_string(file),
line,
end_line,
);
storage.execute(&cypher).expect("create method");
}
fn create_class(
kit: &AsyncKit<AsyncReady>,
id: &str,
project: &str,
name: &str,
qn: &str,
file: &str,
line: u32,
) {
let storage = storage(kit);
let end_line = line + 10;
let cypher = format!(
"CREATE (:Class {{id: '{}', project: '{}', name: '{}', qualifiedName: '{}', \
filePath: '{}', startLine: {}, endLine: {}, isExported: false, docstring: '', \
content: '', parentQn: ''}});",
escape_cypher_string(id),
escape_cypher_string(project),
escape_cypher_string(name),
escape_cypher_string(qn),
escape_cypher_string(file),
line,
end_line,
);
storage.execute(&cypher).expect("create class");
}
fn create_route(kit: &AsyncKit<AsyncReady>, id: &str, project: &str, path: &str, method: &str) {
let storage = storage(kit);
let cypher = format!(
"CREATE (:Route {{id: '{}', project: '{}', name: '{}', qualifiedName: '{}', \
filePath: '', startLine: 0, endLine: 0, httpMethod: '{}', path: '{}', parentQn: ''}});",
escape_cypher_string(id),
escape_cypher_string(project),
escape_cypher_string(path),
escape_cypher_string(path),
escape_cypher_string(method),
escape_cypher_string(path),
);
storage.execute(&cypher).expect("create route");
}
fn create_handler(kit: &AsyncKit<AsyncReady>, id: &str, project: &str, name: &str) {
let storage = storage(kit);
let cypher = format!(
"CREATE (:Handler {{id: '{}', project: '{}', name: '{}', qualifiedName: '{}', \
filePath: '', startLine: 0, endLine: 0, signature: '', returnType: '', \
isExported: false, docstring: '', content: '', parentQn: ''}});",
escape_cypher_string(id),
escape_cypher_string(project),
escape_cypher_string(name),
escape_cypher_string(name),
);
storage.execute(&cypher).expect("create handler");
}
fn create_edge(
kit: &AsyncKit<AsyncReady>,
id: &str,
source: &str,
target: &str,
edge_type: &str,
project: &str,
) {
let storage = storage(kit);
let cypher = format!(
"CREATE (:CodeRelation {{id: '{}', source: '{}', target: '{}', type: '{}', \
confidence: 1.0, confidenceTier: 'High', reason: '', startLine: 1, project: '{}'}});",
escape_cypher_string(id),
escape_cypher_string(source),
escape_cypher_string(target),
escape_cypher_string(edge_type),
escape_cypher_string(project),
);
storage.execute(&cypher).expect("create edge");
}
#[test]
fn package_prefix_extracts_first_two_components() {
assert_eq!(package_prefix("com.example.Foo"), Some("com.example"));
assert_eq!(package_prefix("com.example.Bar"), Some("com.example"));
}
#[test]
fn package_prefix_two_components_returns_both() {
assert_eq!(package_prefix("demo.foo"), Some("demo.foo"));
}
#[test]
fn package_prefix_single_component_returns_none() {
assert_eq!(package_prefix("foo"), None);
}
#[test]
fn package_prefix_empty_returns_none() {
assert_eq!(package_prefix(""), None);
}
#[test]
fn overview_returns_empty_for_empty_db() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert!(result.languages.is_empty(), "languages should be empty");
assert!(result.packages.is_empty(), "packages should be empty");
assert!(
result.entry_points.is_empty(),
"entry_points should be empty"
);
assert!(result.routes.is_empty(), "routes should be empty");
assert!(result.hotspots.is_empty(), "hotspots should be empty");
}
#[test]
fn overview_counts_languages() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_file(&kit, "f1", "demo", "/src/main.rs", "rust");
create_file(&kit, "f2", "demo", "/src/app.py", "python");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.languages.len(), 2, "should have 2 languages");
let py = result
.languages
.iter()
.find(|l| l.language == "python")
.expect("python should be present");
assert_eq!(py.file_count, 1, "python file_count should be 1");
let rs = result
.languages
.iter()
.find(|l| l.language == "rust")
.expect("rust should be present");
assert_eq!(rs.file_count, 1, "rust file_count should be 1");
}
#[test]
fn overview_counts_symbols_per_language() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_file(&kit, "f1", "demo", "/src/main.rs", "rust");
create_file(&kit, "f2", "demo", "/src/app.py", "python");
create_function(&kit, "fn1", "demo", "foo", "demo.foo", "/src/main.rs", 1);
create_function(&kit, "fn2", "demo", "bar", "demo.bar", "/src/main.rs", 10);
create_function(&kit, "fn3", "demo", "baz", "demo.baz", "/src/app.py", 1);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let rs = result
.languages
.iter()
.find(|l| l.language == "rust")
.expect("rust should be present");
assert_eq!(rs.symbol_count, 2, "rust symbol_count should be 2");
let py = result
.languages
.iter()
.find(|l| l.language == "python")
.expect("python should be present");
assert_eq!(py.symbol_count, 1, "python symbol_count should be 1");
}
#[test]
fn overview_lists_entry_points() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(
&kit,
"f_main",
"demo",
"main",
"demo.main",
"/src/main.rs",
1,
);
create_function(&kit, "f_foo", "demo", "foo", "demo.foo", "/src/lib.rs", 1);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.entry_points.len(), 1, "should have 1 entry point");
let ep = &result.entry_points[0];
assert_eq!(ep.name, "main");
assert_eq!(ep.qualified_name, "demo.main");
assert_eq!(ep.file_path, "/src/main.rs");
assert_eq!(ep.line, 1);
}
#[test]
fn overview_lists_entry_points_multiple_patterns() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "f1", "demo", "main", "demo.main", "/src/main.rs", 1);
create_function(&kit, "f2", "demo", "Main", "demo.Main", "/src/Main.cs", 1);
create_function(
&kit,
"f3",
"demo",
"__main__",
"demo.__main__",
"/src/app.py",
1,
);
create_function(&kit, "f4", "demo", "foo", "demo.foo", "/src/lib.rs", 1);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.entry_points.len(), 3, "should have 3 entry points");
let names: Vec<&str> = result
.entry_points
.iter()
.map(|e| e.name.as_str())
.collect();
assert!(names.contains(&"main"));
assert!(names.contains(&"Main"));
assert!(names.contains(&"__main__"));
assert!(!names.contains(&"foo"));
}
#[test]
fn overview_lists_routes() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users", "GET");
create_handler(&kit, "h1", "demo", "list_users");
create_edge(&kit, "e1", "h1", "r1", "HANDLES", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.routes.len(), 1, "should have 1 route");
let route = &result.routes[0];
assert_eq!(route.path, "/api/users");
assert_eq!(route.method, "GET");
assert_eq!(route.handler, "list_users");
}
#[test]
fn overview_route_without_handler_has_empty_handler() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/orphan", "POST");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.routes.len(), 1, "should have 1 route");
assert_eq!(result.routes[0].handler, "", "handler should be empty");
}
#[test]
fn overview_identifies_hotspots() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "f_foo", "demo", "foo", "demo.foo", "/src/lib.rs", 1);
create_function(&kit, "f_a", "demo", "a", "demo.a", "/src/a.rs", 1);
create_function(&kit, "f_b", "demo", "b", "demo.b", "/src/b.rs", 1);
create_function(&kit, "f_c", "demo", "c", "demo.c", "/src/c.rs", 1);
create_edge(&kit, "e1", "f_a", "f_foo", "CALLS", "demo");
create_edge(&kit, "e2", "f_b", "f_foo", "CALLS", "demo");
create_edge(&kit, "e3", "f_c", "f_foo", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert!(!result.hotspots.is_empty(), "should have hotspots");
let foo = result
.hotspots
.iter()
.find(|h| h.name == "foo")
.expect("foo should be a hotspot");
assert_eq!(foo.caller_count, 3, "foo caller_count should be 3");
assert_eq!(foo.qualified_name, "demo.foo");
}
#[test]
fn overview_hotspots_sorted_by_caller_count_desc() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "f_foo", "demo", "foo", "demo.foo", "/src/lib.rs", 1);
create_function(&kit, "f_bar", "demo", "bar", "demo.bar", "/src/lib.rs", 10);
create_function(&kit, "f_a", "demo", "a", "demo.a", "/src/a.rs", 1);
create_function(&kit, "f_b", "demo", "b", "demo.b", "/src/b.rs", 1);
create_function(&kit, "f_c", "demo", "c", "demo.c", "/src/c.rs", 1);
create_edge(&kit, "e1", "f_a", "f_foo", "CALLS", "demo");
create_edge(&kit, "e2", "f_b", "f_foo", "CALLS", "demo");
create_edge(&kit, "e3", "f_c", "f_foo", "CALLS", "demo");
create_edge(&kit, "e4", "f_a", "f_bar", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert!(
result.hotspots.len() >= 2,
"should have at least 2 hotspots"
);
assert_eq!(result.hotspots[0].name, "foo");
assert_eq!(result.hotspots[0].caller_count, 3);
assert_eq!(result.hotspots[1].name, "bar");
assert_eq!(result.hotspots[1].caller_count, 1);
}
#[test]
fn overview_hotspots_limited_to_10() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
for i in 0..15 {
let id = format!("f_{i}");
let name = format!("func{i}");
let qn = format!("demo.func{i}");
let file = format!("/src/f{i}.rs");
create_function(&kit, &id, "demo", &name, &qn, &file, 1);
}
for i in 0..15 {
let id = format!("f_{i}");
let edge_id = format!("e_{i}");
create_edge(&kit, &edge_id, &id, &id, "CALLS", "demo");
}
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(
result.hotspots.len(),
10,
"hotspots should be limited to 10"
);
}
#[test]
fn overview_groups_packages() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_class(
&kit,
"c1",
"demo",
"Foo",
"com.example.Foo",
"/src/Foo.java",
1,
);
create_class(
&kit,
"c2",
"demo",
"Bar",
"com.example.Bar",
"/src/Bar.java",
1,
);
create_class(
&kit,
"c3",
"demo",
"Baz",
"org.other.Baz",
"/src/Baz.java",
1,
);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let pkg = result
.packages
.iter()
.find(|p| p.package == "com.example")
.expect("com.example package should exist");
assert_eq!(pkg.symbol_count, 2, "com.example should have 2 symbols");
let other = result
.packages
.iter()
.find(|p| p.package == "org.other")
.expect("org.other package should exist");
assert_eq!(other.symbol_count, 1, "org.other should have 1 symbol");
}
#[test]
fn overview_includes_methods_in_packages() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_method(
&kit,
"m1",
"demo",
"helper",
"com.example.helper",
"/src/lib.rs",
1,
);
create_function(
&kit,
"f1",
"demo",
"foo",
"com.example.foo",
"/src/lib.rs",
10,
);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let pkg = result
.packages
.iter()
.find(|p| p.package == "com.example")
.expect("com.example package should exist");
assert_eq!(pkg.symbol_count, 2, "should count both method and function");
}
#[test]
fn overview_filters_by_project() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_file(&kit, "f1", "demo", "/src/main.rs", "rust");
create_file(&kit, "f2", "other", "/src/app.py", "python");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(
result.languages.len(),
1,
"should only see demo's languages"
);
assert_eq!(result.languages[0].language, "rust");
}
#[test]
fn overview_hotspot_method_nodes() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_method(
&kit,
"m1",
"demo",
"helper",
"demo.Class.helper",
"/src/lib.rs",
5,
);
create_function(&kit, "f1", "demo", "a", "demo.a", "/src/a.rs", 1);
create_edge(&kit, "e1", "f1", "m1", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let helper = result
.hotspots
.iter()
.find(|h| h.name == "helper")
.expect("helper method should be a hotspot");
assert_eq!(helper.caller_count, 1);
assert_eq!(helper.qualified_name, "demo.Class.helper");
}
#[test]
fn module_boundary_serializes_all_fields() {
let mb = ModuleBoundary {
module_name: "src/api".to_string(),
members: vec![
"src/api/handler.rs".to_string(),
"src/api/route.rs".to_string(),
],
incoming_deps: 3,
outgoing_deps: 2,
cohesion: 0.71,
};
let json = serde_json::to_string(&mb).expect("serialize");
assert!(json.contains("\"module_name\":\"src/api\""), "json: {json}");
assert!(json.contains("\"members\""), "json: {json}");
assert!(json.contains("\"incoming_deps\":3"), "json: {json}");
assert!(json.contains("\"outgoing_deps\":2"), "json: {json}");
assert!(json.contains("\"cohesion\":0.71"), "json: {json}");
}
#[test]
fn dep_direction_serializes_all_fields() {
let dd = DepDirection {
from_module: "module_a".to_string(),
to_module: "module_b".to_string(),
is_circular: true,
};
let json = serde_json::to_string(&dd).expect("serialize");
assert!(
json.contains("\"from_module\":\"module_a\""),
"json: {json}"
);
assert!(json.contains("\"to_module\":\"module_b\""), "json: {json}");
assert!(json.contains("\"is_circular\":true"), "json: {json}");
}
#[test]
fn layer_info_serializes_all_fields() {
let li = LayerInfo {
layer: "Controller".to_string(),
members: vec!["demo.handler".to_string()],
};
let json = serde_json::to_string(&li).expect("serialize");
assert!(json.contains("\"layer\":\"Controller\""), "json: {json}");
assert!(json.contains("\"members\""), "json: {json}");
assert!(json.contains("\"demo.handler\""), "json: {json}");
}
#[test]
fn cross_service_dep_serializes_all_fields() {
let csd = CrossServiceDep {
from_module: "svc_a".to_string(),
to_module: "svc_b".to_string(),
protocol: "HTTP".to_string(),
};
let json = serde_json::to_string(&csd).expect("serialize");
assert!(json.contains("\"from_module\":\"svc_a\""), "json: {json}");
assert!(json.contains("\"to_module\":\"svc_b\""), "json: {json}");
assert!(json.contains("\"protocol\":\"HTTP\""), "json: {json}");
}
#[test]
fn overview_includes_new_fields_in_json() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let json = serde_json::to_string(&result).expect("serialize");
assert!(
json.contains("\"module_boundaries\""),
"json should contain module_boundaries: {json}"
);
assert!(
json.contains("\"dependency_directions\""),
"json should contain dependency_directions: {json}"
);
assert!(
json.contains("\"layers\""),
"json should contain layers: {json}"
);
assert!(
json.contains("\"cross_service_deps\""),
"json should contain cross_service_deps: {json}"
);
}
#[test]
fn overview_cross_service_deps_populated_when_matches_exist() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users", "GET");
create_function_with_content(
&kit,
"f1",
"demo",
"caller",
"demo.caller",
"/src/caller.rs",
10,
r#"fetch("/api/users");"#,
);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert!(
!result.cross_service_deps.is_empty(),
"cross_service_deps should be populated when matches exist: {:?}",
result.cross_service_deps
);
let dep = &result.cross_service_deps[0];
assert_eq!(dep.from_module, "f1", "from_module should be caller id");
assert_eq!(dep.to_module, "r1", "to_module should be callee (route id)");
assert_eq!(
dep.protocol, "HTTP",
"protocol should be HTTP for REST match"
);
}
#[test]
fn overview_cross_service_deps_empty_for_no_matches() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "f1", "demo", "foo", "demo.foo", "/src/a.rs", 1);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert!(
result.cross_service_deps.is_empty(),
"cross_service_deps should be empty when no routes/callers match: {:?}",
result.cross_service_deps
);
}
#[test]
fn detect_module_boundaries_cohesion_5_internal_2_external() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "a1", "demo", "a1", "demo.a1", "/src/a/a1.rs", 1);
create_function(&kit, "a2", "demo", "a2", "demo.a2", "/src/a/a2.rs", 1);
create_function(&kit, "a3", "demo", "a3", "demo.a3", "/src/a/a3.rs", 1);
create_function(&kit, "b1", "demo", "b1", "demo.b1", "/src/b/b1.rs", 1);
create_function(&kit, "b2", "demo", "b2", "demo.b2", "/src/b/b2.rs", 1);
create_edge(&kit, "e1", "a1", "a2", "CALLS", "demo");
create_edge(&kit, "e2", "a2", "a3", "CALLS", "demo");
create_edge(&kit, "e3", "a1", "a3", "CALLS", "demo");
create_edge(&kit, "e4", "a3", "a1", "CALLS", "demo");
create_edge(&kit, "e5", "a2", "a1", "CALLS", "demo");
create_edge(&kit, "e6", "a1", "b1", "CALLS", "demo");
create_edge(&kit, "e7", "b2", "a1", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let module_a = result
.module_boundaries
.iter()
.find(|m| m.module_name.ends_with("src/a"))
.expect("module A should exist");
assert_eq!(module_a.incoming_deps, 1, "incoming_deps for A");
assert_eq!(module_a.outgoing_deps, 1, "outgoing_deps for A");
let expected = 5.0 / 7.0;
assert!(
(module_a.cohesion - expected).abs() < 0.001,
"cohesion should be ~{expected:.3}, got {}",
module_a.cohesion
);
}
#[test]
fn detect_module_boundaries_no_external_deps_cohesion_1() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "a1", "demo", "a1", "demo.a1", "/src/a/a1.rs", 1);
create_function(&kit, "a2", "demo", "a2", "demo.a2", "/src/a/a2.rs", 1);
create_edge(&kit, "e1", "a1", "a2", "CALLS", "demo");
create_edge(&kit, "e2", "a2", "a1", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let module_a = result
.module_boundaries
.iter()
.find(|m| m.module_name.ends_with("src/a"))
.expect("module A should exist");
assert_eq!(module_a.incoming_deps, 0, "no incoming deps");
assert_eq!(module_a.outgoing_deps, 0, "no outgoing deps");
assert!(
(module_a.cohesion - 1.0).abs() < 0.001,
"cohesion should be 1.0, got {}",
module_a.cohesion
);
}
#[test]
fn analyze_dependency_directions_detects_circular() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "a1", "demo", "a1", "demo.a1", "/src/a/a1.rs", 1);
create_function(&kit, "b1", "demo", "b1", "demo.b1", "/src/b/b1.rs", 1);
create_edge(&kit, "e1", "a1", "b1", "CALLS", "demo");
create_edge(&kit, "e2", "b1", "a1", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(
result.dependency_directions.len(),
2,
"should have 2 directions"
);
for dd in &result.dependency_directions {
assert!(
dd.is_circular,
"edge {}→{} should be circular",
dd.from_module, dd.to_module
);
}
}
#[test]
fn analyze_dependency_directions_no_circular() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "a1", "demo", "a1", "demo.a1", "/src/a/a1.rs", 1);
create_function(&kit, "b1", "demo", "b1", "demo.b1", "/src/b/b1.rs", 1);
create_function(&kit, "c1", "demo", "c1", "demo.c1", "/src/c/c1.rs", 1);
create_edge(&kit, "e1", "a1", "b1", "CALLS", "demo");
create_edge(&kit, "e2", "b1", "c1", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(
result.dependency_directions.len(),
2,
"should have 2 directions"
);
for dd in &result.dependency_directions {
assert!(
!dd.is_circular,
"edge {}→{} should not be circular",
dd.from_module, dd.to_module
);
}
}
#[test]
fn detect_layers_controller_handling_route() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(
&kit,
"ctrl1",
"demo",
"list_users",
"demo.list_users",
"/src/api/handler.rs",
1,
);
create_route(&kit, "r1", "demo", "/api/users", "GET");
create_edge(&kit, "e1", "ctrl1", "r1", "HANDLES_ROUTE", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let controller = result
.layers
.iter()
.find(|l| l.layer == "Controller")
.expect("Controller layer should exist");
assert!(
controller.members.contains(&"demo.list_users".to_string()),
"Controller members should contain demo.list_users, got: {:?}",
controller.members
);
}
#[test]
fn protocol_to_string_http_rest_returns_http() {
assert_eq!(protocol_to_string(&ServiceProtocol::HttpRest), "HTTP");
}
#[test]
fn protocol_to_string_grpc_returns_grpc() {
assert_eq!(protocol_to_string(&ServiceProtocol::Grpc), "gRPC");
}
#[test]
fn protocol_to_string_graphql_returns_graphql() {
assert_eq!(protocol_to_string(&ServiceProtocol::GraphQL), "GraphQL");
}
#[test]
fn protocol_to_string_message_queue_returns_message_queue() {
assert_eq!(
protocol_to_string(&ServiceProtocol::MessageQueue),
"MessageQueue"
);
}
#[test]
fn protocol_to_string_event_bus_returns_event_bus() {
assert_eq!(protocol_to_string(&ServiceProtocol::EventBus), "EventBus");
}
#[test]
fn can_reach_returns_true_when_start_equals_target() {
let mut adj: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
let mut neighbors = std::collections::HashSet::new();
neighbors.insert("b".to_string());
adj.insert("a".to_string(), neighbors);
assert!(
can_reach(&adj, "a", "a"),
"start == target should return true immediately"
);
}
#[test]
fn can_reach_returns_true_for_direct_neighbor() {
let mut adj: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
let mut neighbors = std::collections::HashSet::new();
neighbors.insert("b".to_string());
adj.insert("a".to_string(), neighbors);
assert!(can_reach(&adj, "a", "b"), "a→b direct edge");
}
#[test]
fn can_reach_returns_true_for_transitive_path() {
let mut adj: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
let mut a_neighbors = std::collections::HashSet::new();
a_neighbors.insert("b".to_string());
adj.insert("a".to_string(), a_neighbors);
let mut b_neighbors = std::collections::HashSet::new();
b_neighbors.insert("c".to_string());
adj.insert("b".to_string(), b_neighbors);
assert!(can_reach(&adj, "a", "c"), "a→b→c transitive path");
}
#[test]
fn can_reach_returns_false_for_unreachable() {
let mut adj: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
let mut a_neighbors = std::collections::HashSet::new();
a_neighbors.insert("b".to_string());
adj.insert("a".to_string(), a_neighbors);
assert!(!can_reach(&adj, "a", "z"), "z is not reachable from a");
}
#[test]
fn can_reach_returns_false_for_empty_graph() {
let adj: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
assert!(
!can_reach(&adj, "a", "b"),
"empty graph → nothing reachable"
);
}
#[test]
fn detect_layers_service_layer_function_called_by_controller() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(
&kit,
"ctrl1",
"demo",
"list_users",
"demo.list_users",
"/src/api/handler.rs",
1,
);
create_route(&kit, "r1", "demo", "/api/users", "GET");
create_edge(&kit, "e1", "ctrl1", "r1", "HANDLES_ROUTE", "demo");
create_function(
&kit,
"svc1",
"demo",
"fetch_users",
"demo.fetch_users",
"/src/service/user_service.rs",
1,
);
create_edge(&kit, "e2", "ctrl1", "svc1", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let service = result
.layers
.iter()
.find(|l| l.layer == "Service")
.expect("Service layer should exist");
assert!(
service.members.contains(&"demo.fetch_users".to_string()),
"Service members should contain demo.fetch_users, got: {:?}",
service.members
);
}
#[test]
fn detect_layers_repository_layer_function_with_fetches_edge() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(
&kit,
"repo1",
"demo",
"find_user",
"demo.find_user",
"/src/repo/user_repo.rs",
1,
);
create_function(
&kit,
"model1",
"demo",
"user_data",
"demo.user_data",
"/src/model/user.rs",
1,
);
create_edge(&kit, "e1", "repo1", "model1", "FETCHES", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let repository = result
.layers
.iter()
.find(|l| l.layer == "Repository")
.expect("Repository layer should exist");
assert!(
repository.members.contains(&"demo.find_user".to_string()),
"Repository members should contain demo.find_user, got: {:?}",
repository.members
);
}
#[test]
fn detect_layers_model_layer_type_with_has_property_and_no_calls() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_class(
&kit,
"cls1",
"demo",
"User",
"demo.User",
"/src/model/user.rs",
1,
);
create_function(
&kit,
"prop1",
"demo",
"name_field",
"demo.name_field",
"/src/model/user.rs",
5,
);
create_edge(&kit, "e1", "cls1", "prop1", "HAS_PROPERTY", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let model = result
.layers
.iter()
.find(|l| l.layer == "Model")
.expect("Model layer should exist");
assert!(
model.members.contains(&"demo.User".to_string()),
"Model members should contain demo.User, got: {:?}",
model.members
);
}
#[test]
fn detect_layers_all_four_layers_populated() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(
&kit,
"ctrl1",
"demo",
"list_users",
"demo.list_users",
"/src/api/handler.rs",
1,
);
create_route(&kit, "r1", "demo", "/api/users", "GET");
create_edge(&kit, "e1", "ctrl1", "r1", "HANDLES_ROUTE", "demo");
create_function(
&kit,
"svc1",
"demo",
"fetch_users",
"demo.fetch_users",
"/src/service/user_service.rs",
1,
);
create_edge(&kit, "e2", "ctrl1", "svc1", "CALLS", "demo");
create_function(
&kit,
"repo1",
"demo",
"find_user",
"demo.find_user",
"/src/repo/user_repo.rs",
1,
);
create_function(
&kit,
"data1",
"demo",
"db_query",
"demo.db_query",
"/src/repo/db.rs",
1,
);
create_edge(&kit, "e3", "repo1", "data1", "FETCHES", "demo");
create_class(
&kit,
"cls1",
"demo",
"User",
"demo.User",
"/src/model/user.rs",
1,
);
create_function(
&kit,
"prop1",
"demo",
"name_field",
"demo.name_field",
"/src/model/user.rs",
5,
);
create_edge(&kit, "e4", "cls1", "prop1", "HAS_PROPERTY", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let layer_names: Vec<&str> = result.layers.iter().map(|l| l.layer.as_str()).collect();
assert!(
layer_names.contains(&"Controller"),
"Controller layer should exist: {:?}",
layer_names
);
assert!(
layer_names.contains(&"Service"),
"Service layer should exist: {:?}",
layer_names
);
assert!(
layer_names.contains(&"Repository"),
"Repository layer should exist: {:?}",
layer_names
);
assert!(
layer_names.contains(&"Model"),
"Model layer should exist: {:?}",
layer_names
);
}
#[test]
fn detect_layers_empty_when_no_relevant_edges() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "f1", "demo", "plain", "demo.plain", "/src/lib.rs", 1);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert!(
result.layers.is_empty(),
"no relevant edges → no layers, got: {:?}",
result.layers
);
}
#[test]
fn detect_module_boundaries_root_level_file() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "f1", "demo", "foo", "demo.foo", "main.rs", 1);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let root_module = result
.module_boundaries
.iter()
.find(|m| m.module_name.is_empty())
.expect("root-level module should exist with empty name");
assert!(
root_module.members.contains(&"main.rs".to_string()),
"root module should contain main.rs"
);
assert_eq!(root_module.cohesion, 1.0, "no CALLS edges → cohesion 1.0");
}
#[test]
fn analyze_dependency_directions_deep_chain() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "a1", "demo", "a1", "demo.a1", "/src/a/a1.rs", 1);
create_function(&kit, "b1", "demo", "b1", "demo.b1", "/src/b/b1.rs", 1);
create_function(&kit, "c1", "demo", "c1", "demo.c1", "/src/c/c1.rs", 1);
create_function(&kit, "d1", "demo", "d1", "demo.d1", "/src/d/d1.rs", 1);
create_edge(&kit, "e1", "a1", "b1", "CALLS", "demo");
create_edge(&kit, "e2", "b1", "c1", "CALLS", "demo");
create_edge(&kit, "e3", "c1", "d1", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(
result.dependency_directions.len(),
3,
"should have 3 directed edges"
);
for dd in &result.dependency_directions {
assert!(
!dd.is_circular,
"edge {}→{} should not be circular",
dd.from_module, dd.to_module
);
}
}
#[test]
fn detect_layers_model_excluded_when_type_has_calls() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_class(
&kit,
"cls1",
"demo",
"ActiveClass",
"demo.ActiveClass",
"/src/model/user.rs",
1,
);
create_function(
&kit,
"prop1",
"demo",
"field",
"demo.field",
"/src/model/user.rs",
5,
);
create_edge(&kit, "e1", "cls1", "prop1", "HAS_PROPERTY", "demo");
create_function(
&kit,
"caller",
"demo",
"caller",
"demo.caller",
"/src/svc.rs",
1,
);
create_edge(&kit, "e2", "caller", "cls1", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let model_layer = result.layers.iter().find(|l| l.layer == "Model");
assert!(
model_layer.is_none()
|| !model_layer
.unwrap()
.members
.contains(&"demo.ActiveClass".to_string()),
"ActiveClass has CALLS edge → should NOT be in Model layer"
);
}
#[test]
fn detect_layers_service_takes_precedence_over_repository() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(
&kit,
"ctrl1",
"demo",
"list_users",
"demo.list_users",
"/src/api/handler.rs",
1,
);
create_route(&kit, "r1", "demo", "/api/users", "GET");
create_edge(&kit, "e1", "ctrl1", "r1", "HANDLES_ROUTE", "demo");
create_function(
&kit,
"svc_repo",
"demo",
"fetch_and_load",
"demo.fetch_and_load",
"/src/service/svc.rs",
1,
);
create_function(
&kit,
"data1",
"demo",
"db_query",
"demo.db_query",
"/src/repo/db.rs",
1,
);
create_edge(&kit, "e2", "ctrl1", "svc_repo", "CALLS", "demo");
create_edge(&kit, "e3", "svc_repo", "data1", "FETCHES", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let service = result.layers.iter().find(|l| l.layer == "Service");
assert!(service.is_some(), "Service layer should exist");
assert!(
service
.unwrap()
.members
.contains(&"demo.fetch_and_load".to_string()),
"fetch_and_load should be Service (priority over Repository)"
);
}
#[test]
fn overview_multiple_routes_with_handlers() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r1", "demo", "/api/users", "GET");
create_route(&kit, "r2", "demo", "/api/products", "POST");
create_route(&kit, "r3", "demo", "/api/orders", "DELETE");
create_handler(&kit, "h1", "demo", "list_users");
create_handler(&kit, "h2", "demo", "create_product");
create_edge(&kit, "e1", "h1", "r1", "HANDLES", "demo");
create_edge(&kit, "e2", "h2", "r2", "HANDLES", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.routes.len(), 3, "should have 3 routes");
let r1 = result
.routes
.iter()
.find(|r| r.path == "/api/users")
.expect("r1");
assert_eq!(r1.handler, "list_users");
let r2 = result
.routes
.iter()
.find(|r| r.path == "/api/products")
.expect("r2");
assert_eq!(r2.handler, "create_product");
let r3 = result
.routes
.iter()
.find(|r| r.path == "/api/orders")
.expect("r3");
assert_eq!(r3.handler, "", "r3 should have empty handler");
}
#[test]
fn detect_module_boundaries_no_calls_edges_at_all() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "a1", "demo", "a1", "demo.a1", "/src/a/a1.rs", 1);
create_function(&kit, "a2", "demo", "a2", "demo.a2", "/src/a/a2.rs", 1);
create_function(&kit, "b1", "demo", "b1", "demo.b1", "/src/b/b1.rs", 1);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
for mb in &result.module_boundaries {
assert_eq!(
mb.cohesion, 1.0,
"module {} with no CALLS edges should have cohesion 1.0",
mb.module_name
);
assert_eq!(mb.incoming_deps, 0);
assert_eq!(mb.outgoing_deps, 0);
}
}
#[test]
fn module_name_from_path_returns_parent_directory() {
assert_eq!(module_name_from_path("/src/a/foo.rs"), "/src/a");
assert_eq!(module_name_from_path("src/a/foo.rs"), "src/a");
}
#[test]
fn module_name_from_path_nested_directories() {
assert_eq!(module_name_from_path("/a/b/c/d.rs"), "/a/b/c");
}
#[test]
fn module_name_from_path_root_level_file_returns_empty() {
assert_eq!(module_name_from_path("foo.rs"), "");
}
#[test]
fn module_name_from_path_with_trailing_slash() {
assert_eq!(module_name_from_path("/src/a/"), "/src");
}
#[test]
fn module_name_from_path_single_component() {
assert_eq!(module_name_from_path("a"), "");
}
#[test]
fn module_name_from_path_empty_returns_empty() {
assert_eq!(module_name_from_path(""), "");
}
#[test]
fn package_prefix_multiple_dots_returns_first_two() {
assert_eq!(package_prefix("a.b.c.d.e"), Some("a.b"));
}
#[test]
fn package_prefix_trailing_dot_returns_first_two() {
assert_eq!(package_prefix("a.b."), Some("a.b"));
}
#[test]
fn package_prefix_single_char_components() {
assert_eq!(package_prefix("x.y.Z"), Some("x.y"));
}
#[test]
fn can_reach_handles_cycle_in_graph() {
let mut adj: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
let mut a_neighbors = std::collections::HashSet::new();
a_neighbors.insert("b".to_string());
adj.insert("a".to_string(), a_neighbors);
let mut b_neighbors = std::collections::HashSet::new();
b_neighbors.insert("a".to_string());
adj.insert("b".to_string(), b_neighbors);
assert!(
!can_reach(&adj, "a", "c"),
"should return false for unreachable node despite cycle"
);
}
#[test]
fn can_reach_returns_true_for_long_path() {
let mut adj: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
let edges = [
("a", "b"),
("b", "c"),
("c", "d"),
("d", "e"),
("e", "target"),
];
for (from, to) in &edges {
adj.entry((*from).to_string())
.or_default()
.insert((*to).to_string());
}
assert!(
can_reach(&adj, "a", "target"),
"long path should be reachable"
);
}
#[test]
fn can_reach_returns_false_when_target_not_in_adjacency() {
let mut adj: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
let mut a_neighbors = std::collections::HashSet::new();
a_neighbors.insert("b".to_string());
adj.insert("a".to_string(), a_neighbors);
assert!(
!can_reach(&adj, "a", "nonexistent"),
"nonexistent target should return false"
);
}
#[test]
fn overview_entry_points_excludes_non_matching_names() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(
&kit,
"f1",
"demo",
"main_helper",
"demo.main_helper",
"/src/a.rs",
1,
);
create_function(
&kit,
"f2",
"demo",
"mainFunc",
"demo.mainFunc",
"/src/b.rs",
1,
);
create_function(&kit, "f3", "demo", "run", "demo.run", "/src/c.rs", 1);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert!(
result.entry_points.is_empty(),
"non-matching names should not be entry points: {:?}",
result.entry_points
);
}
#[test]
fn overview_hotspots_excludes_targets_not_in_function_or_method() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_class(
&kit,
"cls1",
"demo",
"MyClass",
"demo.MyClass",
"/src/cls.rs",
1,
);
create_function(&kit, "f1", "demo", "caller", "demo.caller", "/src/a.rs", 1);
create_edge(&kit, "e1", "f1", "cls1", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert!(
!result.hotspots.iter().any(|h| h.name == "MyClass"),
"Class node should not appear in hotspots: {:?}",
result.hotspots
);
}
#[test]
fn overview_routes_sorted_by_path() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_route(&kit, "r3", "demo", "/api/zebra", "GET");
create_route(&kit, "r1", "demo", "/api/alpha", "GET");
create_route(&kit, "r2", "demo", "/api/middle", "GET");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.routes.len(), 3);
assert_eq!(result.routes[0].path, "/api/alpha");
assert_eq!(result.routes[1].path, "/api/middle");
assert_eq!(result.routes[2].path, "/api/zebra");
}
#[test]
fn overview_packages_sorted_by_count_desc_then_name() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_class(&kit, "c1", "demo", "A1", "aaa.pkg.A1", "/a.rs", 1);
create_class(&kit, "c2", "demo", "B1", "bbb.pkg.B1", "/b.rs", 1);
create_class(&kit, "c3", "demo", "B2", "bbb.pkg.B2", "/b.rs", 1);
create_class(&kit, "c4", "demo", "B3", "bbb.pkg.B3", "/b.rs", 1);
create_class(&kit, "c5", "demo", "C1", "ccc.pkg.C1", "/c.rs", 1);
create_class(&kit, "c6", "demo", "C2", "ccc.pkg.C2", "/c.rs", 1);
create_class(&kit, "c7", "demo", "C3", "ccc.pkg.C3", "/c.rs", 1);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.packages[0].package, "bbb.pkg");
assert_eq!(result.packages[0].symbol_count, 3);
assert_eq!(result.packages[1].package, "ccc.pkg");
assert_eq!(result.packages[1].symbol_count, 3);
assert_eq!(result.packages[2].package, "aaa.pkg");
assert_eq!(result.packages[2].symbol_count, 1);
}
#[test]
fn overview_module_boundaries_sorted_by_name() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "f1", "demo", "a", "demo.a", "/src/zebra/a.rs", 1);
create_function(&kit, "f2", "demo", "b", "demo.b", "/src/alpha/b.rs", 1);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert!(result.module_boundaries.len() >= 2);
assert_eq!(result.module_boundaries[0].module_name, "/src/alpha");
assert_eq!(result.module_boundaries[1].module_name, "/src/zebra");
}
#[test]
fn overview_languages_sorted_alphabetically() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_file(&kit, "f1", "demo", "/a.py", "python");
create_file(&kit, "f2", "demo", "/b.rs", "rust");
create_file(&kit, "f3", "demo", "/c.ts", "typescript");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.languages.len(), 3);
assert_eq!(result.languages[0].language, "python");
assert_eq!(result.languages[1].language, "rust");
assert_eq!(result.languages[2].language, "typescript");
}
#[test]
fn overview_dependency_directions_sorted_by_from_then_to() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "z1", "demo", "z", "demo.z", "/src/zebra/z.rs", 1);
create_function(&kit, "a1", "demo", "a", "demo.a", "/src/alpha/a.rs", 1);
create_function(&kit, "m1", "demo", "m", "demo.m", "/src/middle/m.rs", 1);
create_edge(&kit, "e1", "z1", "m1", "CALLS", "demo");
create_edge(&kit, "e2", "a1", "z1", "CALLS", "demo");
create_edge(&kit, "e3", "a1", "m1", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.dependency_directions.len(), 3);
assert_eq!(result.dependency_directions[0].from_module, "/src/alpha");
assert_eq!(result.dependency_directions[0].to_module, "/src/middle");
assert_eq!(result.dependency_directions[1].from_module, "/src/alpha");
assert_eq!(result.dependency_directions[1].to_module, "/src/zebra");
assert_eq!(result.dependency_directions[2].from_module, "/src/zebra");
assert_eq!(result.dependency_directions[2].to_module, "/src/middle");
}
#[test]
fn overview_entry_points_sorted_by_qualified_name() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(
&kit,
"f1",
"demo",
"main",
"demo.zebra.main",
"/src/z.rs",
1,
);
create_function(
&kit,
"f2",
"demo",
"main",
"demo.alpha.main",
"/src/a.rs",
1,
);
create_function(
&kit,
"f3",
"demo",
"main",
"demo.middle.main",
"/src/m.rs",
1,
);
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.entry_points.len(), 3);
assert_eq!(result.entry_points[0].qualified_name, "demo.alpha.main");
assert_eq!(result.entry_points[1].qualified_name, "demo.middle.main");
assert_eq!(result.entry_points[2].qualified_name, "demo.zebra.main");
}
#[test]
fn overview_hotspots_tiebreaker_by_qualified_name() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(
&kit,
"f_zebra",
"demo",
"zebra",
"demo.zebra",
"/src/z.rs",
1,
);
create_function(
&kit,
"f_alpha",
"demo",
"alpha",
"demo.alpha",
"/src/a.rs",
1,
);
create_function(
&kit,
"f_caller",
"demo",
"caller",
"demo.caller",
"/src/c.rs",
1,
);
create_edge(&kit, "e1", "f_caller", "f_zebra", "CALLS", "demo");
create_edge(&kit, "e2", "f_caller", "f_alpha", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
assert_eq!(result.hotspots.len(), 2);
assert_eq!(result.hotspots[0].name, "alpha");
assert_eq!(result.hotspots[1].name, "zebra");
}
#[test]
fn overview_layers_in_precedence_order() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(
&kit,
"ctrl1",
"demo",
"list_users",
"demo.list_users",
"/src/api/handler.rs",
1,
);
create_route(&kit, "r1", "demo", "/api/users", "GET");
create_edge(&kit, "e1", "ctrl1", "r1", "HANDLES_ROUTE", "demo");
create_class(
&kit,
"cls1",
"demo",
"User",
"demo.User",
"/src/model/user.rs",
1,
);
create_function(
&kit,
"prop1",
"demo",
"name_field",
"demo.name_field",
"/src/model/user.rs",
5,
);
create_edge(&kit, "e2", "cls1", "prop1", "HAS_PROPERTY", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let layer_names: Vec<&str> = result.layers.iter().map(|l| l.layer.as_str()).collect();
let ctrl_idx = layer_names.iter().position(|&n| n == "Controller");
let model_idx = layer_names.iter().position(|&n| n == "Model");
assert!(ctrl_idx.is_some() && model_idx.is_some());
assert!(ctrl_idx.unwrap() < model_idx.unwrap());
}
#[test]
fn overview_module_with_only_incoming_deps() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "a1", "demo", "a", "demo.a", "/src/a/a1.rs", 1);
create_function(&kit, "b1", "demo", "b", "demo.b", "/src/b/b1.rs", 1);
create_edge(&kit, "e1", "a1", "b1", "CALLS", "demo");
let storage = storage(&kit);
let analyzer = ArchitectureAnalyzer::new(&*storage);
let result = analyzer.overview("demo").expect("overview");
let module_b = result
.module_boundaries
.iter()
.find(|m| m.module_name.ends_with("src/b"))
.expect("module B should exist");
assert_eq!(module_b.incoming_deps, 1, "B should have 1 incoming dep");
assert_eq!(module_b.outgoing_deps, 0, "B should have 0 outgoing deps");
assert!(
(module_b.cohesion - 0.0).abs() < 0.001,
"cohesion should be 0.0, got {}",
module_b.cohesion
);
}
}