Skip to main content

rta_core/analyzers/
architecture.rs

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}