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,28 @@
[package]
name = "archlens"
version = "0.1.0"
edition = "2024"
publish = false
[[bin]]
name = "archlens"
path = "src/main.rs"
[dependencies]
archlens-domain.workspace = true
archlens-application.workspace = true
archlens-tree-sitter.workspace = true
archlens-walkdir.workspace = true
archlens-mermaid.workspace = true
archlens-ascii.workspace = true
archlens-file-writer.workspace = true
archlens-stdout-writer.workspace = true
archlens-toml-config.workspace = true
archlens-cargo-workspace.workspace = true
anyhow.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,61 @@
use std::path::PathBuf;
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(
name = "archlens",
about = "Generate architecture diagrams from source code"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
#[arg(default_value = ".")]
pub path: PathBuf,
#[arg(long, default_value = "module")]
pub level: String,
#[arg(long, default_value = "mermaid")]
pub format: String,
#[arg(long)]
pub output: Option<String>,
#[arg(long)]
pub config: Option<String>,
#[arg(long)]
pub scope: Option<String>,
#[arg(long)]
pub exclude: Vec<String>,
#[arg(long)]
pub split_by_module: bool,
#[arg(long)]
pub strict: bool,
#[arg(
long,
help = "Check if output matches existing file, exit 1 if different"
)]
pub check: bool,
#[arg(short, long, action = clap::ArgAction::Count)]
pub verbose: u8,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Init {
#[arg(default_value = ".")]
path: PathBuf,
},
Diff {
#[arg(help = "Path to existing diagram file to compare against")]
existing: PathBuf,
},
}

View File

