From fc8ad0ebc031976c6cf6cb11a1c48926f7cf6c0e Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 17 Jun 2026 11:24:18 +0200 Subject: [PATCH] refactor: five architectural deepening improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Candidate 1 (NormalizedGraph): qualify→resolve→filter is now a single named operation returning a distinct type; raw CodeGraph cannot call module_edges/subgraph_by_module — pipeline order enforced at compile time. Candidate 2 (Use cases): GenerateDiagram, CheckFreshness, DiffDiagram extracted to application/src/use_cases/; presentation is now a thin CLI dispatcher (~100 lines less, three fewer local functions). Candidate 3 (ExtractionContext): shared accumulator for both Rust and Python extractors replaces parallel Vec<> + 4-arg passing chains. Candidate 4 (ModuleAssignment): ModuleName::assign() returns ModuleAssignment { Explicit | Inferred | Unresolved } instead of Option, callers can distinguish resolution strategies. Candidate 5 (SplitRenderer): append_cross_module_deps removed from DiagramRenderer port; replaced by render_for_module() default impl — port interface now reflects what all renderers actually share. --- .../adapters/mermaid/src/mermaid_renderer.rs | 62 ++-- .../tree-sitter/src/extraction_context.rs | 56 ++++ crates/adapters/tree-sitter/src/lib.rs | 1 + crates/adapters/tree-sitter/src/python/mod.rs | 116 ++----- crates/adapters/tree-sitter/src/rust/mod.rs | 210 +++---------- crates/application/src/lib.rs | 1 + .../src/queries/analyze_codebase.rs | 30 +- .../src/use_cases/check_freshness.rs | 18 ++ .../application/src/use_cases/diff_diagram.rs | 46 +++ .../src/use_cases/generate_diagram.rs | 125 ++++++++ crates/application/src/use_cases/mod.rs | 3 + crates/domain/src/aggregates/mod.rs | 2 + .../domain/src/aggregates/normalized_graph.rs | 77 +++++ crates/domain/src/lib.rs | 5 +- crates/domain/src/ports/diagram_renderer.rs | 20 +- crates/domain/src/value_objects/source/mod.rs | 2 +- .../src/value_objects/source/module_name.rs | 67 ++++- crates/presentation/src/lib.rs | 284 ++++++------------ 18 files changed, 614 insertions(+), 511 deletions(-) create mode 100644 crates/adapters/tree-sitter/src/extraction_context.rs create mode 100644 crates/application/src/use_cases/check_freshness.rs create mode 100644 crates/application/src/use_cases/diff_diagram.rs create mode 100644 crates/application/src/use_cases/generate_diagram.rs create mode 100644 crates/application/src/use_cases/mod.rs create mode 100644 crates/domain/src/aggregates/normalized_graph.rs diff --git a/crates/adapters/mermaid/src/mermaid_renderer.rs b/crates/adapters/mermaid/src/mermaid_renderer.rs index ffc9e63..96bc0ce 100644 --- a/crates/adapters/mermaid/src/mermaid_renderer.rs +++ b/crates/adapters/mermaid/src/mermaid_renderer.rs @@ -226,42 +226,36 @@ impl DiagramRenderer for MermaidRenderer { Ok(RenderOutput::single(file)) } - fn append_cross_module_deps( + fn render_for_module( &self, - content: &str, + subgraph: &CodeGraph, module: &ModuleName, - deps: &[(ModuleName, usize)], - ) -> String { - if deps.is_empty() { - return content.to_string(); - } - - let src_id = format!( - "{}_module", - module.as_str().to_lowercase().replace('-', "_") - ); - let mut extra = format!( - " class {src_id}[\"{}\"] {{\n <>\n }}\n", - module.as_str() - ); - - for (dep_mod, count) in deps { - let dep_id = format!( - "{}_module", - dep_mod.as_str().to_lowercase().replace('-', "_") + cross_deps: &[(ModuleName, usize)], + ) -> Result { + let base = self.render_class_diagram(subgraph); + let content = if cross_deps.is_empty() { + base + } else { + let src_id = format!("{}_module", module.as_str().to_lowercase().replace('-', "_")); + let mut extra = format!( + " class {src_id}[\"{}\"] {{\n <>\n }}\n", + module.as_str() ); - extra.push_str(&format!( - " class {dep_id}[\"{}\"] {{\n <>\n }}\n", - dep_mod.as_str() - )); - let label = if *count == 1 { - "1 dep".to_string() - } else { - format!("{count} deps") - }; - extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n")); - } - - format!("{content}\n{extra}") + for (dep_mod, count) in cross_deps { + let dep_id = format!( + "{}_module", + dep_mod.as_str().to_lowercase().replace('-', "_") + ); + extra.push_str(&format!( + " class {dep_id}[\"{}\"] {{\n <>\n }}\n", + dep_mod.as_str() + )); + let label = if *count == 1 { "1 dep".to_string() } else { format!("{count} deps") }; + extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n")); + } + format!("{base}\n{extra}") + }; + let file = RenderedFile::new("diagram.mmd", &content)?; + Ok(RenderOutput::single(file)) } } diff --git a/crates/adapters/tree-sitter/src/extraction_context.rs b/crates/adapters/tree-sitter/src/extraction_context.rs new file mode 100644 index 0000000..0f2582f --- /dev/null +++ b/crates/adapters/tree-sitter/src/extraction_context.rs @@ -0,0 +1,56 @@ +use std::collections::HashSet; + +use archlens_domain::{ + AnalysisResult, AnalysisWarning, CodeElement, DomainError, FilePath, Relationship, +}; + +/// Accumulator passed through tree-walking helpers. +/// +/// Bundles the four parallel mutable collections that every language extractor +/// maintains, giving helpers a single receiver to push into and eliminating +/// 4-parameter passing chains. +pub struct ExtractionContext { + elements: Vec, + relationships: Vec, + warnings: Vec, + /// Names of types defined in the current file — used to filter primitive + /// and external types from relationship targets. + pub local_types: HashSet, + file_path: FilePath, +} + +impl ExtractionContext { + pub fn new(file_path: FilePath) -> Self { + Self { + elements: Vec::new(), + relationships: Vec::new(), + warnings: Vec::new(), + local_types: HashSet::new(), + file_path, + } + } + + pub fn add_element(&mut self, element: CodeElement) { + self.local_types.insert(element.name().to_string()); + self.elements.push(element); + } + + pub fn add_relationship(&mut self, rel: Relationship) { + self.relationships.push(rel.with_source_file(self.file_path.clone())); + } + + pub fn add_warning(&mut self, file_path: FilePath, line: usize, message: &str) { + if let Ok(w) = AnalysisWarning::new(file_path, line, message) { + self.warnings.push(w); + } + } + + pub fn file_path(&self) -> &FilePath { + &self.file_path + } + + /// Consumes the context and returns the completed `AnalysisResult`. + pub fn into_result(self) -> Result { + Ok(AnalysisResult::new(self.elements, self.relationships, self.warnings)) + } +} diff --git a/crates/adapters/tree-sitter/src/lib.rs b/crates/adapters/tree-sitter/src/lib.rs index 72b2597..f5521fc 100644 --- a/crates/adapters/tree-sitter/src/lib.rs +++ b/crates/adapters/tree-sitter/src/lib.rs @@ -1,3 +1,4 @@ +mod extraction_context; mod language_extractor; mod python; mod rust; diff --git a/crates/adapters/tree-sitter/src/python/mod.rs b/crates/adapters/tree-sitter/src/python/mod.rs index 922c5d6..c26520e 100644 --- a/crates/adapters/tree-sitter/src/python/mod.rs +++ b/crates/adapters/tree-sitter/src/python/mod.rs @@ -1,12 +1,11 @@ -use std::collections::HashSet; - use tree_sitter::{Node, Parser}; use archlens_domain::{ - AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath, - Relationship, RelationshipKind, + AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship, + RelationshipKind, }; +use crate::extraction_context::ExtractionContext; use crate::language_extractor::LanguageExtractor; pub struct PythonExtractor; @@ -27,40 +26,16 @@ pub fn analyze(source: &str, file_path: &FilePath) -> Result = HashSet::new(); - + let mut ctx = ExtractionContext::new(file_path.clone()); let root = tree.root_node(); - collect_classes( - &root, - source, - file_path, - &mut elements, - &mut type_names, - &mut relationships, - &mut warnings, - ); - collect_imports(&root, source, file_path, &mut relationships); - let relationships = relationships - .into_iter() - .map(|r| r.with_source_file(file_path.clone())) - .collect(); + collect_classes(&root, source, &mut ctx); + collect_imports(&root, source, &mut ctx); - Ok(AnalysisResult::new(elements, relationships, warnings)) + ctx.into_result() } -fn collect_classes( - node: &Node, - source: &str, - file_path: &FilePath, - elements: &mut Vec, - type_names: &mut HashSet, - relationships: &mut Vec, - warnings: &mut Vec, -) { +fn collect_classes(node: &Node, source: &str, ctx: &mut ExtractionContext) { let mut cursor = node.walk(); for child in node.children(&mut cursor) { if child.kind() != "class_definition" { @@ -79,26 +54,21 @@ fn collect_classes( .map(|body| collect_methods(&body, source)) .unwrap_or_default(); - match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) { - Ok(element) => { - type_names.insert(name.to_string()); - elements.push(element.with_methods(methods)); - } + match CodeElement::new(name, CodeElementKind::Class, ctx.file_path().clone(), line) { + Ok(element) => ctx.add_element(element.with_methods(methods)), Err(e) => { - if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) { - warnings.push(w); - } + ctx.add_warning(ctx.file_path().clone(), line, &e.to_string()); continue; } } if let Some(superclasses) = child.child_by_field_name("superclasses") { - collect_inheritance(&superclasses, source, name, type_names, relationships); + collect_inheritance(&superclasses, source, name, ctx); } if let Some(body) = child.child_by_field_name("body") { - collect_typed_fields(&body, source, name, type_names, relationships); - collect_constructor_params(&body, source, name, type_names, relationships); + collect_typed_fields(&body, source, name, ctx); + collect_constructor_params(&body, source, name, ctx); } } } @@ -107,8 +77,7 @@ fn collect_inheritance( superclasses: &Node, source: &str, class_name: &str, - _type_names: &HashSet, - relationships: &mut Vec, + ctx: &mut ExtractionContext, ) { let mut cursor = superclasses.walk(); for child in superclasses.children(&mut cursor) { @@ -118,7 +87,7 @@ fn collect_inheritance( && let Ok(rel) = Relationship::new(class_name, base_name, RelationshipKind::Inheritance) { - relationships.push(rel); + ctx.add_relationship(rel); } } } @@ -228,16 +197,12 @@ fn is_external_import(module: &str) -> bool { false } -fn collect_imports( - node: &Node, - source: &str, - file_path: &FilePath, - relationships: &mut Vec, -) { - let file_name = std::path::Path::new(file_path.as_str()) +fn collect_imports(node: &Node, source: &str, ctx: &mut ExtractionContext) { + let file_name = std::path::Path::new(ctx.file_path().as_str()) .file_stem() .and_then(|s| s.to_str()) - .unwrap_or("unknown"); + .unwrap_or("unknown") + .to_string(); let mut cursor = node.walk(); for child in node.children(&mut cursor) { @@ -249,9 +214,9 @@ fn collect_imports( let module = &source[name_child.byte_range()]; if !is_external_import(module) && let Ok(rel) = - Relationship::new(file_name, module, RelationshipKind::Import) + Relationship::new(&file_name, module, RelationshipKind::Import) { - relationships.push(rel); + ctx.add_relationship(rel); } } } @@ -261,9 +226,9 @@ fn collect_imports( let module = &source[module_node.byte_range()]; if !is_external_import(module) && let Ok(rel) = - Relationship::new(file_name, module, RelationshipKind::Import) + Relationship::new(&file_name, module, RelationshipKind::Import) { - relationships.push(rel); + ctx.add_relationship(rel); } } } @@ -314,7 +279,6 @@ fn extract_python_params(params_node: &Node, source: &str) -> String { match param.kind() { "typed_parameter" => { if let Some(type_node) = param.child_by_field_name("type") { - // name is the first identifier child (not a named field) let mut inner = param.walk(); let name = param .children(&mut inner) @@ -339,13 +303,7 @@ fn extract_python_params(params_node: &Node, source: &str) -> String { parts.join(", ") } -fn collect_constructor_params( - body: &Node, - source: &str, - class_name: &str, - _type_names: &HashSet, - relationships: &mut Vec, -) { +fn collect_constructor_params(body: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) { let mut cursor = body.walk(); for child in body.children(&mut cursor) { if child.kind() != "function_definition" { @@ -373,30 +331,18 @@ fn collect_constructor_params( && let Ok(rel) = Relationship::new(class_name, base_type, RelationshipKind::Composition) { - relationships.push(rel); + ctx.add_relationship(rel); } } } } } -fn collect_typed_fields( - body: &Node, - source: &str, - class_name: &str, - type_names: &HashSet, - relationships: &mut Vec, -) { - collect_typed_fields_recursive(body, source, class_name, type_names, relationships); +fn collect_typed_fields(body: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) { + collect_typed_fields_recursive(body, source, class_name, ctx); } -fn collect_typed_fields_recursive( - node: &Node, - source: &str, - class_name: &str, - _type_names: &HashSet, - relationships: &mut Vec, -) { +fn collect_typed_fields_recursive(node: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) { let mut cursor = node.walk(); for child in node.children(&mut cursor) { if (child.kind() == "assignment" || child.kind() == "typed_assignment") @@ -410,10 +356,10 @@ fn collect_typed_fields_recursive( && let Ok(rel) = Relationship::new(class_name, base_type, RelationshipKind::Composition) { - relationships.push(rel); + ctx.add_relationship(rel); } } - collect_typed_fields_recursive(&child, source, class_name, _type_names, relationships); + collect_typed_fields_recursive(&child, source, class_name, ctx); } } diff --git a/crates/adapters/tree-sitter/src/rust/mod.rs b/crates/adapters/tree-sitter/src/rust/mod.rs index 42c47df..01cd478 100644 --- a/crates/adapters/tree-sitter/src/rust/mod.rs +++ b/crates/adapters/tree-sitter/src/rust/mod.rs @@ -1,47 +1,17 @@ -use std::collections::HashSet; - use tree_sitter::{Node, Parser}; const RUST_PRIMITIVES: &[&str] = &[ - "bool", - "char", - "str", - "String", - "u8", - "u16", - "u32", - "u64", - "u128", - "usize", - "i8", - "i16", - "i32", - "i64", - "i128", - "isize", - "f32", - "f64", - "Vec", - "Option", - "Result", - "Box", - "Rc", - "Arc", - "HashMap", - "HashSet", - "BTreeMap", - "BTreeSet", - "PhantomData", - "Pin", - "Cow", - "Self", + "bool", "char", "str", "String", "u8", "u16", "u32", "u64", "u128", "usize", "i8", "i16", + "i32", "i64", "i128", "isize", "f32", "f64", "Vec", "Option", "Result", "Box", "Rc", "Arc", + "HashMap", "HashSet", "BTreeMap", "BTreeSet", "PhantomData", "Pin", "Cow", "Self", ]; use archlens_domain::{ - AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath, - Relationship, RelationshipKind, Visibility, + AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship, + RelationshipKind, Visibility, }; +use crate::extraction_context::ExtractionContext; use crate::language_extractor::LanguageExtractor; pub struct RustExtractor; @@ -62,46 +32,18 @@ pub fn analyze(source: &str, file_path: &FilePath) -> Result = HashSet::new(); - + let mut ctx = ExtractionContext::new(file_path.clone()); let root = tree.root_node(); - collect_types( - &root, - source, - file_path, - &mut elements, - &mut type_names, - &mut warnings, - ); - collect_relationships( - &root, - source, - &type_names, - &mut relationships, - &mut warnings, - ); - collect_mod_declarations(&root, source, file_path, &mut relationships); - collect_use_imports(&root, source, file_path, &mut relationships); - let relationships = relationships - .into_iter() - .map(|r| r.with_source_file(file_path.clone())) - .collect(); + collect_types(&root, source, &mut ctx); + collect_relationships(&root, source, &mut ctx); + collect_mod_declarations(&root, source, &mut ctx); + collect_use_imports(&root, source, &mut ctx); - Ok(AnalysisResult::new(elements, relationships, warnings)) + ctx.into_result() } -fn collect_types( - node: &Node, - source: &str, - file_path: &FilePath, - elements: &mut Vec, - type_names: &mut HashSet, - warnings: &mut Vec, -) { +fn collect_types(node: &Node, source: &str, ctx: &mut ExtractionContext) { let mut cursor = node.walk(); for child in node.children(&mut cursor) { let (kind, name_field) = match child.kind() { @@ -116,52 +58,34 @@ fn collect_types( let line = child.start_position().row + 1; let visibility = detect_visibility(&child, source); - match CodeElement::new(name, kind, file_path.clone(), line) { + match CodeElement::new(name, kind, ctx.file_path().clone(), line) { Ok(element) => { let fields = extract_fields(&child, source); let methods = extract_methods(node, source, name); - let element = element - .with_visibility(visibility) - .with_fields(fields) - .with_methods(methods); - type_names.insert(name.to_string()); - elements.push(element); - } - Err(e) => { - if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) { - warnings.push(w); - } + ctx.add_element( + element + .with_visibility(visibility) + .with_fields(fields) + .with_methods(methods), + ); } + Err(e) => ctx.add_warning(ctx.file_path().clone(), line, &e.to_string()), } } } } -fn collect_relationships( - node: &Node, - source: &str, - type_names: &HashSet, - relationships: &mut Vec, - warnings: &mut Vec, -) { +fn collect_relationships(node: &Node, source: &str, ctx: &mut ExtractionContext) { let mut cursor = node.walk(); for child in node.children(&mut cursor) { match child.kind() { "struct_item" => { if let Some(name_node) = child.child_by_field_name("name") { let struct_name = source[name_node.byte_range()].to_string(); - collect_field_compositions( - &child, - source, - &struct_name, - type_names, - relationships, - ); + collect_field_compositions(&child, source, &struct_name, ctx); } } - "impl_item" => { - collect_trait_impl(&child, source, type_names, relationships, warnings); - } + "impl_item" => collect_trait_impl(&child, source, ctx), _ => {} } } @@ -171,8 +95,7 @@ fn collect_field_compositions( struct_node: &Node, source: &str, struct_name: &str, - _type_names: &HashSet, - relationships: &mut Vec, + ctx: &mut ExtractionContext, ) { if let Some(body) = struct_node.child_by_field_name("body") { let mut cursor = body.walk(); @@ -187,20 +110,14 @@ fn collect_field_compositions( && let Ok(rel) = Relationship::new(struct_name, &type_text, RelationshipKind::Composition) { - relationships.push(rel); + ctx.add_relationship(rel); } } } } } -fn collect_trait_impl( - impl_node: &Node, - source: &str, - _type_names: &HashSet, - relationships: &mut Vec, - _warnings: &mut Vec, -) { +fn collect_trait_impl(impl_node: &Node, source: &str, ctx: &mut ExtractionContext) { let trait_node = impl_node.child_by_field_name("trait"); let type_node = impl_node.child_by_field_name("type"); @@ -215,7 +132,7 @@ fn collect_trait_impl( && let Ok(rel) = Relationship::new(&type_name, &trait_name, RelationshipKind::Inheritance) { - relationships.push(rel); + ctx.add_relationship(rel); } } } @@ -226,12 +143,8 @@ fn extract_fields(node: &Node, source: &str) -> Vec { let mut cursor = body.walk(); for child in body.children(&mut cursor) { if child.kind() == "field_declaration" { - let name = child - .child_by_field_name("name") - .map(|n| &source[n.byte_range()]); - let ty = child - .child_by_field_name("type") - .map(|n| extract_base_type(&n, source)); + let name = child.child_by_field_name("name").map(|n| &source[n.byte_range()]); + let ty = child.child_by_field_name("type").map(|n| extract_base_type(&n, source)); if let (Some(name), Some(ty)) = (name, ty) { fields.push(format!("{name}: {ty}")); } @@ -245,19 +158,15 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec { let mut methods = Vec::new(); let mut cursor = root.walk(); for child in root.children(&mut cursor) { - if child.kind() != "impl_item" { + if child.kind() != "impl_item" || child.child_by_field_name("trait").is_some() { continue; } - if child.child_by_field_name("trait").is_some() { + if child + .child_by_field_name("type") + .is_some_and(|tn| extract_base_type(&tn, source) != type_name) + { continue; } - let type_node = child.child_by_field_name("type"); - if let Some(tn) = type_node { - let impl_name = extract_base_type(&tn, source); - if impl_name != type_name { - continue; - } - } if let Some(body) = child.child_by_field_name("body") { let mut body_cursor = body.walk(); for item in body.children(&mut body_cursor) { @@ -265,11 +174,7 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec { && let Some(name_node) = item.child_by_field_name("name") { let fn_name = &source[name_node.byte_range()]; - let vis = if detect_visibility(&item, source) == Visibility::Public { - "+" - } else { - "-" - }; + let vis = if detect_visibility(&item, source) == Visibility::Public { "+" } else { "-" }; let params = extract_fn_params(&item, source); let ret = extract_fn_return(&item, source); let sig = if ret.is_empty() { @@ -317,45 +222,34 @@ fn extract_fn_return(fn_item: &Node, source: &str) -> String { .unwrap_or_default() } -fn collect_mod_declarations( - node: &Node, - source: &str, - file_path: &FilePath, - relationships: &mut Vec, -) { - let file_name = std::path::Path::new(file_path.as_str()) +fn collect_mod_declarations(node: &Node, source: &str, ctx: &mut ExtractionContext) { + let file_name = std::path::Path::new(ctx.file_path().as_str()) .file_stem() .and_then(|s| s.to_str()) - .unwrap_or("unknown"); + .unwrap_or("unknown") + .to_string(); let mut cursor = node.walk(); for child in node.children(&mut cursor) { - if child.kind() != "mod_item" { - continue; - } - if child.child_by_field_name("body").is_some() { + if child.kind() != "mod_item" || child.child_by_field_name("body").is_some() { continue; } if let Some(name_node) = child.child_by_field_name("name") { let mod_name = &source[name_node.byte_range()]; let target = format!("crate::{mod_name}"); - if let Ok(rel) = Relationship::new(file_name, &target, RelationshipKind::Import) { - relationships.push(rel); + if let Ok(rel) = Relationship::new(&file_name, &target, RelationshipKind::Import) { + ctx.add_relationship(rel); } } } } -fn collect_use_imports( - node: &Node, - source: &str, - file_path: &FilePath, - relationships: &mut Vec, -) { - let file_name = std::path::Path::new(file_path.as_str()) +fn collect_use_imports(node: &Node, source: &str, ctx: &mut ExtractionContext) { + let file_name = std::path::Path::new(ctx.file_path().as_str()) .file_stem() .and_then(|s| s.to_str()) - .unwrap_or("unknown"); + .unwrap_or("unknown") + .to_string(); let mut cursor = node.walk(); for child in node.children(&mut cursor) { @@ -364,16 +258,12 @@ fn collect_use_imports( } if let Some(arg) = child.child_by_field_name("argument") { let text = &source[arg.byte_range()]; - let path = text - .split('{') - .next() - .unwrap_or(text) - .trim_end_matches("::"); + let path = text.split('{').next().unwrap_or(text).trim_end_matches("::"); if (path.starts_with("crate::") || path.starts_with("super::")) - && let Ok(rel) = Relationship::new(file_name, path, RelationshipKind::Import) + && let Ok(rel) = Relationship::new(&file_name, path, RelationshipKind::Import) { - relationships.push(rel); + ctx.add_relationship(rel); } } } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 84c032e..5a46417 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1 +1,2 @@ pub mod queries; +pub mod use_cases; diff --git a/crates/application/src/queries/analyze_codebase.rs b/crates/application/src/queries/analyze_codebase.rs index cfde853..77528af 100644 --- a/crates/application/src/queries/analyze_codebase.rs +++ b/crates/application/src/queries/analyze_codebase.rs @@ -4,7 +4,8 @@ use std::path::Path; use rayon::prelude::*; use archlens_domain::{ - AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship, + AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, + NormalizedGraph, Relationship, ports::{FileDiscovery, SourceAnalyzer}, }; @@ -40,26 +41,22 @@ where .par_iter() .map(|file| match self.source_analyzer.analyze_file(file) { Ok(result) => { - let module = - ModuleName::from_path(file.path().as_str(), root, config.module_mappings()); + let assignment = + ModuleName::assign(file.path().as_str(), root, config.module_mappings()); let elements: Vec = result .elements() .iter() .map(|el| { let mut el = el.clone(); if el.module().is_none() - && let Some(ref m) = module + && let Some(m) = assignment.module_name() { el = el.with_module(m.clone()); } el }) .collect(); - ( - elements, - result.relationships().to_vec(), - result.warnings().to_vec(), - ) + (elements, result.relationships().to_vec(), result.warnings().to_vec()) } Err(err) => { let mut warnings = Vec::new(); @@ -73,14 +70,14 @@ where }) .collect(); - let mut graph = CodeGraph::new(); + let mut raw = CodeGraph::new(); let mut warnings = Vec::new(); for (elements, relationships, warns) in file_results { for el in elements { - graph.add_element(el); + raw.add_element(el); } for rel in relationships { - graph.add_relationship(rel); + raw.add_relationship(rel); } warnings.extend(warns); } @@ -94,22 +91,19 @@ where .map(|s| s.to_lowercase()) .collect(); - let graph = graph - .qualify() - .resolve_relationships() - .filter_external_imports(&known_dirs); + let graph = NormalizedGraph::from_analyzed(raw, &known_dirs)?; Ok(AnalyzeCodebaseResult { graph, warnings }) } } pub struct AnalyzeCodebaseResult { - graph: CodeGraph, + graph: NormalizedGraph, warnings: Vec, } impl AnalyzeCodebaseResult { - pub fn graph(&self) -> &CodeGraph { + pub fn graph(&self) -> &NormalizedGraph { &self.graph } diff --git a/crates/application/src/use_cases/check_freshness.rs b/crates/application/src/use_cases/check_freshness.rs new file mode 100644 index 0000000..d640b4d --- /dev/null +++ b/crates/application/src/use_cases/check_freshness.rs @@ -0,0 +1,18 @@ +use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer}; + +/// Compares the current rendered output against an on-disk file. +/// Returns `Ok(true)` if up to date, `Ok(false)` if stale. +pub struct CheckFreshness<'a> { + pub graph: &'a NormalizedGraph, + pub renderer: &'a dyn DiagramRenderer, + pub existing_path: &'a std::path::Path, +} + +impl<'a> CheckFreshness<'a> { + pub fn execute(&self) -> Result { + let rendered = self.renderer.render(self.graph.as_graph())?; + let current = rendered.files().first().map(|f| f.content()).unwrap_or(""); + let existing = std::fs::read_to_string(self.existing_path).unwrap_or_default(); + Ok(current == existing) + } +} diff --git a/crates/application/src/use_cases/diff_diagram.rs b/crates/application/src/use_cases/diff_diagram.rs new file mode 100644 index 0000000..31dd518 --- /dev/null +++ b/crates/application/src/use_cases/diff_diagram.rs @@ -0,0 +1,46 @@ +use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer}; + +/// The semantic result of comparing two diagram snapshots. +pub struct DiffResult { + pub added: Vec, + pub removed: Vec, +} + +impl DiffResult { + pub fn is_empty(&self) -> bool { + self.added.is_empty() && self.removed.is_empty() + } +} + +/// Compares the rendered current graph against an existing diagram file and +/// returns which lines were added or removed. +pub struct DiffDiagram<'a> { + pub graph: &'a NormalizedGraph, + pub renderer: &'a dyn DiagramRenderer, + pub existing_path: &'a std::path::Path, +} + +impl<'a> DiffDiagram<'a> { + pub fn execute(&self) -> Result { + let rendered = self.renderer.render(self.graph.as_graph())?; + let current = rendered.files().first().map(|f| f.content()).unwrap_or(""); + let existing = std::fs::read_to_string(self.existing_path).unwrap_or_default(); + + let current_lines: std::collections::HashSet<&str> = current.lines().collect(); + let existing_lines: std::collections::HashSet<&str> = existing.lines().collect(); + + let added: Vec = current_lines + .difference(&existing_lines) + .filter(|l| !l.trim().is_empty()) + .map(|l| format!("+ {l}")) + .collect(); + + let removed: Vec = existing_lines + .difference(¤t_lines) + .filter(|l| !l.trim().is_empty()) + .map(|l| format!("- {l}")) + .collect(); + + Ok(DiffResult { added, removed }) + } +} diff --git a/crates/application/src/use_cases/generate_diagram.rs b/crates/application/src/use_cases/generate_diagram.rs new file mode 100644 index 0000000..0dc3041 --- /dev/null +++ b/crates/application/src/use_cases/generate_diagram.rs @@ -0,0 +1,125 @@ +use std::path::PathBuf; + +use archlens_domain::{ + BoundaryRule, DomainError, NormalizedGraph, RenderedFile, RenderOutput, + check_boundary_rules, + ports::DiagramRenderer, +}; + +/// Result of running the generate use case — exposed violations and any output +/// that should be written to disk. +pub struct GenerateDiagramResult { + pub violations: Vec, + pub output: RenderOutput, +} + +/// Orchestrates diagram generation: renders the graph (split or single), +/// checks boundary rules, and returns the output for the caller to write. +pub struct GenerateDiagram { + pub graph: NormalizedGraph, + pub renderer: Box, + pub allow_rules: Vec, + pub deny_rules: Vec, + pub split_by_module: bool, + pub format_ext: String, + pub output_dir: Option, +} + +impl GenerateDiagram { + pub fn execute(self) -> Result<(), DomainError> { + // Boundary rule checking + let violations = if !self.allow_rules.is_empty() || !self.deny_rules.is_empty() { + check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules) + } else { + Vec::new() + }; + + // Render and write + if self.split_by_module { + write_split( + &self.graph, + &*self.renderer, + &self.output_dir, + &self.format_ext, + )?; + } else { + let rendered = self.renderer.render(self.graph.as_graph())?; + write_to_output(rendered, &self.output_dir)?; + } + + // Report violations (after writing so the diagram is still produced) + for v in &violations { + eprintln!("RULE VIOLATION: {}", v.message()); + } + + Ok(()) + } + + pub fn check_violations_only(&self) -> Vec { + if self.allow_rules.is_empty() && self.deny_rules.is_empty() { + return Vec::new(); + } + check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules) + .into_iter() + .map(|v| v.message()) + .collect() + } +} + +pub fn write_split( + graph: &NormalizedGraph, + renderer: &dyn DiagramRenderer, + output_dir: &Option, + ext: &str, +) -> Result<(), DomainError> { + let dir = output_dir + .clone() + .unwrap_or_else(|| PathBuf::from(".")); + + let overview = renderer.render(graph.as_graph())?; + let overview_file = RenderedFile::new( + &format!("overview.{ext}"), + overview.files().first().map(|f| f.content()).unwrap_or(""), + )?; + write_file_to_dir(&dir, 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_for_module(&subgraph, &module, &cross_deps)?; + let module_file = RenderedFile::new( + &format!("{}.{ext}", module.as_str().to_lowercase()), + module_output.files().first().map(|f| f.content()).unwrap_or(""), + )?; + write_file_to_dir(&dir, module_file)?; + } + + Ok(()) +} + +fn write_file_to_dir(dir: &PathBuf, file: RenderedFile) -> Result<(), DomainError> { + let path = dir.join(file.name()); + std::fs::create_dir_all(dir) + .map_err(|e| DomainError::IoError(e.to_string()))?; + std::fs::write(&path, file.content()) + .map_err(|e| DomainError::IoError(e.to_string()))?; + Ok(()) +} + +fn write_to_output(rendered: RenderOutput, output: &Option) -> Result<(), DomainError> { + let content = rendered.files().first().map(|f| f.content()).unwrap_or(""); + match output { + Some(path) => { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| DomainError::IoError(e.to_string()))?; + } + std::fs::write(path, content) + .map_err(|e| DomainError::IoError(e.to_string())) + } + None => { + print!("{content}"); + Ok(()) + } + } +} diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs new file mode 100644 index 0000000..4c069d1 --- /dev/null +++ b/crates/application/src/use_cases/mod.rs @@ -0,0 +1,3 @@ +pub mod check_freshness; +pub mod diff_diagram; +pub mod generate_diagram; diff --git a/crates/domain/src/aggregates/mod.rs b/crates/domain/src/aggregates/mod.rs index b048f79..90750dd 100644 --- a/crates/domain/src/aggregates/mod.rs +++ b/crates/domain/src/aggregates/mod.rs @@ -1,3 +1,5 @@ mod code_graph; +mod normalized_graph; pub use code_graph::CodeGraph; +pub use normalized_graph::NormalizedGraph; diff --git a/crates/domain/src/aggregates/normalized_graph.rs b/crates/domain/src/aggregates/normalized_graph.rs new file mode 100644 index 0000000..dff5943 --- /dev/null +++ b/crates/domain/src/aggregates/normalized_graph.rs @@ -0,0 +1,77 @@ +use std::collections::{HashMap, HashSet}; + +use crate::{CodeElement, CodeGraph, DomainError, ModuleName, Relationship}; + +/// A `CodeGraph` that has been fully normalized: qualified, resolved, and +/// filtered. Only this type exposes module-level and split-by-module queries — +/// callers cannot call those on a raw `CodeGraph`, making incorrect pipeline +/// order a compile-time error rather than a silent bug. +#[derive(Debug, Clone)] +pub struct NormalizedGraph(CodeGraph); + +impl NormalizedGraph { + /// Normalize a raw `CodeGraph` — qualifies type names, resolves + /// relationships, and filters external imports in one named operation. + pub fn from_analyzed( + graph: CodeGraph, + known_dirs: &HashSet, + ) -> Result { + let normalized = graph + .qualify() + .resolve_relationships() + .filter_external_imports(known_dirs); + Ok(Self(normalized)) + } + + /// Wrap a project-level graph (from `CargoWorkspaceAnalyzer` or + /// `PythonProjectAnalyzer`) that is already ready to render — no + /// analysis pipeline needed. + pub fn from_project(graph: CodeGraph) -> Self { + Self(graph) + } + + // ── Element access ─────────────────────────────────────────────────────── + + pub fn elements(&self) -> &[CodeElement] { + self.0.elements() + } + + pub fn relationships(&self) -> &[Relationship] { + self.0.relationships() + } + + pub fn modules(&self) -> Vec { + self.0.modules() + } + + pub fn elements_by_module( + &self, + ) -> (HashMap>, Vec<&CodeElement>) { + self.0.elements_by_module() + } + + // ── Module-level queries (only available on NormalizedGraph) ───────────── + + pub fn module_edges(&self) -> HashMap<(String, String), usize> { + self.0.module_edges() + } + + pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph { + self.0.subgraph_by_module(module) + } + + pub fn cross_module_deps_for(&self, module: &ModuleName) -> Vec<(ModuleName, usize)> { + self.0.cross_module_deps_for(module) + } + + // ── Mutation (merge project edges after normalization) ─────────────────── + + pub fn merge_project_edges(&mut self, project_graph: &CodeGraph) { + self.0.merge_project_edges(project_graph); + } + + /// Expose the inner graph for renderers that accept `&CodeGraph`. + pub fn as_graph(&self) -> &CodeGraph { + &self.0 + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 065581e..98db5ad 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -5,7 +5,7 @@ pub mod entities; pub mod ports; pub mod value_objects; -pub use aggregates::CodeGraph; +pub use aggregates::{CodeGraph, NormalizedGraph}; pub use entities::{CodeElement, Relationship}; pub use error::DomainError; pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning}; @@ -13,5 +13,6 @@ pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility}; pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile}; pub use value_objects::rules::{BoundaryRule, RuleKind, RuleViolation, check_boundary_rules}; pub use value_objects::source::{ - FilePath, Language, ModuleName, SourceFile, normalize_cargo_package, normalize_python_package, + FilePath, Language, ModuleAssignment, ModuleName, SourceFile, + normalize_cargo_package, normalize_python_package, }; diff --git a/crates/domain/src/ports/diagram_renderer.rs b/crates/domain/src/ports/diagram_renderer.rs index fc13d9a..a56d014 100644 --- a/crates/domain/src/ports/diagram_renderer.rs +++ b/crates/domain/src/ports/diagram_renderer.rs @@ -3,13 +3,19 @@ use crate::{CodeGraph, DomainError, ModuleName, RenderOutput}; pub trait DiagramRenderer { fn render(&self, graph: &CodeGraph) -> Result; - fn append_cross_module_deps( + /// Render a single module's subgraph for split-by-module output. + /// + /// `cross_deps` is the list of (external module, relationship count) pairs + /// for dependencies this module has on other modules. The default + /// implementation ignores cross_deps and falls back to `render(subgraph)`. + /// Adapters that support per-module annotations (e.g. Mermaid) override + /// this to include cross-module dependency information in the output. + fn render_for_module( &self, - content: &str, - module: &ModuleName, - deps: &[(ModuleName, usize)], - ) -> String { - let _ = (module, deps); - content.to_string() + subgraph: &CodeGraph, + _module: &ModuleName, + _cross_deps: &[(ModuleName, usize)], + ) -> Result { + self.render(subgraph) } } diff --git a/crates/domain/src/value_objects/source/mod.rs b/crates/domain/src/value_objects/source/mod.rs index ae4c121..b79e753 100644 --- a/crates/domain/src/value_objects/source/mod.rs +++ b/crates/domain/src/value_objects/source/mod.rs @@ -5,7 +5,7 @@ mod source_file; pub use file_path::FilePath; pub use language::Language; -pub use module_name::ModuleName; +pub use module_name::{ModuleAssignment, ModuleName}; pub use source_file::SourceFile; pub fn normalize_cargo_package(name: &str) -> String { diff --git a/crates/domain/src/value_objects/source/module_name.rs b/crates/domain/src/value_objects/source/module_name.rs index d0d0c09..2dfe909 100644 --- a/crates/domain/src/value_objects/source/module_name.rs +++ b/crates/domain/src/value_objects/source/module_name.rs @@ -6,6 +6,40 @@ use crate::DomainError; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ModuleName(String); +/// How a module name was assigned to a source file. +#[derive(Debug, Clone)] +pub enum ModuleAssignment { + /// Matched an explicit user-configured mapping in `archlens.toml`. + Explicit(ModuleName), + /// Derived from file path structure by heuristic (e.g. `crates//...`). + Inferred(ModuleName), + /// No mapping matched and the heuristic could not determine a module. + Unresolved(&'static str), +} + +impl ModuleAssignment { + pub fn module_name(&self) -> Option<&ModuleName> { + match self { + Self::Explicit(m) | Self::Inferred(m) => Some(m), + Self::Unresolved(_) => None, + } + } + + pub fn into_module_name(self) -> Option { + match self { + Self::Explicit(m) | Self::Inferred(m) => Some(m), + Self::Unresolved(_) => None, + } + } + + pub fn reason(&self) -> Option<&'static str> { + match self { + Self::Unresolved(r) => Some(r), + _ => None, + } + } +} + impl ModuleName { pub fn new(value: &str) -> Result { let trimmed = value.trim(); @@ -15,11 +49,13 @@ impl ModuleName { Ok(Self(trimmed.to_string())) } - pub fn from_path( + /// Assign a module to a file path, returning a typed assignment that + /// distinguishes explicit mappings, heuristic inference, and failure. + pub fn assign( file_path: &str, root: &Path, module_mappings: &HashMap, - ) -> Option { + ) -> ModuleAssignment { let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); let root_str = canonical_root.to_str().unwrap_or(""); let relative = file_path @@ -27,15 +63,19 @@ impl ModuleName { .unwrap_or(file_path) .trim_start_matches('/'); + // 1. Explicit mapping for (pattern, module_name) in module_mappings { - if relative.starts_with(pattern.as_str()) { - return Self::new(module_name).ok(); + if relative.starts_with(pattern.as_str()) + && let Ok(m) = Self::new(module_name) + { + return ModuleAssignment::Explicit(m); } } + // 2. Heuristic inference from path structure let parts: Vec<&str> = relative.split('/').collect(); if parts.len() <= 1 { - return None; + return ModuleAssignment::Unresolved("path has no directory component"); } let module_dir = if (parts[0] == "crates" || parts[0] == "src") && parts.len() > 2 { @@ -43,10 +83,23 @@ impl ModuleName { } else if parts[0] != "src" && parts.len() > 1 { parts[0] } else { - return None; + return ModuleAssignment::Unresolved("path under src/ with no further structure"); }; - Self::new(&Self::capitalize(module_dir)).ok() + match Self::new(&Self::capitalize(module_dir)) { + Ok(m) => ModuleAssignment::Inferred(m), + Err(_) => ModuleAssignment::Unresolved("inferred directory name was empty"), + } + } + + /// Convenience wrapper — returns None for Unresolved. + /// Prefer `assign()` when you want to distinguish strategies. + pub fn from_path( + file_path: &str, + root: &Path, + module_mappings: &HashMap, + ) -> Option { + Self::assign(file_path, root, module_mappings).into_module_name() } pub fn from_directory_group(member_path: &str) -> Option { diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 4f7e365..bf5ea55 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -1,28 +1,28 @@ mod cli; -use std::path::PathBuf; - use anyhow::{Result, bail}; use archlens_application::queries::AnalyzeCodebase; +use archlens_application::use_cases::{ + check_freshness::CheckFreshness, + diff_diagram::DiffDiagram, + generate_diagram::{GenerateDiagram, write_split}, +}; 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}, + BoundaryRule, DiagramLevel, NormalizedGraph, + ports::{ConfigLoader, 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<()> { @@ -41,40 +41,44 @@ pub fn run(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 ext = format_extension(&args.format); if args.check { - return check_freshness(&args.output, &graph, &*renderer); + let existing_path = args.output.as_ref() + .ok_or_else(|| anyhow::anyhow!("--check requires --output to specify the file to check against"))?; + let up_to_date = CheckFreshness { + graph: &graph, + renderer: &*renderer, + existing_path: std::path::Path::new(existing_path), + }.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(()); } - // 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() - ); - } - } + 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 output_dir = args.output.as_ref().map(std::path::PathBuf::from); - if args.split_by_module { - write_split(&graph, &*renderer, &args.output, ext)?; - } else { - write_single(&graph, &*renderer, &args.output)?; + let use_case = GenerateDiagram { + graph, + renderer, + allow_rules: allow, + deny_rules: deny, + split_by_module: args.split_by_module, + format_ext: format_extension(&args.format).to_string(), + output_dir, + }; + + let violations = use_case.check_violations_only(); + if args.strict && !violations.is_empty() { + bail!("{} boundary rule violation(s) in strict mode", violations.len()); } + use_case.execute()?; Ok(()) } @@ -93,7 +97,7 @@ fn load_config(args: &Cli) -> Result { } } -fn build_graph(args: &Cli, level: DiagramLevel) -> Result { +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); @@ -116,10 +120,12 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result { 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 project_graph = if cargo_toml.exists() { + CargoWorkspaceAnalyzer::new().analyze(&args.path)? + } else { + PythonProjectAnalyzer::new().analyze(&args.path)? + }; + return Ok(NormalizedGraph::from_project(project_graph)); } let discovery = WalkdirDiscovery::new(); @@ -129,18 +135,10 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result { if !result.warnings().is_empty() { for warning in result.warnings() { - eprintln!( - "WARNING: {}:{} {}", - warning.file_path().as_str(), - warning.line(), - warning.message() - ); + 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() - ); + bail!("analysis produced {} warning(s) in strict mode", result.warnings().len()); } } @@ -167,9 +165,7 @@ fn create_renderer( show_weights: bool, ) -> Result> { match format { - "mermaid" => Ok(Box::new( - MermaidRenderer::with_level(level).with_weights(show_weights), - )), + "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())), @@ -186,85 +182,6 @@ fn format_extension(format: &str) -> &str { } } -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); @@ -272,47 +189,24 @@ fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> { 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 diff = DiffDiagram { + graph: &graph, + renderer: &*renderer, + existing_path, + }.execute()?; - let existing = std::fs::read_to_string(existing_path).unwrap_or_default(); - - if current == existing { + if diff.is_empty() { 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 &diff.removed { + println!("{line}"); } - for line in &existing_lines { - if !current_lines.contains(line) { - removed.push(*line); - } + for line in &diff.added { + println!("{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()); + println!("\n{} added, {} removed", diff.added.len(), diff.removed.len()); std::process::exit(1); } @@ -372,7 +266,6 @@ fn init_tracing(verbosity: u8) { 2 => "debug", _ => "trace", }; - tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() @@ -393,10 +286,7 @@ fn get_changed_files( .map_err(|e| anyhow::anyhow!("git not found: {e}"))?; if !output.status.success() { - bail!( - "git diff failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + bail!("git diff failed: {}", String::from_utf8_lossy(&output.stderr)); } let files = String::from_utf8_lossy(&output.stdout) @@ -420,33 +310,40 @@ fn run_watch(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 output_dir = args.output.as_ref().map(std::path::PathBuf::from); + if args.split_by_module { - write_split(&graph, &*renderer, &args.output, ext)?; + write_split(&graph, &*renderer, &output_dir, ext)?; } else { - write_single(&graph, &*renderer, &args.output)?; + let rendered = renderer.render(graph.as_graph())?; + let content = rendered.files().first().map(|f| f.content()).unwrap_or(""); + match &output_dir { + Some(path) => std::fs::write(path, content)?, + None => print!("{content}"), + } } + 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 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()); + let use_case = GenerateDiagram { + graph, + renderer, + allow_rules: allow, + deny_rules: deny, + split_by_module: false, + format_ext: ext.to_string(), + output_dir: None, + }; + for v in use_case.check_violations_only() { + eprintln!("RULE VIOLATION: {v}"); } } Ok(()) }; - eprintln!( - "Watching {} for changes (Ctrl+C to stop)...", - args.path.display() - ); + eprintln!("Watching {} for changes (Ctrl+C to stop)...", args.path.display()); if let Err(e) = run_once(&args) { eprintln!("Error: {e}"); } else { @@ -454,18 +351,14 @@ fn run_watch(args: Cli) -> Result<()> { } let (tx, rx) = mpsc::channel(); - let mut watcher = recommended_watcher(move |res| { - let _ = tx.send(res); - })?; + 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; - } + if last_run.elapsed() < debounce { continue; } last_run = Instant::now(); eprintln!("Change detected, regenerating..."); if let Err(e) = run_once(&args) { @@ -474,10 +367,7 @@ fn run_watch(args: Cli) -> Result<()> { eprintln!("Diagram updated."); } } - Err(e) => { - eprintln!("Watch error: {e}"); - break; - } + Err(e) => { eprintln!("Watch error: {e}"); break; } } } Ok(())