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 = raw_allow .iter() .filter_map(|s| BoundaryRule::parse(s)) .collect(); let deny: Vec = 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 { 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 { 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> { 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, 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, 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, ) -> 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> { 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 = raw_allow .iter() .filter_map(|s| BoundaryRule::parse(s)) .collect(); let deny: Vec = 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(()) }