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:
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