- CodeGraph::merge_project_edges() replaces presentation-layer function - Language::is_test_file() centralises test file detection (was in walkdir) - AnalysisConfig::is_standard_excluded() centralises default dir exclusions (was in walkdir) - normalize_cargo_package() / normalize_python_package() in domain replace duplicated normalisers in each adapter - walkdir, cargo-workspace, python-project updated to call domain methods
485 lines
14 KiB
Rust
485 lines
14 KiB
Rust
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_d2::D2Renderer;
|
|
use archlens_domain::{
|
|
BoundaryRule, CodeGraph, DiagramLevel, check_boundary_rules,
|
|
ports::{ConfigLoader, OutputWriter, ProjectAnalyzer},
|
|
};
|
|
use archlens_file_writer::FileOutputWriter;
|
|
use archlens_html::HtmlRenderer;
|
|
use archlens_mermaid::MermaidRenderer;
|
|
use archlens_python_project::PythonProjectAnalyzer;
|
|
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);
|
|
|
|
if args.watch {
|
|
return run_watch(args);
|
|
}
|
|
|
|
let level = parse_level(&args.level);
|
|
let config_loader = load_config(&args)?;
|
|
let graph = build_graph(&args, level)?;
|
|
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
|
let ext = format_extension(&args.format);
|
|
|
|
if args.check {
|
|
return check_freshness(&args.output, &graph, &*renderer);
|
|
}
|
|
|
|
// Boundary rule checking
|
|
let (raw_allow, raw_deny) = config_loader.load_rules();
|
|
let allow: Vec<BoundaryRule> = raw_allow
|
|
.iter()
|
|
.filter_map(|s| BoundaryRule::parse(s))
|
|
.collect();
|
|
let deny: Vec<BoundaryRule> = raw_deny
|
|
.iter()
|
|
.filter_map(|s| BoundaryRule::parse(s))
|
|
.collect();
|
|
if !allow.is_empty() || !deny.is_empty() {
|
|
let violations = check_boundary_rules(&graph, &allow, &deny);
|
|
for v in &violations {
|
|
eprintln!("RULE VIOLATION: {}", v.message());
|
|
}
|
|
if args.strict && !violations.is_empty() {
|
|
bail!(
|
|
"{} boundary rule violation(s) in strict mode",
|
|
violations.len()
|
|
);
|
|
}
|
|
}
|
|
|
|
if args.split_by_module {
|
|
write_split(&graph, &*renderer, &args.output, ext)?;
|
|
} else {
|
|
write_single(&graph, &*renderer, &args.output)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn load_config(args: &Cli) -> Result<TomlConfigLoader> {
|
|
match &args.config {
|
|
Some(path) => Ok(TomlConfigLoader::from_path(std::path::Path::new(path))?),
|
|
None => {
|
|
let default_path = args.path.join("archlens.toml");
|
|
if default_path.exists() {
|
|
Ok(TomlConfigLoader::from_path(&default_path)?)
|
|
} else {
|
|
Ok(TomlConfigLoader::default())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
|
|
let config_loader = load_config(args)?;
|
|
let mut analysis_config = config_loader.load_analysis_config()?;
|
|
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);
|
|
}
|
|
if args.include_tests {
|
|
analysis_config = analysis_config.with_include_tests(true);
|
|
}
|
|
if let Some(ref git_ref) = args.since {
|
|
let changed = get_changed_files(&args.path, git_ref)?;
|
|
analysis_config = analysis_config.with_changed_files(changed);
|
|
}
|
|
|
|
if level == DiagramLevel::Project {
|
|
let cargo_toml = args.path.join("Cargo.toml");
|
|
if cargo_toml.exists() {
|
|
return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?);
|
|
}
|
|
return Ok(PythonProjectAnalyzer::new().analyze(&args.path)?);
|
|
}
|
|
|
|
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");
|
|
let project_graph = if workspace_toml.exists() {
|
|
CargoWorkspaceAnalyzer::new().analyze(&args.path).ok()
|
|
} else {
|
|
PythonProjectAnalyzer::new().analyze(&args.path).ok()
|
|
};
|
|
if let Some(pg) = project_graph {
|
|
graph.merge_project_edges(&pg);
|
|
}
|
|
}
|
|
|
|
Ok(graph)
|
|
}
|
|
|
|
fn create_renderer(
|
|
format: &str,
|
|
level: DiagramLevel,
|
|
show_weights: bool,
|
|
) -> Result<Box<dyn archlens_domain::ports::DiagramRenderer>> {
|
|
match format {
|
|
"mermaid" => Ok(Box::new(
|
|
MermaidRenderer::with_level(level).with_weights(show_weights),
|
|
)),
|
|
"ascii" => Ok(Box::new(AsciiRenderer::new())),
|
|
"d2" => Ok(Box::new(D2Renderer::with_level(level))),
|
|
"html" => Ok(Box::new(HtmlRenderer::new())),
|
|
fmt => bail!("unknown format: {fmt}"),
|
|
}
|
|
}
|
|
|
|
fn format_extension(format: &str) -> &str {
|
|
match format {
|
|
"mermaid" => "mmd",
|
|
"d2" => "d2",
|
|
"html" => "html",
|
|
_ => "txt",
|
|
}
|
|
}
|
|
|
|
fn check_freshness(
|
|
output: &Option<String>,
|
|
graph: &CodeGraph,
|
|
renderer: &dyn archlens_domain::ports::DiagramRenderer,
|
|
) -> Result<()> {
|
|
let Some(path) = output else {
|
|
bail!("--check requires --output to specify the file to check against");
|
|
};
|
|
let rendered = renderer.render(graph)?;
|
|
let current = rendered.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.");
|
|
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 cross_deps = graph.cross_module_deps_for(&module);
|
|
let module_output = renderer.render(&subgraph)?;
|
|
let raw = module_output
|
|
.files()
|
|
.first()
|
|
.map(|f| f.content())
|
|
.unwrap_or("");
|
|
let content = renderer.append_cross_module_deps(raw, &module, &cross_deps);
|
|
let module_file = archlens_domain::RenderedFile::new(
|
|
&format!("{}.{ext}", module.as_str().to_lowercase()),
|
|
&content,
|
|
)?;
|
|
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 run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
|
|
init_tracing(args.verbose);
|
|
|
|
let level = parse_level(&args.level);
|
|
let graph = build_graph(args, level)?;
|
|
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
|
|
|
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
|
|
|
|
[rules]
|
|
# Allowed dependency directions between modules (if set, unlisted directions are violations)
|
|
# allow = ["Application --> Domain", "Adapters --> Domain"]
|
|
|
|
# Explicitly forbidden dependency directions (always checked)
|
|
# deny = ["Domain --> Adapters", "Domain --> Application"]
|
|
"#;
|
|
|
|
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();
|
|
}
|
|
|
|
fn get_changed_files(
|
|
root: &std::path::Path,
|
|
git_ref: &str,
|
|
) -> Result<std::collections::HashSet<String>> {
|
|
let output = std::process::Command::new("git")
|
|
.args(["diff", "--name-only", git_ref])
|
|
.current_dir(root)
|
|
.output()
|
|
.map_err(|e| anyhow::anyhow!("git not found: {e}"))?;
|
|
|
|
if !output.status.success() {
|
|
bail!(
|
|
"git diff failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
let files = String::from_utf8_lossy(&output.stdout)
|
|
.lines()
|
|
.map(|l| l.trim().to_string())
|
|
.filter(|l| !l.is_empty())
|
|
.collect();
|
|
Ok(files)
|
|
}
|
|
|
|
fn run_watch(args: Cli) -> Result<()> {
|
|
use notify::{RecursiveMode, Watcher, recommended_watcher};
|
|
use std::sync::mpsc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
let level = parse_level(&args.level);
|
|
let ext = format_extension(&args.format);
|
|
let debounce = Duration::from_millis(500);
|
|
|
|
let run_once = |args: &Cli| -> Result<()> {
|
|
let config_loader = load_config(args)?;
|
|
let graph = build_graph(args, level)?;
|
|
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
|
|
if args.split_by_module {
|
|
write_split(&graph, &*renderer, &args.output, ext)?;
|
|
} else {
|
|
write_single(&graph, &*renderer, &args.output)?;
|
|
}
|
|
let (raw_allow, raw_deny) = config_loader.load_rules();
|
|
let allow: Vec<BoundaryRule> = raw_allow
|
|
.iter()
|
|
.filter_map(|s| BoundaryRule::parse(s))
|
|
.collect();
|
|
let deny: Vec<BoundaryRule> = raw_deny
|
|
.iter()
|
|
.filter_map(|s| BoundaryRule::parse(s))
|
|
.collect();
|
|
if !allow.is_empty() || !deny.is_empty() {
|
|
let violations = check_boundary_rules(&graph, &allow, &deny);
|
|
for v in &violations {
|
|
eprintln!("RULE VIOLATION: {}", v.message());
|
|
}
|
|
}
|
|
Ok(())
|
|
};
|
|
|
|
eprintln!(
|
|
"Watching {} for changes (Ctrl+C to stop)...",
|
|
args.path.display()
|
|
);
|
|
if let Err(e) = run_once(&args) {
|
|
eprintln!("Error: {e}");
|
|
} else {
|
|
eprintln!("Diagram generated.");
|
|
}
|
|
|
|
let (tx, rx) = mpsc::channel();
|
|
let mut watcher = recommended_watcher(move |res| {
|
|
let _ = tx.send(res);
|
|
})?;
|
|
watcher.watch(&args.path, RecursiveMode::Recursive)?;
|
|
|
|
let mut last_run = Instant::now();
|
|
loop {
|
|
match rx.recv() {
|
|
Ok(_) => {
|
|
if last_run.elapsed() < debounce {
|
|
continue;
|
|
}
|
|
last_run = Instant::now();
|
|
eprintln!("Change detected, regenerating...");
|
|
if let Err(e) = run_once(&args) {
|
|
eprintln!("Error: {e}");
|
|
} else {
|
|
eprintln!("Diagram updated.");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Watch error: {e}");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|