@@ -0,0 +1,392 @@
mod cli;
use std::path::PathBuf;
use anyhow::{Result, bail};
use archlens_application::queries::AnalyzeCodebase;
use archlens_ascii::AsciiRenderer;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_domain::{
CodeGraph, DiagramLevel,
ports::{ConfigLoader, OutputWriter, ProjectAnalyzer},
};
use archlens_file_writer::FileOutputWriter;
use archlens_mermaid::MermaidRenderer;
use archlens_stdout_writer::StdoutOutputWriter;
use archlens_toml_config::TomlConfigLoader;
use archlens_tree_sitter::TreeSitterAnalyzer;
use archlens_walkdir::WalkdirDiscovery;
pub use cli::{Cli, Command};
pub type CliArgs = Cli;
pub fn run(args: Cli) -> Result<()> {
match &args.command {
Some(Command::Init { path }) => return init_config(path),
Some(Command::Diff { existing }) => return run_diff(&args, existing),
None => {}
}
init_tracing(args.verbose);
let config_loader = match &args.config {
Some(path) => TomlConfigLoader::from_path(std::path::Path::new(path))?,
None => {
let default_path = args.path.join("archlens.toml");
if default_path.exists() {
TomlConfigLoader::from_path(&default_path)?
} else {
TomlConfigLoader::default()
}
}
};
let mut analysis_config = config_loader.load_analysis_config()?;
let level = parse_level(&args.level);
analysis_config = analysis_config.with_level(level);
if let Some(ref scope) = args.scope {
analysis_config = analysis_config.with_scope(scope.clone());
}
if !args.exclude.is_empty() {
let mut excludes = analysis_config.excludes().to_vec();
excludes.extend(args.exclude.iter().cloned());
analysis_config = analysis_config.with_excludes(excludes);
}
let graph = if level == DiagramLevel::Project {
let project_analyzer = CargoWorkspaceAnalyzer::new();
project_analyzer.analyze(&args.path)?
} else {
let discovery = WalkdirDiscovery::new();
let analyzer = TreeSitterAnalyzer::new();
let analyze = AnalyzeCodebase::new(discovery, analyzer);
let result = analyze.execute(&args.path, &analysis_config)?;
if !result.warnings().is_empty() {
for warning in result.warnings() {
eprintln!(
"WARNING: {}:{} {}",
warning.file_path().as_str(),
warning.line(),
warning.message()
);
}
if args.strict {
bail!(
"analysis produced {} warning(s) in strict mode",
result.warnings().len()
);
}
}
let mut graph = result.graph().clone();
if level == DiagramLevel::Module {
let workspace_toml = args.path.join("Cargo.toml");
if workspace_toml.exists()
&& let Ok(project_graph) = CargoWorkspaceAnalyzer::new().analyze(&args.path)
{
merge_project_deps_as_module_edges(&mut graph, &project_graph);
}
}
graph
};
let renderer: Box<dyn archlens_domain::ports::DiagramRenderer> = match &args.format[..] {
"mermaid" => Box::new(MermaidRenderer::with_level(level)),
"ascii" => Box::new(AsciiRenderer::new()),
fmt => bail!("unknown format: {fmt}"),
};
let ext = match &args.format[..] {
"mermaid" => "mmd",
_ => "txt",
};
if args.check {
if let Some(ref path) = args.output {
let output = renderer.render(&graph)?;
let current = output.files().first().map(|f| f.content()).unwrap_or("");
let existing = std::fs::read_to_string(path).unwrap_or_default();
if current != existing {
eprintln!("Architecture diagram is outdated: {path}");
std::process::exit(1);
}
println!("Architecture diagram is up to date.");
return Ok(());
} else {
bail!("--check requires --output to specify the file to check against");
}
}
if args.split_by_module {
write_split(&graph, &*renderer, &args.output, ext)?;
} else {
write_single(&graph, &*renderer, &args.output)?;
}
Ok(())
}
fn write_split(
graph: &CodeGraph,
renderer: &dyn archlens_domain::ports::DiagramRenderer,
output: &Option<String>,
ext: &str,
) -> Result<()> {
let output_dir = output
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let writer = FileOutputWriter::new(output_dir);
let overview = renderer.render(graph)?;
let overview_file = archlens_domain::RenderedFile::new(
&format!("overview.{ext}"),
overview.files().first().map(|f| f.content()).unwrap_or(""),
)?;
writer.write(&archlens_domain::RenderOutput::single(overview_file))?;
for module in graph.modules() {
let subgraph = graph.subgraph_by_module(&module);
let module_output = renderer.render(&subgraph)?;
let module_file = archlens_domain::RenderedFile::new(
&format!("{}.{ext}", module.as_str().to_lowercase()),
module_output
.files()
.first()
.map(|f| f.content())
.unwrap_or(""),
)?;
writer.write(&archlens_domain::RenderOutput::single(module_file))?;
}
Ok(())
}
fn write_single(
graph: &CodeGraph,
renderer: &dyn archlens_domain::ports::DiagramRenderer,
output: &Option<String>,
) -> Result<()> {
let rendered = renderer.render(graph)?;
match output {
Some(path) => {
let writer = FileOutputWriter::single_file(PathBuf::from(path));
writer.write(&rendered)?;
}
None => {
let writer = StdoutOutputWriter::new();
writer.write(&rendered)?;
}
}
Ok(())
}
fn merge_project_deps_as_module_edges(
graph: &mut archlens_domain::CodeGraph,
project_graph: &archlens_domain::CodeGraph,
) {
use std::collections::HashMap;
let mut crate_to_module: HashMap<&str, &str> = HashMap::new();
for element in project_graph.elements() {
let module = element
.module()
.map(|m| m.as_str())
.unwrap_or(element.name());
crate_to_module.insert(element.name(), module);
}
let graph_modules: std::collections::HashSet<String> = graph
.modules()
.iter()
.map(|m| m.as_str().to_string())
.collect();
for rel in project_graph.relationships() {
let src_module = crate_to_module.get(rel.source());
let tgt_module = crate_to_module.get(rel.target());
if let (Some(src), Some(tgt)) = (src_module, tgt_module) {
let src_cap = capitalize(src);
let tgt_cap = capitalize(tgt);
if src_cap != tgt_cap
&& graph_modules.contains(&src_cap)
&& graph_modules.contains(&tgt_cap)
&& let Ok(edge) = archlens_domain::Relationship::new(
&src_cap,
&tgt_cap,
archlens_domain::RelationshipKind::Composition,
)
{
graph.add_relationship(edge);
}
}
}
}
fn capitalize(s: &str) -> String {
s.split('-')
.map(|seg| {
if seg.is_empty() {
String::new()
} else {
format!("{}{}", seg[..1].to_uppercase(), &seg[1..])
}
})
.collect::<Vec<_>>()
.join("-")
}
fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
init_tracing(args.verbose);
let config_loader = match &args.config {
Some(path) => TomlConfigLoader::from_path(std::path::Path::new(path))?,
None => {
let default_path = args.path.join("archlens.toml");
if default_path.exists() {
TomlConfigLoader::from_path(&default_path)?
} else {
TomlConfigLoader::default()
}
}
};
let mut analysis_config = config_loader.load_analysis_config()?;
let level = parse_level(&args.level);
analysis_config = analysis_config.with_level(level);
let graph = if level == DiagramLevel::Project {
CargoWorkspaceAnalyzer::new().analyze(&args.path)?
} else {
let discovery = WalkdirDiscovery::new();
let analyzer = TreeSitterAnalyzer::new();
let analyze = AnalyzeCodebase::new(discovery, analyzer);
let result = analyze.execute(&args.path, &analysis_config)?;
let mut graph = result.graph().clone();
if level == DiagramLevel::Module {
let workspace_toml = args.path.join("Cargo.toml");
if workspace_toml.exists()
&& let Ok(project_graph) = CargoWorkspaceAnalyzer::new().analyze(&args.path)
{
merge_project_deps_as_module_edges(&mut graph, &project_graph);
}
}
graph
};
let renderer: Box<dyn archlens_domain::ports::DiagramRenderer> = match &args.format[..] {
"mermaid" => Box::new(MermaidRenderer::with_level(level)),
"ascii" => Box::new(AsciiRenderer::new()),
fmt => bail!("unknown format: {fmt}"),
};
let output = renderer.render(&graph)?;
let current = output.files().first().map(|f| f.content()).unwrap_or("");
let existing = std::fs::read_to_string(existing_path).unwrap_or_default();
if current == existing {
println!("No changes detected.");
return Ok(());
}
let current_lines: Vec<&str> = current.lines().collect();
let existing_lines: Vec<&str> = existing.lines().collect();
let mut added = Vec::new();
let mut removed = Vec::new();
for line in &current_lines {
if !existing_lines.contains(line) {
added.push(*line);
}
}
for line in &existing_lines {
if !current_lines.contains(line) {
removed.push(*line);
}
}
if !removed.is_empty() {
println!("Removed:");
for line in &removed {
println!(" - {line}");
}
}
if !added.is_empty() {
println!("Added:");
for line in &added {
println!(" + {line}");
}
}
println!("\n{} added, {} removed", added.len(), removed.len());
std::process::exit(1);
}
fn init_config(path: &std::path::Path) -> Result<()> {
let config_path = path.join("archlens.toml");
if config_path.exists() {
bail!("archlens.toml already exists at {}", config_path.display());
}
let content = r#"[analysis]
# Directories to exclude from analysis
exclude = ["tests/", "vendor/", "generated/"]
# Default granularity: "module", "type", or "project"
level = "module"
[modules]
# Map directories to module names (overrides auto-detection)
# "src/infra" = "Infrastructure"
# "src/api" = "API"
[output]
# Default output format
format = "mermaid"
# Default output path (omit for stdout)
# path = "docs/architecture.mmd"
# Generate separate files per module
split_by_module = false
"#;
std::fs::write(&config_path, content)?;
println!("Created {}", config_path.display());
Ok(())
}
fn parse_level(level: &str) -> DiagramLevel {
match level {
"type" => DiagramLevel::Type,
"project" => DiagramLevel::Project,
_ => DiagramLevel::Module,
}
}
fn init_tracing(verbosity: u8) {
let filter = match verbosity {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
};
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)),
)
.try_init()
.ok();
}

