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