diff --git a/crates/adapters/ascii/src/ascii_renderer.rs b/crates/adapters/ascii/src/ascii_renderer.rs index d37a884..53901f9 100644 --- a/crates/adapters/ascii/src/ascii_renderer.rs +++ b/crates/adapters/ascii/src/ascii_renderer.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use archlens_domain::{ CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile, ports::DiagramRenderer, @@ -55,19 +53,7 @@ impl DiagramRenderer for AsciiRenderer { return Ok(RenderOutput::single(file)); } - let mut grouped: HashMap> = HashMap::new(); - let mut ungrouped: Vec<&CodeElement> = Vec::new(); - - for element in graph.elements() { - if let Some(module) = element.module() { - grouped - .entry(module.as_str().to_string()) - .or_default() - .push(element); - } else { - ungrouped.push(element); - } - } + let (grouped, ungrouped) = graph.elements_by_module(); if !ungrouped.is_empty() { lines.push(String::new()); diff --git a/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs b/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs index 2647668..7469fac 100644 --- a/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs +++ b/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs @@ -82,7 +82,7 @@ impl ProjectAnalyzer for CargoWorkspaceAnalyzer { let mut element = CodeElement::new(package_name, CodeElementKind::Project, file_path, 1)?; - if let Some(module) = infer_group(member_path) { + if let Some(module) = ModuleName::from_directory_group(member_path) { element = element.with_module(module); } @@ -110,14 +110,3 @@ impl ProjectAnalyzer for CargoWorkspaceAnalyzer { Ok(graph) } } - -fn infer_group(member_path: &str) -> Option { - let parts: Vec<&str> = member_path.split('/').collect(); - if parts.len() < 3 { - return None; - } - - let group = parts[parts.len() - 2]; - let capitalized = format!("{}{}", group[..1].to_uppercase(), &group[1..]); - ModuleName::new(&capitalized).ok() -} diff --git a/crates/adapters/mermaid/src/mermaid_renderer.rs b/crates/adapters/mermaid/src/mermaid_renderer.rs index 8b70c5e..8c6d96b 100644 --- a/crates/adapters/mermaid/src/mermaid_renderer.rs +++ b/crates/adapters/mermaid/src/mermaid_renderer.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use archlens_domain::{ - CodeElement, CodeGraph, DiagramLevel, DomainError, RelationshipKind, RenderOutput, + CodeElement, CodeGraph, DiagramLevel, DomainError, ModuleName, RelationshipKind, RenderOutput, RenderedFile, Visibility, ports::DiagramRenderer, }; @@ -46,20 +46,7 @@ impl MermaidRenderer { fn render_class_diagram(&self, graph: &CodeGraph) -> String { let mut lines = vec!["classDiagram".to_string()]; - let mut grouped: HashMap> = HashMap::new(); - let mut ungrouped: Vec<&CodeElement> = Vec::new(); - - for element in graph.elements() { - if let Some(module) = element.module() { - grouped - .entry(module.as_str().to_string()) - .or_default() - .push(element); - } else { - ungrouped.push(element); - } - } - + let (grouped, ungrouped) = graph.elements_by_module(); let has_namespaces = !grouped.is_empty(); let mut seen: HashSet = HashSet::new(); @@ -157,7 +144,7 @@ impl MermaidRenderer { RelationshipKind::Import => { let source_mod = file_to_module.get(rel.source()); let target_top = rel.target().split('.').next().unwrap_or(""); - let target_mod = Self::capitalize(target_top); + let target_mod = ModuleName::capitalize(target_top); if let Some(src) = source_mod && modules.contains(&target_mod) @@ -201,29 +188,10 @@ impl MermaidRenderer { lines.join("\n") } - fn capitalize(s: &str) -> String { - if s.is_empty() { - return String::new(); - } - format!("{}{}", s[..1].to_uppercase(), &s[1..]) - } - fn render_project_flowchart(&self, graph: &CodeGraph) -> String { let mut lines = vec!["graph TD".to_string()]; - let mut grouped: HashMap> = HashMap::new(); - let mut ungrouped: Vec<&CodeElement> = Vec::new(); - - for element in graph.elements() { - if let Some(module) = element.module() { - grouped - .entry(module.as_str().to_string()) - .or_default() - .push(element); - } else { - ungrouped.push(element); - } - } + let (grouped, ungrouped) = graph.elements_by_module(); for element in &ungrouped { let id = Self::sanitize_id(element.name()); diff --git a/crates/adapters/tree-sitter/src/language_extractor.rs b/crates/adapters/tree-sitter/src/language_extractor.rs new file mode 100644 index 0000000..4f17008 --- /dev/null +++ b/crates/adapters/tree-sitter/src/language_extractor.rs @@ -0,0 +1,5 @@ +use archlens_domain::{AnalysisResult, DomainError, FilePath}; + +pub trait LanguageExtractor { + fn analyze(&self, source: &str, file_path: &FilePath) -> Result; +} diff --git a/crates/adapters/tree-sitter/src/lib.rs b/crates/adapters/tree-sitter/src/lib.rs index b2f45c4..72b2597 100644 --- a/crates/adapters/tree-sitter/src/lib.rs +++ b/crates/adapters/tree-sitter/src/lib.rs @@ -1,3 +1,4 @@ +mod language_extractor; mod python; mod rust; mod tree_sitter_analyzer; diff --git a/crates/adapters/tree-sitter/src/python/mod.rs b/crates/adapters/tree-sitter/src/python/mod.rs index a4d6a74..8b9c37f 100644 --- a/crates/adapters/tree-sitter/src/python/mod.rs +++ b/crates/adapters/tree-sitter/src/python/mod.rs @@ -7,6 +7,16 @@ use archlens_domain::{ Relationship, RelationshipKind, }; +use crate::language_extractor::LanguageExtractor; + +pub struct PythonExtractor; + +impl LanguageExtractor for PythonExtractor { + fn analyze(&self, source: &str, file_path: &FilePath) -> Result { + analyze(source, file_path) + } +} + pub fn analyze(source: &str, file_path: &FilePath) -> Result { let mut parser = Parser::new(); parser diff --git a/crates/adapters/tree-sitter/src/rust/mod.rs b/crates/adapters/tree-sitter/src/rust/mod.rs index 36f350f..fed838a 100644 --- a/crates/adapters/tree-sitter/src/rust/mod.rs +++ b/crates/adapters/tree-sitter/src/rust/mod.rs @@ -42,6 +42,16 @@ use archlens_domain::{ Relationship, RelationshipKind, Visibility, }; +use crate::language_extractor::LanguageExtractor; + +pub struct RustExtractor; + +impl LanguageExtractor for RustExtractor { + fn analyze(&self, source: &str, file_path: &FilePath) -> Result { + analyze(source, file_path) + } +} + pub fn analyze(source: &str, file_path: &FilePath) -> Result { let mut parser = Parser::new(); parser diff --git a/crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs b/crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs index 30806f6..1f23285 100644 --- a/crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs +++ b/crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs @@ -1,8 +1,13 @@ use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer}; -use crate::{python, rust}; +use crate::language_extractor::LanguageExtractor; +use crate::python::PythonExtractor; +use crate::rust::RustExtractor; -pub struct TreeSitterAnalyzer; +pub struct TreeSitterAnalyzer { + rust: RustExtractor, + python: PythonExtractor, +} impl Default for TreeSitterAnalyzer { fn default() -> Self { @@ -12,7 +17,18 @@ impl Default for TreeSitterAnalyzer { impl TreeSitterAnalyzer { pub fn new() -> Self { - Self + Self { + rust: RustExtractor, + python: PythonExtractor, + } + } + + fn extractor_for(&self, language: Language) -> Option<&dyn LanguageExtractor> { + match language { + Language::Rust => Some(&self.rust), + Language::Python => Some(&self.python), + Language::CSharp => None, + } } } @@ -21,10 +37,9 @@ impl SourceAnalyzer for TreeSitterAnalyzer { let source = std::fs::read_to_string(file.path().as_str()) .map_err(|e| DomainError::IoError(e.to_string()))?; - match file.language() { - Language::Rust => rust::analyze(&source, file.path()), - Language::Python => python::analyze(&source, file.path()), - Language::CSharp => Ok(AnalysisResult::empty()), + match self.extractor_for(file.language()) { + Some(extractor) => extractor.analyze(&source, file.path()), + None => Ok(AnalysisResult::empty()), } } } diff --git a/crates/application/src/queries/analyze_codebase.rs b/crates/application/src/queries/analyze_codebase.rs index ebfb132..ef39657 100644 --- a/crates/application/src/queries/analyze_codebase.rs +++ b/crates/application/src/queries/analyze_codebase.rs @@ -5,7 +5,6 @@ use rayon::prelude::*; use archlens_domain::{ AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship, - RelationshipKind, ports::{FileDiscovery, SourceAnalyzer}, }; @@ -41,7 +40,8 @@ where .par_iter() .map(|file| match self.source_analyzer.analyze_file(file) { Ok(result) => { - let module = infer_module(file.path().as_str(), root, config); + let module = + ModuleName::from_path(file.path().as_str(), root, config.module_mappings()); let elements: Vec = result .elements() .iter() @@ -85,149 +85,23 @@ where warnings.extend(warns); } - let graph = resolve_cross_file_relationships(graph); - let graph = filter_external_imports(graph, root); + let known_dirs: HashSet = std::fs::read_dir(root) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .map(|s| s.to_lowercase()) + .collect(); + + let graph = graph + .resolve_relationships() + .filter_external_imports(&known_dirs); Ok(AnalyzeCodebaseResult { graph, warnings }) } } -fn resolve_cross_file_relationships(graph: CodeGraph) -> CodeGraph { - use std::collections::HashMap; - - let mut file_types: HashMap> = HashMap::new(); - let mut name_modules: HashMap<&str, HashSet>> = HashMap::new(); - let all_type_names: HashSet<&str> = graph.elements().iter().map(|e| e.name()).collect(); - - for element in graph.elements() { - file_types - .entry(element.file_path().as_str().to_string()) - .or_default() - .insert(element.name().to_string()); - name_modules - .entry(element.name()) - .or_default() - .insert(element.module().map(|m| m.as_str())); - } - - let mut resolved = CodeGraph::new(); - for element in graph.elements() { - resolved.add_element(element.clone()); - } - for rel in graph.relationships() { - match rel.kind() { - RelationshipKind::Import => { - resolved.add_relationship(rel.clone()); - } - _ => { - if !all_type_names.contains(rel.source()) || !all_type_names.contains(rel.target()) - { - continue; - } - - if let Some(src_file) = rel.source_file() { - let file_key = src_file.as_str().to_string(); - if let Some(types_in_file) = file_types.get(&file_key) - && types_in_file.contains(rel.target()) - { - resolved.add_relationship(rel.clone()); - continue; - } - } - - let tgt_modules = &name_modules[rel.target()]; - if tgt_modules.len() == 1 { - resolved.add_relationship(rel.clone()); - } - } - } - } - resolved -} - -fn filter_external_imports(graph: CodeGraph, root: &Path) -> CodeGraph { - let known_dirs: HashSet = std::fs::read_dir(root) - .into_iter() - .flatten() - .filter_map(|e| e.ok()) - .filter(|e| e.path().is_dir()) - .filter_map(|e| e.file_name().into_string().ok()) - .map(|s| s.to_lowercase()) - .collect(); - - let module_names: HashSet = graph - .modules() - .iter() - .map(|m| m.as_str().to_lowercase()) - .collect(); - - let all_known: HashSet<&str> = known_dirs - .iter() - .map(|s| s.as_str()) - .chain(module_names.iter().map(|s| s.as_str())) - .collect(); - - let mut filtered = CodeGraph::new(); - for element in graph.elements() { - filtered.add_element(element.clone()); - } - for rel in graph.relationships() { - if rel.kind() == RelationshipKind::Import { - let target_top = rel.target().split('.').next().unwrap_or("").to_lowercase(); - if !all_known.contains(target_top.as_str()) { - continue; - } - } - filtered.add_relationship(rel.clone()); - } - filtered -} - -fn infer_module(file_path: &str, root: &Path, config: &AnalysisConfig) -> Option { - let relative = if let Some(stripped) = file_path.strip_prefix(root.to_str().unwrap_or("")) { - stripped.trim_start_matches('/') - } else { - file_path - }; - - for (pattern, module_name) in config.module_mappings() { - if relative.starts_with(pattern) { - return ModuleName::new(module_name).ok(); - } - } - - let parts: Vec<&str> = relative.split('/').collect(); - if parts.len() <= 1 { - return None; - } - - let module_dir = if parts[0] == "crates" && parts.len() > 2 { - // workspace: crates//src/... - parts[1] - } else if parts[0] == "src" && parts.len() > 2 { - // single project: src//... - parts[1] - } else if parts[0] != "src" && parts.len() > 1 { - parts[0] - } else { - return None; - }; - - let capitalized = module_dir - .split('-') - .map(|seg| { - if seg.is_empty() { - String::new() - } else { - format!("{}{}", seg[..1].to_uppercase(), &seg[1..]) - } - }) - .collect::>() - .join("-"); - - ModuleName::new(&capitalized).ok() -} - pub struct AnalyzeCodebaseResult { graph: CodeGraph, warnings: Vec, diff --git a/crates/application/src/queries/mod.rs b/crates/application/src/queries/mod.rs index ee6e763..601e5f0 100644 --- a/crates/application/src/queries/mod.rs +++ b/crates/application/src/queries/mod.rs @@ -1,5 +1,3 @@ mod analyze_codebase; -mod render_diagrams; pub use analyze_codebase::{AnalyzeCodebase, AnalyzeCodebaseResult}; -pub use render_diagrams::RenderDiagrams; diff --git a/crates/application/src/queries/render_diagrams.rs b/crates/application/src/queries/render_diagrams.rs deleted file mode 100644 index c39de64..0000000 --- a/crates/application/src/queries/render_diagrams.rs +++ /dev/null @@ -1,45 +0,0 @@ -use archlens_domain::{ - CodeGraph, DomainError, OutputConfig, - ports::{DiagramRenderer, OutputWriter}, -}; - -pub struct RenderDiagrams -where - R: DiagramRenderer, - W: OutputWriter, -{ - renderer: R, - writer: W, -} - -impl RenderDiagrams -where - R: DiagramRenderer, - W: OutputWriter, -{ - pub fn new(renderer: R, writer: W) -> Self { - Self { renderer, writer } - } - - pub fn execute(&self, graph: &CodeGraph, config: &OutputConfig) -> Result<(), DomainError> { - if config.split_by_module() { - let overview = self.renderer.render(graph)?; - self.writer.write(&overview)?; - - for module in graph.modules() { - let subgraph = graph.subgraph_by_module(&module); - let output = self.renderer.render(&subgraph)?; - self.writer.write(&output)?; - } - } else { - let output = self.renderer.render(graph)?; - self.writer.write(&output)?; - } - - Ok(()) - } - - pub fn writer(&self) -> &W { - &self.writer - } -} diff --git a/crates/application/tests/render_diagrams_tests.rs b/crates/application/tests/render_diagrams_tests.rs deleted file mode 100644 index 0d9accc..0000000 --- a/crates/application/tests/render_diagrams_tests.rs +++ /dev/null @@ -1,78 +0,0 @@ -mod fakes; - -use archlens_application::queries::RenderDiagrams; -use archlens_domain::{ - CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, OutputConfig, -}; - -use fakes::{FakeDiagramRenderer, FakeOutputWriter}; - -fn build_graph() -> CodeGraph { - let mut graph = CodeGraph::new(); - graph.add_element( - CodeElement::new( - "OrderService", - CodeElementKind::Class, - FilePath::new("src/service.rs").unwrap(), - 1, - ) - .unwrap() - .with_module(ModuleName::new("Orders").unwrap()), - ); - graph.add_element( - CodeElement::new( - "BillingService", - CodeElementKind::Class, - FilePath::new("src/billing.rs").unwrap(), - 1, - ) - .unwrap() - .with_module(ModuleName::new("Billing").unwrap()), - ); - graph -} - -#[test] -fn renders_single_diagram_and_writes_output() { - let renderer = FakeDiagramRenderer::new(); - let writer = FakeOutputWriter::new(); - let config = OutputConfig::default(); - - let use_case = RenderDiagrams::new(renderer, writer); - use_case.execute(&build_graph(), &config).unwrap(); - - let outputs = use_case.writer().written_outputs(); - assert_eq!(outputs.len(), 1); - assert_eq!(outputs[0].files().len(), 1); -} - -#[test] -fn split_mode_renders_overview_plus_per_module_diagrams() { - let renderer = FakeDiagramRenderer::new(); - let writer = FakeOutputWriter::new(); - let config = OutputConfig::default().with_split_by_module(true); - - let graph = build_graph(); // has 2 modules: Orders, Billing - - let use_case = RenderDiagrams::new(renderer, writer); - use_case.execute(&graph, &config).unwrap(); - - let outputs = use_case.writer().written_outputs(); - // 1 overview + 2 module diagrams = 3 writes - assert_eq!(outputs.len(), 3); -} - -#[test] -fn empty_graph_still_produces_output() { - let renderer = FakeDiagramRenderer::new(); - let writer = FakeOutputWriter::new(); - let config = OutputConfig::default(); - - let graph = CodeGraph::new(); - - let use_case = RenderDiagrams::new(renderer, writer); - use_case.execute(&graph, &config).unwrap(); - - let outputs = use_case.writer().written_outputs(); - assert_eq!(outputs.len(), 1); -} diff --git a/crates/domain/src/aggregates/code_graph.rs b/crates/domain/src/aggregates/code_graph.rs index 90bc5f1..7c7e5cc 100644 --- a/crates/domain/src/aggregates/code_graph.rs +++ b/crates/domain/src/aggregates/code_graph.rs @@ -1,6 +1,6 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; -use crate::{CodeElement, ModuleName, Relationship}; +use crate::{CodeElement, ModuleName, Relationship, RelationshipKind}; #[derive(Debug, Clone)] pub struct CodeGraph { @@ -53,6 +53,105 @@ impl CodeGraph { modules } + pub fn elements_by_module(&self) -> (HashMap>, Vec<&CodeElement>) { + let mut grouped: HashMap> = HashMap::new(); + let mut ungrouped: Vec<&CodeElement> = Vec::new(); + + for element in &self.elements { + if let Some(module) = element.module() { + grouped + .entry(module.as_str().to_string()) + .or_default() + .push(element); + } else { + ungrouped.push(element); + } + } + + (grouped, ungrouped) + } + + pub fn resolve_relationships(self) -> CodeGraph { + let mut file_types: HashMap> = HashMap::new(); + let mut name_modules: HashMap<&str, HashSet>> = HashMap::new(); + let all_type_names: HashSet<&str> = self.elements.iter().map(|e| e.name()).collect(); + + for element in &self.elements { + file_types + .entry(element.file_path().as_str().to_string()) + .or_default() + .insert(element.name().to_string()); + name_modules + .entry(element.name()) + .or_default() + .insert(element.module().map(|m| m.as_str())); + } + + let mut resolved = CodeGraph::new(); + for element in &self.elements { + resolved.add_element(element.clone()); + } + for rel in &self.relationships { + match rel.kind() { + RelationshipKind::Import => { + resolved.add_relationship(rel.clone()); + } + _ => { + if !all_type_names.contains(rel.source()) + || !all_type_names.contains(rel.target()) + { + continue; + } + + if let Some(src_file) = rel.source_file() { + let file_key = src_file.as_str().to_string(); + if let Some(types_in_file) = file_types.get(&file_key) + && types_in_file.contains(rel.target()) + { + resolved.add_relationship(rel.clone()); + continue; + } + } + + let tgt_modules = &name_modules[rel.target()]; + if tgt_modules.len() == 1 { + resolved.add_relationship(rel.clone()); + } + } + } + } + resolved + } + + pub fn filter_external_imports(self, known_modules: &HashSet) -> CodeGraph { + let module_names: HashSet = self + .modules() + .iter() + .map(|m| m.as_str().to_lowercase()) + .collect(); + + let all_known: HashSet<&str> = known_modules + .iter() + .map(|s| s.as_str()) + .chain(module_names.iter().map(|s| s.as_str())) + .collect(); + + let mut filtered = CodeGraph::new(); + for element in &self.elements { + filtered.add_element(element.clone()); + } + for rel in &self.relationships { + if rel.kind() == RelationshipKind::Import { + let target_top = rel.target().split('.').next().unwrap_or("").to_lowercase(); + if !all_known.contains(target_top.as_str()) { + continue; + } + } + filtered.add_relationship(rel.clone()); + } + filtered + } + pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph { let filtered_elements: Vec = self .elements diff --git a/crates/domain/src/value_objects/source/module_name.rs b/crates/domain/src/value_objects/source/module_name.rs index 21ba2ce..79888b6 100644 --- a/crates/domain/src/value_objects/source/module_name.rs +++ b/crates/domain/src/value_objects/source/module_name.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use std::path::Path; + use crate::DomainError; #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -12,6 +15,60 @@ impl ModuleName { Ok(Self(trimmed.to_string())) } + pub fn from_path( + file_path: &str, + root: &Path, + module_mappings: &HashMap, + ) -> Option { + let relative = file_path + .strip_prefix(root.to_str().unwrap_or("")) + .unwrap_or(file_path) + .trim_start_matches('/'); + + for (pattern, module_name) in module_mappings { + if relative.starts_with(pattern.as_str()) { + return Self::new(module_name).ok(); + } + } + + let parts: Vec<&str> = relative.split('/').collect(); + if parts.len() <= 1 { + return None; + } + + let module_dir = if (parts[0] == "crates" || parts[0] == "src") && parts.len() > 2 { + parts[1] + } else if parts[0] != "src" && parts.len() > 1 { + parts[0] + } else { + return None; + }; + + Self::new(&Self::capitalize(module_dir)).ok() + } + + pub fn from_directory_group(member_path: &str) -> Option { + let parts: Vec<&str> = member_path.split('/').collect(); + if parts.len() < 3 { + return None; + } + let group = parts[parts.len() - 2]; + Self::new(&Self::capitalize(group)).ok() + } + + pub fn capitalize(s: &str) -> String { + s.split('-') + .map(|seg| { + if seg.is_empty() { + String::new() + } else { + format!("{}{}", seg[..1].to_uppercase(), &seg[1..]) + } + }) + .collect::>() + .join("-") + } + pub fn as_str(&self) -> &str { &self.0 } diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 0ef5c8b..332fcb0 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -8,7 +8,7 @@ use archlens_application::queries::AnalyzeCodebase; use archlens_ascii::AsciiRenderer; use archlens_cargo_workspace::CargoWorkspaceAnalyzer; use archlens_domain::{ - CodeGraph, DiagramLevel, + CodeGraph, DiagramLevel, ModuleName, ports::{ConfigLoader, OutputWriter, ProjectAnalyzer}, }; use archlens_file_writer::FileOutputWriter; @@ -30,21 +30,43 @@ pub fn run(args: Cli) -> Result<()> { } init_tracing(args.verbose); - let config_loader = match &args.config { - Some(path) => TomlConfigLoader::from_path(std::path::Path::new(path))?, + let level = parse_level(&args.level); + let graph = build_graph(&args, level)?; + let renderer = create_renderer(&args.format, level)?; + let ext = format_extension(&args.format); + + if args.check { + return check_freshness(&args.output, &graph, &*renderer); + } + + 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() { - TomlConfigLoader::from_path(&default_path)? + Ok(TomlConfigLoader::from_path(&default_path)?) } else { - TomlConfigLoader::default() + 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()?; - 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()); } @@ -54,79 +76,80 @@ pub fn run(args: Cli) -> Result<()> { 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 level == DiagramLevel::Project { + return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?); + } - 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 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() + ); } - - 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); - } + if args.strict { + bail!( + "analysis produced {} warning(s) in strict mode", + result.warnings().len() + ); } + } - graph - }; + let mut graph = result.graph().clone(); - let renderer: Box = match &args.format[..] { - "mermaid" => Box::new(MermaidRenderer::with_level(level)), - "ascii" => Box::new(AsciiRenderer::new()), + 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); + } + } + + Ok(graph) +} + +fn create_renderer( + format: &str, + level: DiagramLevel, +) -> Result> { + match format { + "mermaid" => Ok(Box::new(MermaidRenderer::with_level(level))), + "ascii" => Ok(Box::new(AsciiRenderer::new())), fmt => bail!("unknown format: {fmt}"), - }; + } +} - let ext = match &args.format[..] { +fn format_extension(format: &str) -> &str { + match format { "mermaid" => "mmd", _ => "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"); }; - - 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"); - } + 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); } - - if args.split_by_module { - write_split(&graph, &*renderer, &args.output, ext)?; - } else { - write_single(&graph, &*renderer, &args.output)?; - } - + println!("Architecture diagram is up to date."); Ok(()) } @@ -213,8 +236,8 @@ fn merge_project_deps_as_module_edges( 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); + let src_cap = ModuleName::capitalize(src); + let tgt_cap = ModuleName::capitalize(tgt); if src_cap != tgt_cap && graph_modules.contains(&src_cap) @@ -231,62 +254,12 @@ fn merge_project_deps_as_module_edges( } } -fn capitalize(s: &str) -> String { - s.split('-') - .map(|seg| { - if seg.is_empty() { - String::new() - } else { - format!("{}{}", seg[..1].to_uppercase(), &seg[1..]) - } - }) - .collect::>() - .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 = match &args.format[..] { - "mermaid" => Box::new(MermaidRenderer::with_level(level)), - "ascii" => Box::new(AsciiRenderer::new()), - fmt => bail!("unknown format: {fmt}"), - }; + let graph = build_graph(args, level)?; + let renderer = create_renderer(&args.format, level)?; let output = renderer.render(&graph)?; let current = output.files().first().map(|f| f.content()).unwrap_or("");