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,10 @@
[package]
name = "archlens-mermaid"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,3 @@
mod mermaid_renderer;
pub use mermaid_renderer::MermaidRenderer;

View File

@@ -0,0 +1,266 @@
use std::collections::{HashMap, HashSet};
use archlens_domain::{
CodeElement, CodeGraph, DiagramLevel, DomainError, RelationshipKind, RenderOutput,
RenderedFile, Visibility, ports::DiagramRenderer,
};
pub struct MermaidRenderer {
level: DiagramLevel,
}
impl Default for MermaidRenderer {
fn default() -> Self {
Self::new()
}
}
impl MermaidRenderer {
pub fn new() -> Self {
Self {
level: DiagramLevel::Type,
}
}
pub fn with_level(level: DiagramLevel) -> Self {
Self { level }
}
fn format_element_name(element: &CodeElement) -> String {
let name = element.name();
if element.generics().is_empty() {
name.to_string()
} else {
format!("{}~{}~", name, element.generics().join(", "))
}
}
fn format_visibility(visibility: Visibility) -> &'static str {
match visibility {
Visibility::Public => "public",
Visibility::Private => "private",
Visibility::Internal => "internal",
}
}
fn render_class_diagram(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["classDiagram".to_string()];
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
let has_namespaces = !grouped.is_empty();
let mut seen: HashSet<String> = HashSet::new();
for element in &ungrouped {
if seen.insert(element.name().to_string()) {
Self::push_class_lines(&mut lines, element, " ");
}
}
if has_namespaces {
for (namespace, elements) in &grouped {
lines.push(format!(" namespace {namespace} {{"));
let mut ns_seen: HashSet<String> = HashSet::new();
for element in elements {
if ns_seen.insert(element.name().to_string()) {
Self::push_class_lines(&mut lines, element, " ");
}
}
lines.push(" }".to_string());
}
}
let mut rel_seen: HashSet<String> = HashSet::new();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "<|--",
RelationshipKind::Composition => "-->",
RelationshipKind::Import => "..>",
};
let key = format!("{} {} {}", rel.source(), arrow, rel.target());
if rel_seen.insert(key.clone()) {
lines.push(format!(" {key}"));
}
}
lines.join("\n")
}
fn push_class_lines(lines: &mut Vec<String>, element: &CodeElement, indent: &str) {
lines.push(format!(
"{indent}class {}",
Self::format_element_name(element)
));
if element.visibility() != Visibility::Public {
lines.push(format!(
"{indent}<<{}>> {}",
Self::format_visibility(element.visibility()),
element.name()
));
}
let name = element.name();
for field in element.fields() {
lines.push(format!("{indent}{name} : {field}"));
}
for method in element.methods() {
lines.push(format!("{indent}{name} : {method}"));
}
}
fn render_module_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
let mut name_to_modules: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut file_to_module: HashMap<String, String> = HashMap::new();
let mut modules: HashSet<String> = HashSet::new();
for element in graph.elements() {
if let Some(module) = element.module() {
name_to_modules
.entry(element.name())
.or_default()
.insert(module.as_str());
modules.insert(module.as_str().to_string());
let file_stem = std::path::Path::new(element.file_path().as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if !file_stem.is_empty() {
file_to_module.insert(file_stem.to_string(), module.as_str().to_string());
}
}
}
for module in &modules {
lines.push(format!(" {module}[{module}]"));
}
let mut module_edges: HashSet<(String, String)> = HashSet::new();
for rel in graph.relationships() {
match rel.kind() {
RelationshipKind::Import => {
let source_mod = file_to_module.get(rel.source());
let target_top = rel.target().split('.').next().unwrap_or("");
let target_mod = Self::capitalize(target_top);
if let Some(src) = source_mod
&& modules.contains(&target_mod)
&& *src != target_mod
{
module_edges.insert((src.clone(), target_mod));
}
}
_ => {
if modules.contains(rel.source())
&& modules.contains(rel.target())
&& rel.source() != rel.target()
{
module_edges.insert((rel.source().to_string(), rel.target().to_string()));
continue;
}
let src_mods = name_to_modules.get(rel.source());
let tgt_mods = name_to_modules.get(rel.target());
if let (Some(src_set), Some(tgt_set)) = (src_mods, tgt_mods) {
for src_mod in src_set {
if tgt_set.contains(src_mod) {
continue;
}
for tgt_mod in tgt_set {
if src_mod != tgt_mod {
module_edges.insert((src_mod.to_string(), tgt_mod.to_string()));
}
}
}
}
}
}
}
for (source, target) in &module_edges {
lines.push(format!(" {source} --> {target}"));
}
lines.join("\n")
}
fn capitalize(s: &str) -> String {
if s.is_empty() {
return String::new();
}
format!("{}{}", s[..1].to_uppercase(), &s[1..])
}
fn render_project_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
for element in &ungrouped {
let id = Self::sanitize_id(element.name());
lines.push(format!(" {id}[{}]", element.name()));
}
for (group, elements) in &grouped {
lines.push(format!(" subgraph {group}"));
for element in elements {
let id = Self::sanitize_id(element.name());
lines.push(format!(" {id}[{}]", element.name()));
}
lines.push(" end".to_string());
}
for rel in graph.relationships() {
let source_id = Self::sanitize_id(rel.source());
let target_id = Self::sanitize_id(rel.target());
lines.push(format!(" {source_id} --> {target_id}"));
}
lines.join("\n")
}
fn sanitize_id(name: &str) -> String {
name.replace(['-', '.'], "_")
}
}
impl DiagramRenderer for MermaidRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let content = match self.level {
DiagramLevel::Type => self.render_class_diagram(graph),
DiagramLevel::Module => self.render_module_flowchart(graph),
DiagramLevel::Project => self.render_project_flowchart(graph),
};
let file = RenderedFile::new("diagram.mmd", &content)?;
Ok(RenderOutput::single(file))
}
}

