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