Files
archlens/crates/presentation/src/lib.rs
Gabriel Kaszewski 009c821f48
All checks were successful
CI / Check / Test (push) Successful in 3m1s
Architecture Docs / Generate diagrams (push) Successful in 2m43s
style: cargo fmt
2026-06-17 13:44:22 +02:00

415 lines
12 KiB
Rust

mod cli;
use anyhow::{Result, bail};
use archlens_application::use_cases::{
build_code_graph::BuildCodeGraph, check_freshness::CheckFreshness, diff_diagram::DiffDiagram,
generate_diagram::GenerateDiagram,
};
use archlens_ascii::AsciiRenderer;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_d2::D2Renderer;
use archlens_domain::{
BoundaryRule, DiagramLevel, NormalizedGraph, RenderOutput,
ports::{ConfigLoader, ProjectAnalyzer},
};
use archlens_html::HtmlRenderer;
use archlens_mermaid::MermaidRenderer;
use archlens_python_project::PythonProjectAnalyzer;
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)?;
if args.check {
let existing_path = args.output.as_ref().ok_or_else(|| {
anyhow::anyhow!("--check requires --output to specify the file to check against")
})?;
let existing_content = std::fs::read_to_string(existing_path)
.map_err(|e| anyhow::anyhow!("cannot read {existing_path}: {e}"))?;
let up_to_date = CheckFreshness {
graph: &graph,
renderer: &*renderer,
existing_content: &existing_content,
}
.execute()?;
if up_to_date {
println!("Architecture diagram is up to date.");
} else {
eprintln!("Architecture diagram is outdated: {existing_path}");
std::process::exit(1);
}
return Ok(());
}
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();
let use_case = GenerateDiagram {
graph,
renderer,
allow_rules: allow,
deny_rules: deny,
split_by_module: args.split_by_module,
};
let result = use_case.execute()?;
if args.strict && !result.violations.is_empty() {
bail!(
"{} boundary rule violation(s) in strict mode",
result.violations.len()
);
}
for v in &result.violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
write_diagram_output(&result.output, args.output.as_deref(), args.split_by_module)?;
Ok(())
}
fn write_diagram_output(
output: &RenderOutput,
output_path: Option<&str>,
split: bool,
) -> Result<()> {
if split {
let dir = std::path::PathBuf::from(output_path.unwrap_or("."));
std::fs::create_dir_all(&dir)?;
for file in output.files() {
std::fs::write(dir.join(file.name()), file.content())?;
}
} else if let Some(path) = output_path {
let p = std::path::Path::new(path);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
let content = output.files().first().map(|f| f.content()).unwrap_or("");
std::fs::write(p, content)?;
} else {
let content = output.files().first().map(|f| f.content()).unwrap_or("");
print!("{content}");
}
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<NormalizedGraph> {
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);
}
let project_analyzer: Option<Box<dyn ProjectAnalyzer>> = {
let cargo_toml = args.path.join("Cargo.toml");
let pyproject = args.path.join("pyproject.toml");
if cargo_toml.exists() {
Some(Box::new(CargoWorkspaceAnalyzer::new()))
} else if pyproject.exists() {
Some(Box::new(PythonProjectAnalyzer::new()))
} else {
None
}
};
let use_case = BuildCodeGraph {
discovery: WalkdirDiscovery::new(),
source_analyzer: TreeSitterAnalyzer::new(),
project_analyzer,
};
let result = use_case.execute(&args.path, &analysis_config, level)?;
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()
);
}
}
Ok(result.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 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 existing_content = std::fs::read_to_string(existing_path)
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", existing_path.display()))?;
let diff = DiffDiagram {
graph: &graph,
renderer: &*renderer,
existing_content: &existing_content,
}
.execute()?;
if diff.is_empty() {
println!("No changes detected.");
return Ok(());
}
for line in &diff.removed {
println!("{line}");
}
for line in &diff.added {
println!("{line}");
}
println!(
"\n{} added, {} removed",
diff.added.len(),
diff.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 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)?;
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();
let use_case = GenerateDiagram {
graph,
renderer,
allow_rules: allow,
deny_rules: deny,
split_by_module: args.split_by_module,
};
let result = use_case.execute()?;
write_diagram_output(&result.output, args.output.as_deref(), args.split_by_module)?;
for v in &result.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(())
}