View File

@@ -0,0 +1,328 @@
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, DiagramLevel, FilePath, ModuleName, Relationship,
RelationshipKind, Visibility, ports::DiagramRenderer,
};
use archlens_mermaid::MermaidRenderer;
fn build_type_level_graph() -> CodeGraph {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/service.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
graph
}
#[test]
fn renders_class_diagram_with_elements_and_composition() {
let renderer = MermaidRenderer::new();
let output = renderer.render(&build_type_level_graph()).unwrap();
let content = output.files()[0].content();
assert!(content.contains("classDiagram"));
assert!(content.contains("class OrderService"));
assert!(content.contains("class Order"));
assert!(content.contains("OrderService --> Order"));
}
#[test]
fn inheritance_uses_different_arrow() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"Animal",
CodeElementKind::Class,
FilePath::new("src/animal.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"Dog",
CodeElementKind::Class,
FilePath::new("src/dog.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("Dog", "Animal", RelationshipKind::Inheritance).unwrap(),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("Dog <|-- Animal"));
}
#[test]
fn elements_show_kind_and_generics() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"Repository",
CodeElementKind::Trait,
FilePath::new("src/repo.rs").unwrap(),
1,
)
.unwrap()
.with_generics(vec!["T".to_string()]),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("class Repository~T~"));
}
#[test]
fn private_elements_show_visibility_annotation() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"InternalHelper",
CodeElementKind::Class,
FilePath::new("src/helper.rs").unwrap(),
1,
)
.unwrap()
.with_visibility(Visibility::Private),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("class InternalHelper"));
assert!(content.contains("<<private>>"));
}
#[test]
fn renders_module_level_flowchart() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"BillingService",
CodeElementKind::Class,
FilePath::new("src/billing/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Billing").unwrap()),
);
graph.add_relationship(
Relationship::new(
"OrderService",
"BillingService",
RelationshipKind::Composition,
)
.unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("graph TD"));
assert!(content.contains("Orders"));
assert!(content.contains("Billing"));
assert!(content.contains("Orders --> Billing"));
}
#[test]
fn empty_graph_produces_valid_diagram() {
let renderer = MermaidRenderer::new();
let output = renderer.render(&CodeGraph::new()).unwrap();
let content = output.files()[0].content();
assert!(content.contains("classDiagram"));
}
#[test]
fn project_level_renders_subgraphs_for_grouped_projects() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"myapp-domain",
CodeElementKind::Project,
FilePath::new("crates/domain/Cargo.toml").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"myapp-sqlite",
CodeElementKind::Project,
FilePath::new("crates/adapters/sqlite/Cargo.toml").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Adapters").unwrap()),
);
graph.add_element(
CodeElement::new(
"myapp-nats",
CodeElementKind::Project,
FilePath::new("crates/adapters/nats/Cargo.toml").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Adapters").unwrap()),
);
graph.add_relationship(
Relationship::new(
"myapp-sqlite",
"myapp-domain",
RelationshipKind::Composition,
)
.unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Project);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("graph TD"));
assert!(content.contains("subgraph Adapters"));
assert!(content.contains("myapp-sqlite"));
assert!(content.contains("myapp-nats"));
assert!(content.contains("myapp-domain"));
assert!(content.contains("-->"));
}
#[test]
fn type_level_groups_elements_by_module() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/orders/order.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"Invoice",
CodeElementKind::Struct,
FilePath::new("src/billing/invoice.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Billing").unwrap()),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("namespace Orders"));
assert!(content.contains("namespace Billing"));
assert!(content.contains("OrderService"));
assert!(content.contains("Invoice"));
}
#[test]
fn module_level_aggregates_cross_module_deps_into_single_arrow() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"OrderRepo",
CodeElementKind::Trait,
FilePath::new("src/orders/repo.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"DbPool",
CodeElementKind::Struct,
FilePath::new("src/infra/db.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Infra").unwrap()),
);
graph.add_element(
CodeElement::new(
"SqliteRepo",
CodeElementKind::Struct,
FilePath::new("src/infra/sqlite.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Infra").unwrap()),
);
// Two types in Orders depend on two types in Infra
graph.add_relationship(
Relationship::new("OrderService", "DbPool", RelationshipKind::Composition).unwrap(),
);
graph.add_relationship(
Relationship::new("OrderRepo", "SqliteRepo", RelationshipKind::Composition).unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
let arrow_count = content.matches("Orders --> Infra").count();
assert_eq!(
arrow_count, 1,
"should have exactly one aggregated arrow, got:\n{content}"
);
}