View File

@@ -0,0 +1,9 @@
use anyhow::Result;
use clap::Parser;
use archlens::Cli;
fn main() -> Result<()> {
let args = Cli::parse();
archlens::run(args)
}

View File

@@ -0,0 +1,133 @@
use std::fs;
use archlens::run;
fn create_rust_project(dir: &std::path::Path) {
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(
dir.join("src/order.rs"),
"pub struct Order {\n pub id: u64,\n}\n",
)
.unwrap();
fs::write(
dir.join("src/service.rs"),
"pub struct OrderService {\n order: Order,\n}\n",
)
.unwrap();
}
fn create_multi_module_project(dir: &std::path::Path) {
fs::create_dir_all(dir.join("src/orders")).unwrap();
fs::create_dir_all(dir.join("src/billing")).unwrap();
fs::write(
dir.join("src/orders/order.rs"),
"pub struct Order {\n pub id: u64,\n}\n",
)
.unwrap();
fs::write(
dir.join("src/orders/service.rs"),
"pub struct OrderService {\n order: Order,\n}\n",
)
.unwrap();
fs::write(
dir.join("src/billing/invoice.rs"),
"pub struct Invoice {\n pub total: f64,\n}\n",
)
.unwrap();
}
#[test]
fn analyzes_rust_project_and_writes_mermaid_to_file() {
let project = tempfile::tempdir().unwrap();
create_rust_project(project.path());
let output_dir = tempfile::tempdir().unwrap();
let output_file = output_dir.path().join("arch.mmd");
run(archlens::CliArgs {
command: None,
path: project.path().to_path_buf(),
level: "type".to_string(),
format: "mermaid".to_string(),
output: Some(output_file.to_str().unwrap().to_string()),
config: None,
scope: None,
exclude: vec![],
split_by_module: false,
strict: false,
check: false,
verbose: 0,
})
.unwrap();
let content = fs::read_to_string(&output_file).unwrap();
assert!(content.contains("classDiagram"));
assert!(content.contains("Order"));
assert!(content.contains("OrderService"));
}
#[test]
fn works_without_config_file() {
let project = tempfile::tempdir().unwrap();
create_rust_project(project.path());
let output_dir = tempfile::tempdir().unwrap();
let output_file = output_dir.path().join("arch.mmd");
let result = run(archlens::CliArgs {
command: None,
path: project.path().to_path_buf(),
level: "type".to_string(),
format: "mermaid".to_string(),
output: Some(output_file.to_str().unwrap().to_string()),
config: None,
scope: None,
exclude: vec![],
split_by_module: false,
strict: false,
check: false,
verbose: 0,
});
assert!(result.is_ok());
}
#[test]
fn split_by_module_writes_overview_and_per_module_files() {
let project = tempfile::tempdir().unwrap();
create_multi_module_project(project.path());
let output_dir = tempfile::tempdir().unwrap();
run(archlens::CliArgs {
command: None,
path: project.path().to_path_buf(),
level: "module".to_string(),
format: "mermaid".to_string(),
output: Some(output_dir.path().to_str().unwrap().to_string()),
config: None,
scope: None,
exclude: vec![],
split_by_module: true,
strict: false,
check: false,
verbose: 0,
})
.unwrap();
let overview = output_dir.path().join("overview.mmd");
assert!(overview.exists(), "overview.mmd should exist");
let overview_content = fs::read_to_string(&overview).unwrap();
assert!(overview_content.contains("graph TD") || overview_content.contains("classDiagram"));
let entries: Vec<_> = fs::read_dir(output_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert!(
entries.len() > 1,
"should have overview + at least one module file"
);
}