1use std::collections::{BTreeMap, BTreeSet};
2
3use petgraph::{algo::tarjan_scc, graph::DiGraph, graph::NodeIndex};
4use syn::{Item, UseTree};
5
6use crate::{analyzers::Analyzer, collector::RepositorySnapshot, model::ArchitectureAssessment};
7
8pub struct ArchitectureAnalyzer;
9
10impl Analyzer<ArchitectureAssessment> for ArchitectureAnalyzer {
11 fn analyze(&self, snapshot: &RepositorySnapshot) -> ArchitectureAssessment {
12 let module_profiles = snapshot
13 .rust_files()
14 .map(module_profile)
15 .collect::<Vec<_>>();
16 let module_count = module_profiles.len().max(1);
17 let package_count = snapshot
18 .manifests
19 .iter()
20 .filter(|manifest| manifest.package_name.is_some())
21 .count();
22 let top_level_modules = module_profiles
23 .iter()
24 .filter_map(|profile| profile.top_level_module.clone())
25 .collect::<BTreeSet<_>>();
26 let avg_module_depth = module_profiles
27 .iter()
28 .map(|profile| profile.module_depth)
29 .sum::<usize>() as f32
30 / module_count as f32;
31 let fan_out = fan_out_by_module(&module_profiles, &top_level_modules);
32 let fan_out_edges = fan_out.values().map(BTreeSet::len).sum::<usize>();
33 let max_fan_out = fan_out.values().map(BTreeSet::len).max().unwrap_or(0);
34 let avg_fan_out = if fan_out.is_empty() {
35 0.0
36 } else {
37 fan_out_edges as f32 / fan_out.len() as f32
38 };
39
40 let module_centralization_risks = module_profiles
41 .iter()
42 .filter(|profile| profile.mod_declarations > 20)
43 .map(|profile| {
44 format!(
45 "{} centralizes {} module declarations",
46 profile.relative_path, profile.mod_declarations
47 )
48 })
49 .collect::<Vec<_>>();
50 let circular_dependencies = circular_dependencies(&fan_out);
51 let detected_layers =
52 structural_signals(package_count, avg_module_depth, fan_out_edges, max_fan_out);
53 let domain_boundaries = structural_boundaries(snapshot, &top_level_modules);
54 let architecture_style = classify_architecture(package_count, avg_module_depth);
55 let separation_of_concerns = separation_of_concerns(
56 package_count,
57 avg_module_depth,
58 max_fan_out,
59 &module_centralization_risks,
60 &circular_dependencies,
61 );
62
63 let mut score = 68_i32;
64 score += match package_count {
65 0 | 1 => 0,
66 2..=4 => 12,
67 _ => 14,
68 };
69 score += if (1.0..=4.5).contains(&avg_module_depth) {
70 8
71 } else if (0.5..1.0).contains(&avg_module_depth) {
72 6
73 } else if avg_module_depth > 6.0 {
74 -8
75 } else {
76 -3
77 };
78 score += if fan_out_edges > 0 && max_fan_out <= 12 {
79 6
80 } else if max_fan_out > 20 {
81 -12
82 } else if avg_fan_out > 8.0 {
83 -8
84 } else {
85 0
86 };
87 let centralization_density = module_centralization_risks.len() as f32 / module_count as f32;
88 score -= ((centralization_density * 35.0).round() as i32).min(20);
89 score -= (circular_dependencies.len() as i32 * 12).min(24);
90
91 ArchitectureAssessment {
92 detected_layers,
93 domain_boundaries,
94 module_centralization_risks,
95 circular_dependencies,
96 architecture_style,
97 separation_of_concerns,
98 score: score.clamp(0, 100) as u8,
99 }
100 }
101}
102
103#[derive(Debug)]
104struct ModuleProfile {
105 relative_path: String,
106 top_level_module: Option<String>,
107 module_depth: usize,
108 mod_declarations: usize,
109 crate_uses: BTreeSet<String>,
110}
111
112fn module_profile(file: &crate::collector::FileSnapshot) -> ModuleProfile {
113 let content = file.content.as_deref().unwrap_or_default();
114 let parsed = if file.lines <= 500 {
115 parse_rust_architecture(content).ok()
116 } else {
117 None
118 };
119 ModuleProfile {
120 relative_path: file.relative_path.clone(),
121 top_level_module: top_level_module(&file.relative_path),
122 module_depth: module_depth(&file.relative_path),
123 mod_declarations: parsed
124 .as_ref()
125 .map(|metrics| metrics.mod_declarations)
126 .unwrap_or_else(|| count_mod_declarations_textually(content)),
127 crate_uses: parsed
128 .map(|metrics| metrics.crate_uses)
129 .unwrap_or_else(|| collect_crate_uses_textually(content)),
130 }
131}
132
133#[derive(Default)]
134struct ArchitectureSyntax {
135 mod_declarations: usize,
136 crate_uses: BTreeSet<String>,
137}
138
139fn parse_rust_architecture(content: &str) -> Result<ArchitectureSyntax, syn::Error> {
140 let syntax = syn::parse_file(content)?;
141 let mut metrics = ArchitectureSyntax::default();
142 for item in syntax.items {
143 match item {
144 Item::Mod(_) => metrics.mod_declarations += 1,
145 Item::Use(item_use) => collect_crate_uses_from_tree(&item_use.tree, &mut metrics),
146 _ => {}
147 }
148 }
149 Ok(metrics)
150}
151
152fn collect_crate_uses_from_tree(tree: &UseTree, metrics: &mut ArchitectureSyntax) {
153 match tree {
154 UseTree::Path(path) if path.ident == "crate" => {
155 collect_first_segment_after_crate(&path.tree, metrics);
156 }
157 UseTree::Path(path) => collect_crate_uses_from_tree(&path.tree, metrics),
158 UseTree::Group(group) => {
159 for tree in &group.items {
160 collect_crate_uses_from_tree(tree, metrics);
161 }
162 }
163 UseTree::Name(_) | UseTree::Rename(_) | UseTree::Glob(_) => {}
164 }
165}
166
167fn collect_first_segment_after_crate(tree: &UseTree, metrics: &mut ArchitectureSyntax) {
168 match tree {
169 UseTree::Path(path) => {
170 metrics.crate_uses.insert(path.ident.to_string());
171 }
172 UseTree::Name(name) => {
173 metrics.crate_uses.insert(name.ident.to_string());
174 }
175 UseTree::Rename(rename) => {
176 metrics.crate_uses.insert(rename.ident.to_string());
177 }
178 UseTree::Group(group) => {
179 for tree in &group.items {
180 collect_first_segment_after_crate(tree, metrics);
181 }
182 }
183 UseTree::Glob(_) => {}
184 }
185}
186
187fn fan_out_by_module(
188 profiles: &[ModuleProfile],
189 top_level_modules: &BTreeSet<String>,
190) -> BTreeMap<String, BTreeSet<String>> {
191 let mut fan_out = BTreeMap::new();
192 for profile in profiles {
193 let Some(source) = &profile.top_level_module else {
194 continue;
195 };
196 for target in &profile.crate_uses {
197 if target != source && top_level_modules.contains(target) {
198 fan_out
199 .entry(source.clone())
200 .or_insert_with(BTreeSet::new)
201 .insert(target.clone());
202 }
203 }
204 }
205 fan_out
206}
207
208fn circular_dependencies(fan_out: &BTreeMap<String, BTreeSet<String>>) -> Vec<String> {
209 let mut graph = DiGraph::<String, ()>::new();
210 let mut nodes = BTreeMap::<String, NodeIndex>::new();
211 for (source, targets) in fan_out {
212 let source_idx = node_for(source, &mut graph, &mut nodes);
213 for target in targets {
214 let target_idx = node_for(target, &mut graph, &mut nodes);
215 graph.add_edge(source_idx, target_idx, ());
216 }
217 }
218
219 tarjan_scc(&graph)
220 .into_iter()
221 .filter(|component| component.len() > 1)
222 .map(|component| {
223 let mut names = component
224 .into_iter()
225 .map(|node| graph[node].clone())
226 .collect::<Vec<_>>();
227 names.sort();
228 format!("cycle among top-level modules: {}", names.join(" -> "))
229 })
230 .collect()
231}
232
233fn node_for(
234 name: &str,
235 graph: &mut DiGraph<String, ()>,
236 nodes: &mut BTreeMap<String, NodeIndex>,
237) -> NodeIndex {
238 if let Some(node) = nodes.get(name) {
239 *node
240 } else {
241 let node = graph.add_node(name.to_string());
242 nodes.insert(name.to_string(), node);
243 node
244 }
245}
246
247fn structural_signals(
248 package_count: usize,
249 avg_module_depth: f32,
250 fan_out_edges: usize,
251 max_fan_out: usize,
252) -> Vec<String> {
253 let mut signals = Vec::new();
254 if package_count > 1 {
255 signals.push("multi-crate workspace".to_string());
256 } else {
257 signals.push("single crate".to_string());
258 }
259 if avg_module_depth >= 1.0 {
260 signals.push("nested module tree".to_string());
261 }
262 if fan_out_edges > 0 {
263 signals.push("crate-relative module fan-out".to_string());
264 }
265 if max_fan_out > 20 {
266 signals.push("high top-level fan-out".to_string());
267 }
268 signals
269}
270
271fn structural_boundaries(
272 snapshot: &RepositorySnapshot,
273 top_level_modules: &BTreeSet<String>,
274) -> Vec<String> {
275 let mut boundaries = snapshot
276 .manifests
277 .iter()
278 .filter_map(|manifest| manifest.package_name.as_ref())
279 .map(|name| format!("crate:{name}"))
280 .collect::<BTreeSet<_>>();
281 boundaries.extend(
282 top_level_modules
283 .iter()
284 .map(|module| format!("module:{module}")),
285 );
286 boundaries.into_iter().collect()
287}
288
289fn separation_of_concerns(
290 package_count: usize,
291 avg_module_depth: f32,
292 max_fan_out: usize,
293 centralization_risks: &[String],
294 circular_dependencies: &[String],
295) -> String {
296 if !circular_dependencies.is_empty() {
297 "Top-level module cycles detected; dependency direction should be reviewed.".to_string()
298 } else if max_fan_out > 20 || !centralization_risks.is_empty() {
299 "Some structural boundaries exist, with centralization hotspots to review.".to_string()
300 } else if package_count > 1 || avg_module_depth >= 1.0 {
301 "Structural boundaries are visible through crates and module organization.".to_string()
302 } else {
303 "Compact structure with limited module separation detected.".to_string()
304 }
305}
306
307fn classify_architecture(package_count: usize, avg_module_depth: f32) -> String {
308 if package_count > 3 {
309 "modular Cargo workspace".to_string()
310 } else if package_count == 1 && avg_module_depth < 1.0 {
311 "single-crate compact codebase".to_string()
312 } else if package_count == 1 {
313 "single-crate modular codebase".to_string()
314 } else {
315 "compact Cargo workspace".to_string()
316 }
317}
318
319fn top_level_module(relative_path: &str) -> Option<String> {
320 let components_after_src = components_after_src(relative_path)?;
321 let first = components_after_src.first()?;
322 if matches!(*first, "lib.rs" | "main.rs") {
323 return None;
324 }
325 Some(first.trim_end_matches(".rs").to_string())
326}
327
328fn module_depth(relative_path: &str) -> usize {
329 let Some(components) = components_after_src(relative_path) else {
330 return 0;
331 };
332 if components.len() == 1 && matches!(components[0], "lib.rs" | "main.rs") {
333 return 0;
334 }
335 components.len()
336}
337
338fn components_after_src(relative_path: &str) -> Option<Vec<&str>> {
339 let components = relative_path.split('/').collect::<Vec<_>>();
340 let src_index = components
341 .iter()
342 .position(|component| *component == "src")?;
343 Some(components.into_iter().skip(src_index + 1).collect())
344}
345
346fn count_mod_declarations_textually(content: &str) -> usize {
347 content
348 .lines()
349 .map(str::trim)
350 .filter(|line| line.starts_with("mod ") || line.starts_with("pub mod "))
351 .count()
352}
353
354fn collect_crate_uses_textually(content: &str) -> BTreeSet<String> {
355 content
356 .lines()
357 .map(str::trim)
358 .filter_map(|line| line.strip_prefix("use crate::"))
359 .filter_map(|line| {
360 line.split(|ch: char| !ch.is_alphanumeric() && ch != '_')
361 .find(|segment| !segment.is_empty())
362 })
363 .map(str::to_string)
364 .collect()
365}
366
367#[cfg(test)]
368mod tests {
369 use std::{collections::BTreeMap, path::PathBuf};
370
371 use crate::{
372 analyzers::{architecture::parse_rust_architecture, Analyzer},
373 collector::{CargoManifest, FileSnapshot, RepositorySnapshot},
374 };
375
376 use super::ArchitectureAnalyzer;
377
378 #[test]
379 fn empty_repository_scores_as_compact_unknown_structure() {
380 let report = ArchitectureAnalyzer.analyze(&snapshot(Vec::new(), Vec::new()));
381
382 assert!(report.score < 75);
383 assert!(report.module_centralization_risks.is_empty());
384 assert!(report.circular_dependencies.is_empty());
385 }
386
387 #[test]
388 fn typical_workspace_gets_structural_signals_without_path_layer_names() {
389 let report = ArchitectureAnalyzer.analyze(&snapshot(
390 vec![
391 ("crates/core/src/lib.rs", "pub mod engine;\n"),
392 ("crates/core/src/engine.rs", "pub fn run() {}\n"),
393 (
394 "crates/http/src/lib.rs",
395 "use crate::router;\npub mod router;\n",
396 ),
397 ("crates/http/src/router.rs", "pub fn route() {}\n"),
398 ],
399 vec!["core", "http"],
400 ));
401
402 assert!(report
403 .detected_layers
404 .contains(&"multi-crate workspace".into()));
405 assert!(report.score >= 80);
406 }
407
408 #[test]
409 fn extreme_entrypoint_centralization_is_reported_by_renamed_metric() {
410 let mut content = String::new();
411 for index in 0..25 {
412 content.push_str(&format!("pub mod module_{index};\n"));
413 }
414 let report = ArchitectureAnalyzer.analyze(&snapshot(
415 vec![("src/lib.rs", content.as_str())],
416 vec!["app"],
417 ));
418
419 assert_eq!(report.module_centralization_risks.len(), 1);
420 assert!(report.score < 80);
421 }
422
423 #[test]
424 fn adversarial_commented_use_does_not_create_cycle() {
425 let metrics = parse_rust_architecture("// use crate::fake;\npub mod real;\n")
426 .expect("valid Rust should parse");
427
428 assert!(metrics.crate_uses.is_empty());
429 assert_eq!(metrics.mod_declarations, 1);
430 }
431
432 #[test]
433 fn top_level_cycles_are_detected_with_petgraph() {
434 let report = ArchitectureAnalyzer.analyze(&snapshot(
435 vec![
436 ("src/a.rs", "use crate::b;\npub fn a() {}\n"),
437 ("src/b.rs", "use crate::a;\npub fn b() {}\n"),
438 ],
439 vec!["app"],
440 ));
441
442 assert_eq!(report.circular_dependencies.len(), 1);
443 }
444
445 fn snapshot(files: Vec<(&str, &str)>, packages: Vec<&str>) -> RepositorySnapshot {
446 RepositorySnapshot {
447 root: PathBuf::from("/tmp/repo"),
448 files: files
449 .into_iter()
450 .map(|(relative_path, content)| FileSnapshot {
451 path: PathBuf::from("/tmp/repo").join(relative_path),
452 relative_path: relative_path.to_string(),
453 extension: Some("rs".into()),
454 bytes: content.len() as u64,
455 lines: content.lines().count(),
456 content: Some(content.to_string()),
457 })
458 .collect(),
459 manifests: packages
460 .into_iter()
461 .map(|name| CargoManifest {
462 relative_path: format!("crates/{name}/Cargo.toml"),
463 package_name: Some(name.to_string()),
464 workspace_members: Vec::new(),
465 dependencies: BTreeMap::new(),
466 dev_dependencies: BTreeMap::new(),
467 build_dependencies: BTreeMap::new(),
468 })
469 .collect(),
470 }
471 }
472}