init: archlens — architecture diagram generator
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:
2026-06-16 16:13:04 +02:00
commit 35f27d00b0
106 changed files with 6744 additions and 0 deletions

View 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");
}

View 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))
}
}

View 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())
}
}

View 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;

View 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(())
}
}

View 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()),
}
}
}

View 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);
}