init: archlens — architecture diagram generator
Some checks failed
CI / Check / Test (push) Failing after 1m24s
Some checks failed
CI / Check / Test (push) Failing after 1m24s
Hex arch + DDD, tree-sitter parsing, Mermaid/ASCII output. Supports Rust + Python. 92 tests. CI, diff, --check for staleness detection.
This commit is contained in:
11
crates/application/Cargo.toml
Normal file
11
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "archlens-application"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
archlens-domain.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
rayon.workspace = true
|
||||
1
crates/application/src/lib.rs
Normal file
1
crates/application/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod queries;
|
||||
244
crates/application/src/queries/analyze_codebase.rs
Normal file
244
crates/application/src/queries/analyze_codebase.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use archlens_domain::{
|
||||
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship,
|
||||
RelationshipKind,
|
||||
ports::{FileDiscovery, SourceAnalyzer},
|
||||
};
|
||||
|
||||
pub struct AnalyzeCodebase<F, S>
|
||||
where
|
||||
F: FileDiscovery + Send + Sync,
|
||||
S: SourceAnalyzer,
|
||||
{
|
||||
file_discovery: F,
|
||||
source_analyzer: S,
|
||||
}
|
||||
|
||||
impl<F, S> AnalyzeCodebase<F, S>
|
||||
where
|
||||
F: FileDiscovery + Send + Sync,
|
||||
S: SourceAnalyzer,
|
||||
{
|
||||
pub fn new(file_discovery: F, source_analyzer: S) -> Self {
|
||||
Self {
|
||||
file_discovery,
|
||||
source_analyzer,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute(
|
||||
&self,
|
||||
root: &Path,
|
||||
config: &AnalysisConfig,
|
||||
) -> Result<AnalyzeCodebaseResult, DomainError> {
|
||||
let files = self.file_discovery.discover(root, config)?;
|
||||
|
||||
let file_results: Vec<(Vec<CodeElement>, Vec<Relationship>, Vec<AnalysisWarning>)> = files
|
||||
.par_iter()
|
||||
.map(|file| match self.source_analyzer.analyze_file(file) {
|
||||
Ok(result) => {
|
||||
let module = infer_module(file.path().as_str(), root, config);
|
||||
let elements: Vec<CodeElement> = result
|
||||
.elements()
|
||||
.iter()
|
||||
.map(|el| {
|
||||
let mut el = el.clone();
|
||||
if el.module().is_none()
|
||||
&& let Some(ref m) = module
|
||||
{
|
||||
el = el.with_module(m.clone());
|
||||
}
|
||||
el
|
||||
})
|
||||
.collect();
|
||||
(
|
||||
elements,
|
||||
result.relationships().to_vec(),
|
||||
result.warnings().to_vec(),
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
let mut warnings = Vec::new();
|
||||
if let Ok(warning) =
|
||||
AnalysisWarning::new(file.path().clone(), 0, &err.to_string())
|
||||
{
|
||||
warnings.push(warning);
|
||||
}
|
||||
(Vec::new(), Vec::new(), warnings)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut graph = CodeGraph::new();
|
||||
let mut warnings = Vec::new();
|
||||
for (elements, relationships, warns) in file_results {
|
||||
for el in elements {
|
||||
graph.add_element(el);
|
||||
}
|
||||
for rel in relationships {
|
||||
graph.add_relationship(rel);
|
||||
}
|
||||
warnings.extend(warns);
|
||||
}
|
||||
|
||||
let graph = resolve_cross_file_relationships(graph);
|
||||
let graph = filter_external_imports(graph, root);
|
||||
|
||||
Ok(AnalyzeCodebaseResult { graph, warnings })
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_cross_file_relationships(graph: CodeGraph) -> CodeGraph {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut file_types: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
let mut name_modules: HashMap<&str, HashSet<Option<&str>>> = HashMap::new();
|
||||
let all_type_names: HashSet<&str> = graph.elements().iter().map(|e| e.name()).collect();
|
||||
|
||||
for element in graph.elements() {
|
||||
file_types
|
||||
.entry(element.file_path().as_str().to_string())
|
||||
.or_default()
|
||||
.insert(element.name().to_string());
|
||||
name_modules
|
||||
.entry(element.name())
|
||||
.or_default()
|
||||
.insert(element.module().map(|m| m.as_str()));
|
||||
}
|
||||
|
||||
let mut resolved = CodeGraph::new();
|
||||
for element in graph.elements() {
|
||||
resolved.add_element(element.clone());
|
||||
}
|
||||
for rel in graph.relationships() {
|
||||
match rel.kind() {
|
||||
RelationshipKind::Import => {
|
||||
resolved.add_relationship(rel.clone());
|
||||
}
|
||||
_ => {
|
||||
if !all_type_names.contains(rel.source()) || !all_type_names.contains(rel.target())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(src_file) = rel.source_file() {
|
||||
let file_key = src_file.as_str().to_string();
|
||||
if let Some(types_in_file) = file_types.get(&file_key)
|
||||
&& types_in_file.contains(rel.target())
|
||||
{
|
||||
resolved.add_relationship(rel.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let tgt_modules = &name_modules[rel.target()];
|
||||
if tgt_modules.len() == 1 {
|
||||
resolved.add_relationship(rel.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved
|
||||
}
|
||||
|
||||
fn filter_external_imports(graph: CodeGraph, root: &Path) -> CodeGraph {
|
||||
let known_dirs: HashSet<String> = std::fs::read_dir(root)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.map(|s| s.to_lowercase())
|
||||
.collect();
|
||||
|
||||
let module_names: HashSet<String> = graph
|
||||
.modules()
|
||||
.iter()
|
||||
.map(|m| m.as_str().to_lowercase())
|
||||
.collect();
|
||||
|
||||
let all_known: HashSet<&str> = known_dirs
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.chain(module_names.iter().map(|s| s.as_str()))
|
||||
.collect();
|
||||
|
||||
let mut filtered = CodeGraph::new();
|
||||
for element in graph.elements() {
|
||||
filtered.add_element(element.clone());
|
||||
}
|
||||
for rel in graph.relationships() {
|
||||
if rel.kind() == RelationshipKind::Import {
|
||||
let target_top = rel.target().split('.').next().unwrap_or("").to_lowercase();
|
||||
if !all_known.contains(target_top.as_str()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
filtered.add_relationship(rel.clone());
|
||||
}
|
||||
filtered
|
||||
}
|
||||
|
||||
fn infer_module(file_path: &str, root: &Path, config: &AnalysisConfig) -> Option<ModuleName> {
|
||||
let relative = if let Some(stripped) = file_path.strip_prefix(root.to_str().unwrap_or("")) {
|
||||
stripped.trim_start_matches('/')
|
||||
} else {
|
||||
file_path
|
||||
};
|
||||
|
||||
for (pattern, module_name) in config.module_mappings() {
|
||||
if relative.starts_with(pattern) {
|
||||
return ModuleName::new(module_name).ok();
|
||||
}
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = relative.split('/').collect();
|
||||
if parts.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let module_dir = if parts[0] == "crates" && parts.len() > 2 {
|
||||
// workspace: crates/<crate-name>/src/...
|
||||
parts[1]
|
||||
} else if parts[0] == "src" && parts.len() > 2 {
|
||||
// single project: src/<module>/...
|
||||
parts[1]
|
||||
} else if parts[0] != "src" && parts.len() > 1 {
|
||||
parts[0]
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let capitalized = module_dir
|
||||
.split('-')
|
||||
.map(|seg| {
|
||||
if seg.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}{}", seg[..1].to_uppercase(), &seg[1..])
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
|
||||
ModuleName::new(&capitalized).ok()
|
||||
}
|
||||
|
||||
pub struct AnalyzeCodebaseResult {
|
||||
graph: CodeGraph,
|
||||
warnings: Vec<AnalysisWarning>,
|
||||
}
|
||||
|
||||
impl AnalyzeCodebaseResult {
|
||||
pub fn graph(&self) -> &CodeGraph {
|
||||
&self.graph
|
||||
}
|
||||
|
||||
pub fn warnings(&self) -> &[AnalysisWarning] {
|
||||
&self.warnings
|
||||
}
|
||||
}
|
||||
5
crates/application/src/queries/mod.rs
Normal file
5
crates/application/src/queries/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod analyze_codebase;
|
||||
mod render_diagrams;
|
||||
|
||||
pub use analyze_codebase::{AnalyzeCodebase, AnalyzeCodebaseResult};
|
||||
pub use render_diagrams::RenderDiagrams;
|
||||
45
crates/application/src/queries/render_diagrams.rs
Normal file
45
crates/application/src/queries/render_diagrams.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use archlens_domain::{
|
||||
CodeGraph, DomainError, OutputConfig,
|
||||
ports::{DiagramRenderer, OutputWriter},
|
||||
};
|
||||
|
||||
pub struct RenderDiagrams<R, W>
|
||||
where
|
||||
R: DiagramRenderer,
|
||||
W: OutputWriter,
|
||||
{
|
||||
renderer: R,
|
||||
writer: W,
|
||||
}
|
||||
|
||||
impl<R, W> RenderDiagrams<R, W>
|
||||
where
|
||||
R: DiagramRenderer,
|
||||
W: OutputWriter,
|
||||
{
|
||||
pub fn new(renderer: R, writer: W) -> Self {
|
||||
Self { renderer, writer }
|
||||
}
|
||||
|
||||
pub fn execute(&self, graph: &CodeGraph, config: &OutputConfig) -> Result<(), DomainError> {
|
||||
if config.split_by_module() {
|
||||
let overview = self.renderer.render(graph)?;
|
||||
self.writer.write(&overview)?;
|
||||
|
||||
for module in graph.modules() {
|
||||
let subgraph = graph.subgraph_by_module(&module);
|
||||
let output = self.renderer.render(&subgraph)?;
|
||||
self.writer.write(&output)?;
|
||||
}
|
||||
} else {
|
||||
let output = self.renderer.render(graph)?;
|
||||
self.writer.write(&output)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn writer(&self) -> &W {
|
||||
&self.writer
|
||||
}
|
||||
}
|
||||
344
crates/application/tests/analyze_codebase_tests.rs
Normal file
344
crates/application/tests/analyze_codebase_tests.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
mod fakes;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use archlens_application::queries::AnalyzeCodebase;
|
||||
use archlens_domain::{
|
||||
AnalysisConfig, AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError,
|
||||
FilePath, Language, Relationship, RelationshipKind, SourceFile,
|
||||
};
|
||||
|
||||
use fakes::{FakeFileDiscovery, FakeSourceAnalyzer};
|
||||
|
||||
#[test]
|
||||
fn analyzes_discovered_files_and_builds_code_graph() {
|
||||
let files = vec![
|
||||
SourceFile::new(FilePath::new("src/order.rs").unwrap(), Language::Rust),
|
||||
SourceFile::new(FilePath::new("src/service.rs").unwrap(), Language::Rust),
|
||||
];
|
||||
|
||||
let discovery = FakeFileDiscovery::new(files);
|
||||
|
||||
let analyzer = FakeSourceAnalyzer::new()
|
||||
.with_result(
|
||||
"src/order.rs",
|
||||
AnalysisResult::new(
|
||||
vec![
|
||||
CodeElement::new(
|
||||
"Order",
|
||||
CodeElementKind::Struct,
|
||||
FilePath::new("src/order.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
)
|
||||
.with_result(
|
||||
"src/service.rs",
|
||||
AnalysisResult::new(
|
||||
vec![
|
||||
CodeElement::new(
|
||||
"OrderService",
|
||||
CodeElementKind::Class,
|
||||
FilePath::new("src/service.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
vec![
|
||||
Relationship::new("OrderService", "Order", RelationshipKind::Composition)
|
||||
.unwrap(),
|
||||
],
|
||||
vec![],
|
||||
),
|
||||
);
|
||||
|
||||
let use_case = AnalyzeCodebase::new(discovery, analyzer);
|
||||
let result = use_case
|
||||
.execute(Path::new("."), &AnalysisConfig::default())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.graph().elements().len(), 2);
|
||||
assert_eq!(result.graph().relationships().len(), 1);
|
||||
assert!(result.warnings().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_list_returns_empty_graph() {
|
||||
let discovery = FakeFileDiscovery::empty();
|
||||
let analyzer = FakeSourceAnalyzer::new();
|
||||
|
||||
let use_case = AnalyzeCodebase::new(discovery, analyzer);
|
||||
let result = use_case
|
||||
.execute(Path::new("."), &AnalysisConfig::default())
|
||||
.unwrap();
|
||||
|
||||
assert!(result.graph().elements().is_empty());
|
||||
assert!(result.graph().relationships().is_empty());
|
||||
assert!(result.warnings().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_warnings_from_multiple_files() {
|
||||
let files = vec![
|
||||
SourceFile::new(FilePath::new("src/a.rs").unwrap(), Language::Rust),
|
||||
SourceFile::new(FilePath::new("src/b.rs").unwrap(), Language::Rust),
|
||||
];
|
||||
|
||||
let discovery = FakeFileDiscovery::new(files);
|
||||
let analyzer = FakeSourceAnalyzer::new()
|
||||
.with_result(
|
||||
"src/a.rs",
|
||||
AnalysisResult::new(
|
||||
vec![],
|
||||
vec![],
|
||||
vec![
|
||||
AnalysisWarning::new(FilePath::new("src/a.rs").unwrap(), 10, "unknown macro")
|
||||
.unwrap(),
|
||||
],
|
||||
),
|
||||
)
|
||||
.with_result(
|
||||
"src/b.rs",
|
||||
AnalysisResult::new(
|
||||
vec![],
|
||||
vec![],
|
||||
vec![
|
||||
AnalysisWarning::new(
|
||||
FilePath::new("src/b.rs").unwrap(),
|
||||
5,
|
||||
"unparseable block",
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
let use_case = AnalyzeCodebase::new(discovery, analyzer);
|
||||
let result = use_case
|
||||
.execute(Path::new("."), &AnalysisConfig::default())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.warnings().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_error_on_file_collects_warning_and_continues() {
|
||||
let files = vec![
|
||||
SourceFile::new(FilePath::new("src/good.rs").unwrap(), Language::Rust),
|
||||
SourceFile::new(FilePath::new("src/broken.rs").unwrap(), Language::Rust),
|
||||
];
|
||||
|
||||
let discovery = FakeFileDiscovery::new(files);
|
||||
let analyzer = FakeSourceAnalyzer::new()
|
||||
.with_result(
|
||||
"src/good.rs",
|
||||
AnalysisResult::new(
|
||||
vec![
|
||||
CodeElement::new(
|
||||
"Good",
|
||||
CodeElementKind::Struct,
|
||||
FilePath::new("src/good.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
)
|
||||
.with_error(
|
||||
"src/broken.rs",
|
||||
DomainError::AnalysisError("parse failed".to_string()),
|
||||
);
|
||||
|
||||
let use_case = AnalyzeCodebase::new(discovery, analyzer);
|
||||
let result = use_case
|
||||
.execute(Path::new("."), &AnalysisConfig::default())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.graph().elements().len(), 1);
|
||||
assert_eq!(result.graph().elements()[0].name(), "Good");
|
||||
assert_eq!(result.warnings().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_module_from_directory_structure() {
|
||||
let files = vec![
|
||||
SourceFile::new(
|
||||
FilePath::new("/project/src/orders/service.rs").unwrap(),
|
||||
Language::Rust,
|
||||
),
|
||||
SourceFile::new(
|
||||
FilePath::new("/project/src/billing/invoice.rs").unwrap(),
|
||||
Language::Rust,
|
||||
),
|
||||
SourceFile::new(
|
||||
FilePath::new("/project/src/lib.rs").unwrap(),
|
||||
Language::Rust,
|
||||
),
|
||||
];
|
||||
|
||||
let discovery = FakeFileDiscovery::new(files);
|
||||
let analyzer = FakeSourceAnalyzer::new()
|
||||
.with_result(
|
||||
"/project/src/orders/service.rs",
|
||||
AnalysisResult::new(
|
||||
vec![
|
||||
CodeElement::new(
|
||||
"OrderService",
|
||||
CodeElementKind::Class,
|
||||
FilePath::new("/project/src/orders/service.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
)
|
||||
.with_result(
|
||||
"/project/src/billing/invoice.rs",
|
||||
AnalysisResult::new(
|
||||
vec![
|
||||
CodeElement::new(
|
||||
"Invoice",
|
||||
CodeElementKind::Struct,
|
||||
FilePath::new("/project/src/billing/invoice.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
)
|
||||
.with_result(
|
||||
"/project/src/lib.rs",
|
||||
AnalysisResult::new(
|
||||
vec![
|
||||
CodeElement::new(
|
||||
"App",
|
||||
CodeElementKind::Struct,
|
||||
FilePath::new("/project/src/lib.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
);
|
||||
|
||||
let use_case = AnalyzeCodebase::new(discovery, analyzer);
|
||||
let result = use_case
|
||||
.execute(Path::new("/project"), &AnalysisConfig::default())
|
||||
.unwrap();
|
||||
|
||||
let order_svc = result
|
||||
.graph()
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "OrderService")
|
||||
.unwrap();
|
||||
assert_eq!(order_svc.module().unwrap().as_str(), "Orders");
|
||||
|
||||
let invoice = result
|
||||
.graph()
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "Invoice")
|
||||
.unwrap();
|
||||
assert_eq!(invoice.module().unwrap().as_str(), "Billing");
|
||||
|
||||
let app = result
|
||||
.graph()
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "App")
|
||||
.unwrap();
|
||||
assert!(app.module().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_nested_module_from_deep_directories() {
|
||||
let files = vec![SourceFile::new(
|
||||
FilePath::new("/project/src/orders/models/order.rs").unwrap(),
|
||||
Language::Rust,
|
||||
)];
|
||||
|
||||
let discovery = FakeFileDiscovery::new(files);
|
||||
let analyzer = FakeSourceAnalyzer::new().with_result(
|
||||
"/project/src/orders/models/order.rs",
|
||||
AnalysisResult::new(
|
||||
vec![
|
||||
CodeElement::new(
|
||||
"Order",
|
||||
CodeElementKind::Struct,
|
||||
FilePath::new("/project/src/orders/models/order.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
);
|
||||
|
||||
let use_case = AnalyzeCodebase::new(discovery, analyzer);
|
||||
let result = use_case
|
||||
.execute(Path::new("/project"), &AnalysisConfig::default())
|
||||
.unwrap();
|
||||
|
||||
let order = result
|
||||
.graph()
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "Order")
|
||||
.unwrap();
|
||||
assert_eq!(order.module().unwrap().as_str(), "Orders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_config_module_mappings() {
|
||||
let files = vec![SourceFile::new(
|
||||
FilePath::new("/project/src/infra/db.rs").unwrap(),
|
||||
Language::Rust,
|
||||
)];
|
||||
|
||||
let discovery = FakeFileDiscovery::new(files);
|
||||
let analyzer = FakeSourceAnalyzer::new().with_result(
|
||||
"/project/src/infra/db.rs",
|
||||
AnalysisResult::new(
|
||||
vec![
|
||||
CodeElement::new(
|
||||
"DbPool",
|
||||
CodeElementKind::Struct,
|
||||
FilePath::new("/project/src/infra/db.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
);
|
||||
|
||||
let mut mappings = std::collections::HashMap::new();
|
||||
mappings.insert("src/infra".to_string(), "Infrastructure".to_string());
|
||||
let config = AnalysisConfig::default().with_module_mappings(mappings);
|
||||
|
||||
let use_case = AnalyzeCodebase::new(discovery, analyzer);
|
||||
let result = use_case.execute(Path::new("/project"), &config).unwrap();
|
||||
|
||||
let db = result
|
||||
.graph()
|
||||
.elements()
|
||||
.iter()
|
||||
.find(|e| e.name() == "DbPool")
|
||||
.unwrap();
|
||||
assert_eq!(db.module().unwrap().as_str(), "Infrastructure");
|
||||
}
|
||||
17
crates/application/tests/fakes/diagram_renderer.rs
Normal file
17
crates/application/tests/fakes/diagram_renderer.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use archlens_domain::{CodeGraph, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer};
|
||||
|
||||
pub struct FakeDiagramRenderer;
|
||||
|
||||
impl FakeDiagramRenderer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagramRenderer for FakeDiagramRenderer {
|
||||
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
|
||||
let content = format!("graph with {} elements", graph.elements().len());
|
||||
let file = RenderedFile::new("output.mmd", &content)?;
|
||||
Ok(RenderOutput::single(file))
|
||||
}
|
||||
}
|
||||
27
crates/application/tests/fakes/file_discovery.rs
Normal file
27
crates/application/tests/fakes/file_discovery.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use std::path::Path;
|
||||
|
||||
use archlens_domain::{AnalysisConfig, DomainError, SourceFile, ports::FileDiscovery};
|
||||
|
||||
pub struct FakeFileDiscovery {
|
||||
files: Vec<SourceFile>,
|
||||
}
|
||||
|
||||
impl FakeFileDiscovery {
|
||||
pub fn new(files: Vec<SourceFile>) -> Self {
|
||||
Self { files }
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self { files: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl FileDiscovery for FakeFileDiscovery {
|
||||
fn discover(
|
||||
&self,
|
||||
_root: &Path,
|
||||
_config: &AnalysisConfig,
|
||||
) -> Result<Vec<SourceFile>, DomainError> {
|
||||
Ok(self.files.clone())
|
||||
}
|
||||
}
|
||||
9
crates/application/tests/fakes/mod.rs
Normal file
9
crates/application/tests/fakes/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod diagram_renderer;
|
||||
mod file_discovery;
|
||||
mod output_writer;
|
||||
mod source_analyzer;
|
||||
|
||||
pub use diagram_renderer::FakeDiagramRenderer;
|
||||
pub use file_discovery::FakeFileDiscovery;
|
||||
pub use output_writer::FakeOutputWriter;
|
||||
pub use source_analyzer::FakeSourceAnalyzer;
|
||||
26
crates/application/tests/fakes/output_writer.rs
Normal file
26
crates/application/tests/fakes/output_writer.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter};
|
||||
|
||||
pub struct FakeOutputWriter {
|
||||
written: RefCell<Vec<RenderOutput>>,
|
||||
}
|
||||
|
||||
impl FakeOutputWriter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
written: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn written_outputs(&self) -> Vec<RenderOutput> {
|
||||
self.written.borrow().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputWriter for FakeOutputWriter {
|
||||
fn write(&self, output: &RenderOutput) -> Result<(), DomainError> {
|
||||
self.written.borrow_mut().push(output.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
43
crates/application/tests/fakes/source_analyzer.rs
Normal file
43
crates/application/tests/fakes/source_analyzer.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use archlens_domain::{AnalysisResult, DomainError, SourceFile, ports::SourceAnalyzer};
|
||||
|
||||
enum FakeResponse {
|
||||
Success(AnalysisResult),
|
||||
Failure(DomainError),
|
||||
}
|
||||
|
||||
pub struct FakeSourceAnalyzer {
|
||||
results: HashMap<String, FakeResponse>,
|
||||
}
|
||||
|
||||
impl FakeSourceAnalyzer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
results: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_result(mut self, file_path: &str, result: AnalysisResult) -> Self {
|
||||
self.results
|
||||
.insert(file_path.to_string(), FakeResponse::Success(result));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_error(mut self, file_path: &str, error: DomainError) -> Self {
|
||||
self.results
|
||||
.insert(file_path.to_string(), FakeResponse::Failure(error));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SourceAnalyzer for FakeSourceAnalyzer {
|
||||
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError> {
|
||||
let key = file.path().as_str().to_string();
|
||||
match self.results.get(&key) {
|
||||
Some(FakeResponse::Success(result)) => Ok(result.clone()),
|
||||
Some(FakeResponse::Failure(_)) => Err(DomainError::AnalysisError(key)),
|
||||
None => Ok(AnalysisResult::empty()),
|
||||
}
|
||||
}
|
||||
}
|
||||
78
crates/application/tests/render_diagrams_tests.rs
Normal file
78
crates/application/tests/render_diagrams_tests.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
mod fakes;
|
||||
|
||||
use archlens_application::queries::RenderDiagrams;
|
||||
use archlens_domain::{
|
||||
CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, OutputConfig,
|
||||
};
|
||||
|
||||
use fakes::{FakeDiagramRenderer, FakeOutputWriter};
|
||||
|
||||
fn build_graph() -> CodeGraph {
|
||||
let mut graph = CodeGraph::new();
|
||||
graph.add_element(
|
||||
CodeElement::new(
|
||||
"OrderService",
|
||||
CodeElementKind::Class,
|
||||
FilePath::new("src/service.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.with_module(ModuleName::new("Orders").unwrap()),
|
||||
);
|
||||
graph.add_element(
|
||||
CodeElement::new(
|
||||
"BillingService",
|
||||
CodeElementKind::Class,
|
||||
FilePath::new("src/billing.rs").unwrap(),
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.with_module(ModuleName::new("Billing").unwrap()),
|
||||
);
|
||||
graph
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_single_diagram_and_writes_output() {
|
||||
let renderer = FakeDiagramRenderer::new();
|
||||
let writer = FakeOutputWriter::new();
|
||||
let config = OutputConfig::default();
|
||||
|
||||
let use_case = RenderDiagrams::new(renderer, writer);
|
||||
use_case.execute(&build_graph(), &config).unwrap();
|
||||
|
||||
let outputs = use_case.writer().written_outputs();
|
||||
assert_eq!(outputs.len(), 1);
|
||||
assert_eq!(outputs[0].files().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_mode_renders_overview_plus_per_module_diagrams() {
|
||||
let renderer = FakeDiagramRenderer::new();
|
||||
let writer = FakeOutputWriter::new();
|
||||
let config = OutputConfig::default().with_split_by_module(true);
|
||||
|
||||
let graph = build_graph(); // has 2 modules: Orders, Billing
|
||||
|
||||
let use_case = RenderDiagrams::new(renderer, writer);
|
||||
use_case.execute(&graph, &config).unwrap();
|
||||
|
||||
let outputs = use_case.writer().written_outputs();
|
||||
// 1 overview + 2 module diagrams = 3 writes
|
||||
assert_eq!(outputs.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_graph_still_produces_output() {
|
||||
let renderer = FakeDiagramRenderer::new();
|
||||
let writer = FakeOutputWriter::new();
|
||||
let config = OutputConfig::default();
|
||||
|
||||
let graph = CodeGraph::new();
|
||||
|
||||
let use_case = RenderDiagrams::new(renderer, writer);
|
||||
use_case.execute(&graph, &config).unwrap();
|
||||
|
||||
let outputs = use_case.writer().written_outputs();
|
||||
assert_eq!(outputs.len(), 1);
|
||||
}
|
||||
Reference in New Issue
Block a user