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:
28
crates/presentation/Cargo.toml
Normal file
28
crates/presentation/Cargo.toml
Normal 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
|
||||
61
crates/presentation/src/cli.rs
Normal file
61
crates/presentation/src/cli.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
392
crates/presentation/src/lib.rs
Normal file
392
crates/presentation/src/lib.rs
Normal 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 ¤t_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();
|
||||
}
|
||||
9
crates/presentation/src/main.rs
Normal file
9
crates/presentation/src/main.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
|
||||
use archlens::Cli;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Cli::parse();
|
||||
archlens::run(args)
|
||||
}
|
||||
133
crates/presentation/tests/end_to_end_tests.rs
Normal file
133
crates/presentation/tests/end_to_end_tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user