Compare commits

...

9 Commits

31 changed files with 2506 additions and 262 deletions

11
Cargo.lock generated
View File

@@ -107,6 +107,7 @@ name = "archlens-ascii"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"archlens-domain", "archlens-domain",
"archlens-rendering-primitives",
"thiserror", "thiserror",
"tracing", "tracing",
] ]
@@ -128,6 +129,7 @@ name = "archlens-d2"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"archlens-domain", "archlens-domain",
"archlens-rendering-primitives",
"tempfile", "tempfile",
] ]
@@ -153,6 +155,7 @@ name = "archlens-html"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"archlens-domain", "archlens-domain",
"archlens-rendering-primitives",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
@@ -163,6 +166,7 @@ name = "archlens-mermaid"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"archlens-domain", "archlens-domain",
"archlens-rendering-primitives",
"thiserror", "thiserror",
"tracing", "tracing",
] ]
@@ -177,6 +181,13 @@ dependencies = [
"toml", "toml",
] ]
[[package]]
name = "archlens-rendering-primitives"
version = "0.1.0"
dependencies = [
"archlens-domain",
]
[[package]] [[package]]
name = "archlens-stdout-writer" name = "archlens-stdout-writer"
version = "0.1.0" version = "0.1.0"

View File

@@ -15,6 +15,7 @@ members = [
"crates/adapters/python-project", "crates/adapters/python-project",
"crates/adapters/d2", "crates/adapters/d2",
"crates/adapters/html-viewer", "crates/adapters/html-viewer",
"crates/adapters/rendering-primitives",
] ]
[workspace.dependencies] [workspace.dependencies]
@@ -32,6 +33,7 @@ archlens-cargo-workspace = { path = "crates/adapters/cargo-workspace" }
archlens-python-project = { path = "crates/adapters/python-project" } archlens-python-project = { path = "crates/adapters/python-project" }
archlens-d2 = { path = "crates/adapters/d2" } archlens-d2 = { path = "crates/adapters/d2" }
archlens-html = { path = "crates/adapters/html-viewer" } archlens-html = { path = "crates/adapters/html-viewer" }
archlens-rendering-primitives = { path = "crates/adapters/rendering-primitives" }
serde_json = "1" serde_json = "1"
# Error handling # Error handling

View File

@@ -6,5 +6,6 @@ publish = false
[dependencies] [dependencies]
archlens-domain.workspace = true archlens-domain.workspace = true
archlens-rendering-primitives.workspace = true
thiserror.workspace = true thiserror.workspace = true
tracing.workspace = true tracing.workspace = true

View File

@@ -2,6 +2,7 @@ use archlens_domain::{
CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile, CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile,
ports::DiagramRenderer, ports::DiagramRenderer,
}; };
use archlens_rendering_primitives::non_import_rels;
pub struct AsciiRenderer; pub struct AsciiRenderer;
@@ -94,20 +95,16 @@ impl DiagramRenderer for AsciiRenderer {
lines.push("└───".to_string()); lines.push("└───".to_string());
} }
let non_import_rels: Vec<_> = graph let filtered_rels: Vec<_> = non_import_rels(graph.relationships()).collect();
.relationships()
.iter()
.filter(|r| r.kind() != RelationshipKind::Import)
.collect();
if !non_import_rels.is_empty() { if !filtered_rels.is_empty() {
lines.push(String::new()); lines.push(String::new());
lines.push("── Relationships ──".to_string()); lines.push("── Relationships ──".to_string());
for rel in &non_import_rels { for rel in &filtered_rels {
let arrow = match rel.kind() { let arrow = match rel.kind() {
RelationshipKind::Inheritance => "extends", RelationshipKind::Inheritance => "extends",
RelationshipKind::Composition => "has", RelationshipKind::Composition => "has",
RelationshipKind::Import => "imports", RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
}; };
lines.push(format!( lines.push(format!(
" {} ─[{}]─> {}", " {} ─[{}]─> {}",

View File

@@ -6,6 +6,7 @@ publish = false
[dependencies] [dependencies]
archlens-domain.workspace = true archlens-domain.workspace = true
archlens-rendering-primitives.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true tempfile.workspace = true

View File

@@ -1,6 +1,7 @@
use archlens_domain::{ use archlens_domain::{
CodeGraph, DiagramLevel, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer, CodeGraph, DiagramLevel, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer,
}; };
use archlens_rendering_primitives::{non_import_rels, sanitize_identifier};
pub struct D2Renderer { pub struct D2Renderer {
level: DiagramLevel, level: DiagramLevel,
@@ -36,20 +37,16 @@ impl DiagramRenderer for D2Renderer {
} }
} }
fn sanitize(name: &str) -> String {
name.replace("::", "_").replace(['-', ' '], "_")
}
fn render_type(graph: &CodeGraph) -> String { fn render_type(graph: &CodeGraph) -> String {
let mut lines = Vec::new(); let mut lines = Vec::new();
let (by_module, ungrouped) = graph.elements_by_module(); let (by_module, ungrouped) = graph.elements_by_module();
// Grouped by module // Grouped by module
for (module, elements) in &by_module { for (module, elements) in &by_module {
let mod_id = sanitize(module); let mod_id = sanitize_identifier(module);
lines.push(format!("{mod_id}: {{")); lines.push(format!("{mod_id}: {{"));
for el in elements { for el in elements {
let el_id = sanitize(el.name()); let el_id = sanitize_identifier(el.name());
lines.push(format!(" {el_id}: {{")); lines.push(format!(" {el_id}: {{"));
lines.push(" shape: class".to_string()); lines.push(" shape: class".to_string());
for field in el.fields() { for field in el.fields() {
@@ -69,21 +66,21 @@ fn render_type(graph: &CodeGraph) -> String {
// Ungrouped elements // Ungrouped elements
for el in &ungrouped { for el in &ungrouped {
let el_id = sanitize(el.name()); let el_id = sanitize_identifier(el.name());
lines.push(format!("{el_id}: {{")); lines.push(format!("{el_id}: {{"));
lines.push(" shape: class".to_string()); lines.push(" shape: class".to_string());
lines.push("}".to_string()); lines.push("}".to_string());
} }
// Relationships // Relationships
for rel in graph.relationships() { for rel in non_import_rels(graph.relationships()) {
use archlens_domain::RelationshipKind; use archlens_domain::RelationshipKind;
let src = sanitize(rel.source()); let src = sanitize_identifier(rel.source());
let tgt = sanitize(rel.target()); let tgt = sanitize_identifier(rel.target());
let arrow = match rel.kind() { let arrow = match rel.kind() {
RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"), RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"),
RelationshipKind::Composition => format!("{src} -> {tgt}"), RelationshipKind::Composition => format!("{src} -> {tgt}"),
RelationshipKind::Import => continue, RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
}; };
lines.push(arrow); lines.push(arrow);
} }
@@ -95,47 +92,51 @@ fn render_module(graph: &CodeGraph) -> String {
let mut lines = Vec::new(); let mut lines = Vec::new();
for module in graph.modules() { for module in graph.modules() {
let id = sanitize(module.as_str()); let id = sanitize_identifier(module.as_str());
lines.push(format!("{id}: {}", module.as_str())); lines.push(format!("{id}: {}", module.as_str()));
} }
for (src, tgt) in graph.module_edges().keys() { for (src, tgt) in graph.module_edges().keys() {
lines.push(format!("{} -> {}", sanitize(src), sanitize(tgt))); lines.push(format!(
"{} -> {}",
sanitize_identifier(src),
sanitize_identifier(tgt)
));
} }
lines.join("\n") lines.join("\n")
} }
fn render_project(graph: &CodeGraph) -> String { fn render_project(graph: &CodeGraph) -> String {
use archlens_domain::RelationshipKind;
use std::collections::HashMap; use std::collections::HashMap;
let mut lines = Vec::new(); let mut lines = Vec::new();
let (by_module, ungrouped) = graph.elements_by_module(); let (by_module, ungrouped) = graph.elements_by_module();
for (module, elements) in &by_module { for (module, elements) in &by_module {
let mod_id = sanitize(module); let mod_id = sanitize_identifier(module);
lines.push(format!("{mod_id}: {{")); lines.push(format!("{mod_id}: {{"));
for el in elements { for el in elements {
lines.push(format!(" {}: {}", sanitize(el.name()), el.name())); lines.push(format!(
" {}: {}",
sanitize_identifier(el.name()),
el.name()
));
} }
lines.push("}".to_string()); lines.push("}".to_string());
} }
for el in &ungrouped { for el in &ungrouped {
lines.push(format!("{}: {}", sanitize(el.name()), el.name())); lines.push(format!("{}: {}", sanitize_identifier(el.name()), el.name()));
} }
let name_to_id: HashMap<&str, String> = graph let name_to_id: HashMap<&str, String> = graph
.elements() .elements()
.iter() .iter()
.map(|e| (e.name(), sanitize(e.name()))) .map(|e| (e.name(), sanitize_identifier(e.name())))
.collect(); .collect();
for rel in graph.relationships() { for rel in non_import_rels(graph.relationships()) {
if rel.kind() == RelationshipKind::Import {
continue;
}
if let (Some(src), Some(tgt)) = (name_to_id.get(rel.source()), name_to_id.get(rel.target())) if let (Some(src), Some(tgt)) = (name_to_id.get(rel.source()), name_to_id.get(rel.target()))
{ {
lines.push(format!("{src} -> {tgt}")); lines.push(format!("{src} -> {tgt}"));

View File

@@ -6,6 +6,7 @@ publish = false
[dependencies] [dependencies]
archlens-domain.workspace = true archlens-domain.workspace = true
archlens-rendering-primitives.workspace = true
serde.workspace = true serde.workspace = true
serde_json = "1" serde_json = "1"

View File

@@ -2,9 +2,8 @@ use std::collections::HashMap;
use serde::Serialize; use serde::Serialize;
use archlens_domain::{ use archlens_domain::{CodeGraph, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer};
CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile, ports::DiagramRenderer, use archlens_rendering_primitives::non_import_rels;
};
pub struct HtmlRenderer; pub struct HtmlRenderer;
@@ -66,10 +65,7 @@ impl DiagramRenderer for HtmlRenderer {
}); });
} }
let edges = graph let edges = non_import_rels(graph.relationships())
.relationships()
.iter()
.filter(|r| r.kind() != RelationshipKind::Import)
.filter_map(|r| { .filter_map(|r| {
let src = id_map.get(r.source())?; let src = id_map.get(r.source())?;
let tgt = id_map.get(r.target())?; let tgt = id_map.get(r.target())?;

View File

@@ -6,5 +6,6 @@ publish = false
[dependencies] [dependencies]
archlens-domain.workspace = true archlens-domain.workspace = true
archlens-rendering-primitives.workspace = true
thiserror.workspace = true thiserror.workspace = true
tracing.workspace = true tracing.workspace = true

View File

@@ -4,6 +4,7 @@ use archlens_domain::{
CodeElement, CodeGraph, DiagramLevel, DomainError, ModuleName, RelationshipKind, RenderOutput, CodeElement, CodeGraph, DiagramLevel, DomainError, ModuleName, RelationshipKind, RenderOutput,
RenderedFile, Visibility, ports::DiagramRenderer, RenderedFile, Visibility, ports::DiagramRenderer,
}; };
use archlens_rendering_primitives::non_import_rels;
pub struct MermaidRenderer { pub struct MermaidRenderer {
level: DiagramLevel, level: DiagramLevel,
@@ -94,14 +95,11 @@ impl MermaidRenderer {
lines.extend(deferred_members); lines.extend(deferred_members);
let mut rel_seen: HashSet<String> = HashSet::new(); let mut rel_seen: HashSet<String> = HashSet::new();
for rel in graph.relationships() { for rel in non_import_rels(graph.relationships()) {
if rel.kind() == RelationshipKind::Import {
continue;
}
let arrow = match rel.kind() { let arrow = match rel.kind() {
RelationshipKind::Inheritance => "<|--", RelationshipKind::Inheritance => "<|--",
RelationshipKind::Composition => "-->", RelationshipKind::Composition => "-->",
RelationshipKind::Import => "..>", RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
}; };
let src = Self::display_name(rel.source()); let src = Self::display_name(rel.source());
let tgt = Self::display_name(rel.target()); let tgt = Self::display_name(rel.target());

View File

@@ -0,0 +1,8 @@
[package]
name = "archlens-rendering-primitives"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true

View File

@@ -0,0 +1,11 @@
use archlens_domain::{Relationship, RelationshipKind};
/// Returns an iterator over all relationships except those with kind `Import`.
pub fn non_import_rels(rels: &[Relationship]) -> impl Iterator<Item = &Relationship> {
rels.iter().filter(|r| r.kind() != RelationshipKind::Import)
}
/// Replaces `::`, `-`, `.`, and space with `_`.
pub fn sanitize_identifier(name: &str) -> String {
name.replace("::", "_").replace(['-', '.', ' '], "_")
}

View File

@@ -0,0 +1,52 @@
use archlens_domain::{Relationship, RelationshipKind};
use archlens_rendering_primitives::{non_import_rels, sanitize_identifier};
fn rel(src: &str, tgt: &str, kind: RelationshipKind) -> Relationship {
Relationship::new(src, tgt, kind).unwrap()
}
#[test]
fn non_import_rels_excludes_import_relationships() {
let rels = vec![
rel("A", "B", RelationshipKind::Composition),
rel("C", "D", RelationshipKind::Import),
rel("E", "F", RelationshipKind::Inheritance),
];
let filtered: Vec<_> = non_import_rels(&rels).collect();
assert_eq!(filtered.len(), 2);
assert!(
filtered
.iter()
.all(|r| r.kind() != RelationshipKind::Import)
);
}
#[test]
fn non_import_rels_passes_all_non_import_kinds() {
let rels = vec![
rel("A", "B", RelationshipKind::Composition),
rel("C", "D", RelationshipKind::Inheritance),
];
let filtered: Vec<_> = non_import_rels(&rels).collect();
assert_eq!(filtered.len(), 2);
}
#[test]
fn sanitize_identifier_replaces_double_colon_with_underscore() {
assert_eq!(sanitize_identifier("foo::bar"), "foo_bar");
}
#[test]
fn sanitize_identifier_replaces_hyphen_with_underscore() {
assert_eq!(sanitize_identifier("my-crate"), "my_crate");
}
#[test]
fn sanitize_identifier_replaces_dot_with_underscore() {
assert_eq!(sanitize_identifier("v1.2"), "v1_2");
}
#[test]
fn sanitize_identifier_replaces_space_with_underscore() {
assert_eq!(sanitize_identifier("my crate"), "my_crate");
}

View File

@@ -1,5 +1,39 @@
use tree_sitter::{Node, Parser};
use archlens_domain::{AnalysisResult, DomainError, FilePath}; use archlens_domain::{AnalysisResult, DomainError, FilePath};
use crate::extraction_context::ExtractionContext;
pub trait LanguageExtractor { pub trait LanguageExtractor {
fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError>; fn tree_sitter_language(&self) -> tree_sitter::Language;
fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
}
pub fn run_extraction(
extractor: &dyn LanguageExtractor,
source: &str,
file_path: &FilePath,
) -> Result<AnalysisResult, DomainError> {
let mut parser = Parser::new();
parser
.set_language(&extractor.tree_sitter_language())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
let tree = parser
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse source".into()))?;
let mut ctx = ExtractionContext::new(file_path.clone());
let root = tree.root_node();
extractor.extract_types(&root, source, &mut ctx);
extractor.extract_relationships(&root, source, &mut ctx);
extractor.extract_imports(&root, source, &mut ctx);
ctx.into_result()
} }

View File

@@ -1,9 +1,6 @@
use tree_sitter::{Node, Parser}; use tree_sitter::Node;
use archlens_domain::{ use archlens_domain::{CodeElement, CodeElementKind, Relationship, RelationshipKind};
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
RelationshipKind,
};
use crate::extraction_context::ExtractionContext; use crate::extraction_context::ExtractionContext;
use crate::language_extractor::LanguageExtractor; use crate::language_extractor::LanguageExtractor;
@@ -11,28 +8,23 @@ use crate::language_extractor::LanguageExtractor;
pub struct PythonExtractor; pub struct PythonExtractor;
impl LanguageExtractor for PythonExtractor { impl LanguageExtractor for PythonExtractor {
fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> { fn tree_sitter_language(&self) -> tree_sitter::Language {
analyze(source, file_path) tree_sitter_python::LANGUAGE.into()
} }
}
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> { fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
let mut parser = Parser::new(); // collect_classes handles class elements, inheritance, and field compositions
parser // in a single pass — Python's relationship extraction is interleaved with type extraction
.set_language(&tree_sitter_python::LANGUAGE.into()) collect_classes(root, source, ctx);
.map_err(|e| DomainError::AnalysisError(e.to_string()))?; }
let tree = parser fn extract_relationships(&self, _root: &Node, _source: &str, _ctx: &mut ExtractionContext) {
.parse(source, None) // Relationships are collected inside collect_classes for Python
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?; }
let mut ctx = ExtractionContext::new(file_path.clone()); fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
let root = tree.root_node(); collect_imports(root, source, ctx);
}
collect_classes(&root, source, &mut ctx);
collect_imports(&root, source, &mut ctx);
ctx.into_result()
} }
fn collect_classes(node: &Node, source: &str, ctx: &mut ExtractionContext) { fn collect_classes(node: &Node, source: &str, ctx: &mut ExtractionContext) {

View File

@@ -1,4 +1,4 @@
use tree_sitter::{Node, Parser}; use tree_sitter::Node;
const RUST_PRIMITIVES: &[&str] = &[ const RUST_PRIMITIVES: &[&str] = &[
"bool", "bool",
@@ -35,10 +35,7 @@ const RUST_PRIMITIVES: &[&str] = &[
"Self", "Self",
]; ];
use archlens_domain::{ use archlens_domain::{CodeElement, CodeElementKind, Relationship, RelationshipKind, Visibility};
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
RelationshipKind, Visibility,
};
use crate::extraction_context::ExtractionContext; use crate::extraction_context::ExtractionContext;
use crate::language_extractor::LanguageExtractor; use crate::language_extractor::LanguageExtractor;
@@ -46,30 +43,22 @@ use crate::language_extractor::LanguageExtractor;
pub struct RustExtractor; pub struct RustExtractor;
impl LanguageExtractor for RustExtractor { impl LanguageExtractor for RustExtractor {
fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> { fn tree_sitter_language(&self) -> tree_sitter::Language {
analyze(source, file_path) tree_sitter_rust::LANGUAGE.into()
} }
}
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> { fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
let mut parser = Parser::new(); collect_types(root, source, ctx);
parser }
.set_language(&tree_sitter_rust::LANGUAGE.into())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
let tree = parser fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
.parse(source, None) collect_relationships(root, source, ctx);
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?; }
let mut ctx = ExtractionContext::new(file_path.clone()); fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
let root = tree.root_node(); collect_mod_declarations(root, source, ctx);
collect_use_imports(root, source, ctx);
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);
ctx.into_result()
} }
fn collect_types(node: &Node, source: &str, ctx: &mut ExtractionContext) { fn collect_types(node: &Node, source: &str, ctx: &mut ExtractionContext) {

View File

@@ -1,6 +1,6 @@
use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer}; use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer};
use crate::language_extractor::LanguageExtractor; use crate::language_extractor::{LanguageExtractor, run_extraction};
use crate::python::PythonExtractor; use crate::python::PythonExtractor;
use crate::rust::RustExtractor; use crate::rust::RustExtractor;
@@ -38,7 +38,7 @@ impl SourceAnalyzer for TreeSitterAnalyzer {
.map_err(|e| DomainError::IoError(e.to_string()))?; .map_err(|e| DomainError::IoError(e.to_string()))?;
match self.extractor_for(file.language()) { match self.extractor_for(file.language()) {
Some(extractor) => extractor.analyze(&source, file.path()), Some(extractor) => run_extraction(extractor, &source, file.path()),
None => Ok(AnalysisResult::empty()), None => Ok(AnalysisResult::empty()),
} }
} }

View File

@@ -0,0 +1,69 @@
use std::path::Path;
use archlens_domain::{
AnalysisConfig, AnalysisWarning, DiagramLevel, DomainError, NormalizedGraph,
ports::{FileDiscovery, ProjectAnalyzer, SourceAnalyzer},
};
use crate::queries::AnalyzeCodebase;
#[derive(Debug)]
pub struct BuildCodeGraphResult {
pub graph: NormalizedGraph,
pub warnings: Vec<AnalysisWarning>,
}
pub struct BuildCodeGraph<F, S>
where
F: FileDiscovery + Send + Sync,
S: SourceAnalyzer,
{
pub discovery: F,
pub source_analyzer: S,
pub project_analyzer: Option<Box<dyn ProjectAnalyzer>>,
}
impl<F, S> BuildCodeGraph<F, S>
where
F: FileDiscovery + Send + Sync,
S: SourceAnalyzer,
{
pub fn execute(
self,
root: &Path,
config: &AnalysisConfig,
level: DiagramLevel,
) -> Result<BuildCodeGraphResult, DomainError> {
match level {
DiagramLevel::Project => {
let pa = self.project_analyzer.ok_or_else(|| {
DomainError::AnalysisError(
"no project analyzer available for Project level".into(),
)
})?;
let cg = pa.analyze(root)?;
Ok(BuildCodeGraphResult {
graph: NormalizedGraph::from_project(cg),
warnings: Vec::new(),
})
}
DiagramLevel::Module | DiagramLevel::Type => {
let analyze = AnalyzeCodebase::new(self.discovery, self.source_analyzer);
let result = analyze.execute(root, config)?;
let mut graph = result.graph().clone();
if level == DiagramLevel::Module
&& let Some(pa) = self.project_analyzer
{
// Propagate error: a present but malformed manifest is a user error,
// not a silent skip.
let project_cg = pa.analyze(root)?;
graph.merge_project_edges(&project_cg);
}
Ok(BuildCodeGraphResult {
graph,
warnings: result.warnings().to_vec(),
})
}
}
}
}

View File

@@ -1,18 +1,17 @@
use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer}; use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};
/// Compares the current rendered output against an on-disk file. /// Compares the current rendered output against provided file content.
/// Returns `Ok(true)` if up to date, `Ok(false)` if stale. /// Returns `Ok(true)` if up to date, `Ok(false)` if stale.
pub struct CheckFreshness<'a> { pub struct CheckFreshness<'a> {
pub graph: &'a NormalizedGraph, pub graph: &'a NormalizedGraph,
pub renderer: &'a dyn DiagramRenderer, pub renderer: &'a dyn DiagramRenderer,
pub existing_path: &'a std::path::Path, pub existing_content: &'a str,
} }
impl<'a> CheckFreshness<'a> { impl<'a> CheckFreshness<'a> {
pub fn execute(&self) -> Result<bool, DomainError> { pub fn execute(&self) -> Result<bool, DomainError> {
let rendered = self.renderer.render(self.graph.as_graph())?; let rendered = self.renderer.render(self.graph.as_graph())?;
let current = rendered.files().first().map(|f| f.content()).unwrap_or(""); 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 == self.existing_content)
Ok(current == existing)
} }
} }

View File

@@ -12,22 +12,22 @@ impl DiffResult {
} }
} }
/// Compares the rendered current graph against an existing diagram file and /// Compares the rendered current graph against provided diagram content and
/// returns which lines were added or removed. /// returns which lines were added or removed.
pub struct DiffDiagram<'a> { pub struct DiffDiagram<'a> {
pub graph: &'a NormalizedGraph, pub graph: &'a NormalizedGraph,
pub renderer: &'a dyn DiagramRenderer, pub renderer: &'a dyn DiagramRenderer,
pub existing_path: &'a std::path::Path, pub existing_content: &'a str,
} }
impl<'a> DiffDiagram<'a> { impl<'a> DiffDiagram<'a> {
pub fn execute(&self) -> Result<DiffResult, DomainError> { pub fn execute(&self) -> Result<DiffResult, DomainError> {
let rendered = self.renderer.render(self.graph.as_graph())?; let rendered = self.renderer.render(self.graph.as_graph())?;
let current = rendered.files().first().map(|f| f.content()).unwrap_or(""); 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 current_lines: std::collections::HashSet<&str> = current.lines().collect();
let existing_lines: std::collections::HashSet<&str> = existing.lines().collect(); let existing_lines: std::collections::HashSet<&str> =
self.existing_content.lines().collect();
let added: Vec<String> = current_lines let added: Vec<String> = current_lines
.difference(&existing_lines) .difference(&existing_lines)

View File

@@ -1,122 +1,67 @@
use std::path::PathBuf;
use archlens_domain::{ use archlens_domain::{
BoundaryRule, DomainError, NormalizedGraph, RenderOutput, RenderedFile, check_boundary_rules, BoundaryRule, DomainError, NormalizedGraph, RenderOutput, RenderedFile, RuleViolation,
ports::DiagramRenderer, 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 struct GenerateDiagramResult {
pub violations: Vec<String>, pub violations: Vec<RuleViolation>,
pub output: RenderOutput, 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 struct GenerateDiagram {
pub graph: NormalizedGraph, pub graph: NormalizedGraph,
pub renderer: Box<dyn DiagramRenderer>, pub renderer: Box<dyn DiagramRenderer>,
pub allow_rules: Vec<BoundaryRule>, pub allow_rules: Vec<BoundaryRule>,
pub deny_rules: Vec<BoundaryRule>, pub deny_rules: Vec<BoundaryRule>,
pub split_by_module: bool, pub split_by_module: bool,
pub format_ext: String,
pub output_dir: Option<PathBuf>,
} }
impl GenerateDiagram { impl GenerateDiagram {
pub fn execute(self) -> Result<(), DomainError> { pub fn execute(self) -> Result<GenerateDiagramResult, DomainError> {
// Boundary rule checking
let violations = if !self.allow_rules.is_empty() || !self.deny_rules.is_empty() { 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) check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules)
} else { } else {
Vec::new() Vec::new()
}; };
// Render and write let output = if self.split_by_module {
if self.split_by_module { render_split(&self.graph, &*self.renderer)?
write_split(
&self.graph,
&*self.renderer,
&self.output_dir,
&self.format_ext,
)?;
} else { } else {
let rendered = self.renderer.render(self.graph.as_graph())?; self.renderer.render(self.graph.as_graph())?
write_to_output(rendered, &self.output_dir)?; };
}
// Report violations (after writing so the diagram is still produced) Ok(GenerateDiagramResult { violations, output })
for v in &violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
Ok(())
}
pub fn check_violations_only(&self) -> Vec<String> {
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( fn render_split(
graph: &NormalizedGraph, graph: &NormalizedGraph,
renderer: &dyn DiagramRenderer, renderer: &dyn DiagramRenderer,
output_dir: &Option<PathBuf>, ) -> Result<RenderOutput, DomainError> {
ext: &str, let mut files = Vec::new();
) -> Result<(), DomainError> {
let dir = output_dir.clone().unwrap_or_else(|| PathBuf::from("."));
let overview = renderer.render(graph.as_graph())?; let overview = renderer.render(graph.as_graph())?;
let overview_file = RenderedFile::new( let ext = overview
&format!("overview.{ext}"), .files()
overview.files().first().map(|f| f.content()).unwrap_or(""), .first()
)?; .and_then(|f| f.name().rsplit_once('.').map(|(_, e)| e))
write_file_to_dir(&dir, overview_file)?; .unwrap_or("txt");
if let Some(f) = overview.files().first() {
files.push(RenderedFile::new(&format!("overview.{ext}"), f.content())?);
}
for module in graph.modules() { for module in graph.modules() {
let subgraph = graph.subgraph_by_module(&module); let subgraph = graph.subgraph_by_module(&module);
let cross_deps = graph.cross_module_deps_for(&module); let cross_deps = graph.cross_module_deps_for(&module);
let module_output = renderer.render_for_module(&subgraph, &module, &cross_deps)?; let module_output = renderer.render_for_module(&subgraph, &module, &cross_deps)?;
let module_file = RenderedFile::new( if let Some(f) = module_output.files().first() {
&format!("{}.{ext}", module.as_str().to_lowercase()), files.push(RenderedFile::new(
module_output &format!("{}.{ext}", module.as_str().to_lowercase()),
.files() f.content(),
.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<PathBuf>) -> 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(())
} }
} }
Ok(RenderOutput::new(files))
} }

View File

@@ -1,3 +1,4 @@
pub mod build_code_graph;
pub mod check_freshness; pub mod check_freshness;
pub mod diff_diagram; pub mod diff_diagram;
pub mod generate_diagram; pub mod generate_diagram;

View File

@@ -0,0 +1,176 @@
mod fakes;
use std::path::Path;
use archlens_application::use_cases::build_code_graph::BuildCodeGraph;
use archlens_domain::{
AnalysisConfig, AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, CodeGraph,
DiagramLevel, FilePath, Language, SourceFile,
};
use fakes::{FakeFileDiscovery, FakeProjectAnalyzer, FakeSourceAnalyzer};
#[test]
fn project_level_returns_project_analyzer_graph() {
let mut cg = CodeGraph::new();
cg.add_element(
CodeElement::new(
"MyApp",
CodeElementKind::Project,
FilePath::new("Cargo.toml").unwrap(),
1,
)
.unwrap(),
);
let use_case = BuildCodeGraph {
discovery: FakeFileDiscovery::empty(),
source_analyzer: FakeSourceAnalyzer::new(),
project_analyzer: Some(Box::new(FakeProjectAnalyzer::new(cg))),
};
let result = use_case
.execute(
Path::new("."),
&AnalysisConfig::default(),
DiagramLevel::Project,
)
.unwrap();
assert_eq!(result.graph.elements().len(), 1);
assert_eq!(result.graph.elements()[0].name(), "MyApp");
assert!(result.warnings.is_empty());
}
#[test]
fn project_level_without_analyzer_returns_error() {
let use_case = BuildCodeGraph {
discovery: FakeFileDiscovery::empty(),
source_analyzer: FakeSourceAnalyzer::new(),
project_analyzer: None,
};
let err = use_case
.execute(
Path::new("."),
&AnalysisConfig::default(),
DiagramLevel::Project,
)
.unwrap_err();
assert!(err.to_string().contains("no project analyzer"));
}
#[test]
fn type_level_uses_source_analyzer_not_project() {
let files = vec![SourceFile::new(
FilePath::new("src/order.rs").unwrap(),
Language::Rust,
)];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new().with_result(
"src/order.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
);
let mut project_cg = CodeGraph::new();
project_cg.add_element(
CodeElement::new(
"ProjectElement",
CodeElementKind::Project,
FilePath::new("Cargo.toml").unwrap(),
1,
)
.unwrap(),
);
let use_case = BuildCodeGraph {
discovery,
source_analyzer: analyzer,
project_analyzer: Some(Box::new(FakeProjectAnalyzer::new(project_cg))),
};
let result = use_case
.execute(
Path::new("."),
&AnalysisConfig::default(),
DiagramLevel::Type,
)
.unwrap();
// Source element present, project element NOT merged (Type level skips merge)
assert_eq!(result.graph.elements().len(), 1);
assert_eq!(result.graph.elements()[0].name(), "Order");
}
#[test]
fn module_level_without_project_analyzer_succeeds() {
let use_case = BuildCodeGraph {
discovery: FakeFileDiscovery::empty(),
source_analyzer: FakeSourceAnalyzer::new(),
project_analyzer: None,
};
let result = use_case
.execute(
Path::new("."),
&AnalysisConfig::default(),
DiagramLevel::Module,
)
.unwrap();
assert!(result.graph.elements().is_empty());
}
#[test]
fn warnings_from_source_analysis_are_propagated() {
let files = vec![SourceFile::new(
FilePath::new("src/broken.rs").unwrap(),
Language::Rust,
)];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new().with_result(
"src/broken.rs",
AnalysisResult::new(
vec![],
vec![],
vec![
AnalysisWarning::new(
FilePath::new("src/broken.rs").unwrap(),
5,
"unparseable block",
)
.unwrap(),
],
),
);
let use_case = BuildCodeGraph {
discovery,
source_analyzer: analyzer,
project_analyzer: None,
};
let result = use_case
.execute(
Path::new("."),
&AnalysisConfig::default(),
DiagramLevel::Module,
)
.unwrap();
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].message(), "unparseable block");
}

View File

@@ -0,0 +1,43 @@
mod fakes;
use archlens_application::use_cases::check_freshness::CheckFreshness;
use archlens_domain::{CodeGraph, NormalizedGraph, ports::DiagramRenderer};
use fakes::FakeDiagramRenderer;
fn empty_graph() -> NormalizedGraph {
NormalizedGraph::from_project(CodeGraph::new())
}
#[test]
fn returns_true_when_content_matches() {
let graph = empty_graph();
let renderer = FakeDiagramRenderer::new();
let rendered = renderer.render(graph.as_graph()).unwrap();
let existing = rendered.files().first().unwrap().content().to_string();
let result = CheckFreshness {
graph: &graph,
renderer: &renderer,
existing_content: &existing,
}
.execute()
.unwrap();
assert!(result);
}
#[test]
fn returns_false_when_content_differs() {
let graph = empty_graph();
let renderer = FakeDiagramRenderer::new();
let result = CheckFreshness {
graph: &graph,
renderer: &renderer,
existing_content: "stale content that does not match",
}
.execute()
.unwrap();
assert!(!result);
}

View File

@@ -0,0 +1,44 @@
mod fakes;
use archlens_application::use_cases::diff_diagram::DiffDiagram;
use archlens_domain::{CodeGraph, NormalizedGraph, ports::DiagramRenderer};
use fakes::FakeDiagramRenderer;
fn empty_graph() -> NormalizedGraph {
NormalizedGraph::from_project(CodeGraph::new())
}
#[test]
fn no_diff_when_content_matches() {
let graph = empty_graph();
let renderer = FakeDiagramRenderer::new();
let rendered = renderer.render(graph.as_graph()).unwrap();
let existing = rendered.files().first().unwrap().content().to_string();
let result = DiffDiagram {
graph: &graph,
renderer: &renderer,
existing_content: &existing,
}
.execute()
.unwrap();
assert!(result.is_empty());
}
#[test]
fn detects_added_lines() {
let graph = empty_graph();
let renderer = FakeDiagramRenderer::new();
let result = DiffDiagram {
graph: &graph,
renderer: &renderer,
existing_content: "old line that will be removed",
}
.execute()
.unwrap();
assert!(!result.added.is_empty());
assert!(!result.removed.is_empty());
}

View File

@@ -3,9 +3,11 @@
mod diagram_renderer; mod diagram_renderer;
mod file_discovery; mod file_discovery;
mod output_writer; mod output_writer;
mod project_analyzer;
mod source_analyzer; mod source_analyzer;
pub use diagram_renderer::FakeDiagramRenderer; pub use diagram_renderer::FakeDiagramRenderer;
pub use file_discovery::FakeFileDiscovery; pub use file_discovery::FakeFileDiscovery;
pub use output_writer::FakeOutputWriter; pub use output_writer::FakeOutputWriter;
pub use project_analyzer::FakeProjectAnalyzer;
pub use source_analyzer::FakeSourceAnalyzer; pub use source_analyzer::FakeSourceAnalyzer;

View File

@@ -0,0 +1,25 @@
use std::path::Path;
use archlens_domain::{CodeGraph, DomainError, ports::ProjectAnalyzer};
pub struct FakeProjectAnalyzer {
graph: CodeGraph,
}
impl FakeProjectAnalyzer {
pub fn new(graph: CodeGraph) -> Self {
Self { graph }
}
pub fn empty() -> Self {
Self {
graph: CodeGraph::new(),
}
}
}
impl ProjectAnalyzer for FakeProjectAnalyzer {
fn analyze(&self, _root: &Path) -> Result<CodeGraph, DomainError> {
Ok(self.graph.clone())
}
}

View File

@@ -0,0 +1,125 @@
mod fakes;
use std::path::Path;
use archlens_application::queries::AnalyzeCodebase;
use archlens_application::use_cases::generate_diagram::GenerateDiagram;
use archlens_domain::{
AnalysisConfig, AnalysisResult, BoundaryRule, CodeElement, CodeElementKind, CodeGraph,
FilePath, Language, ModuleName, NormalizedGraph, Relationship, RelationshipKind, SourceFile,
};
use fakes::{FakeDiagramRenderer, FakeFileDiscovery, FakeSourceAnalyzer};
fn empty_graph() -> NormalizedGraph {
NormalizedGraph::from_project(CodeGraph::new())
}
fn graph_with_one_module() -> NormalizedGraph {
let files = vec![SourceFile::new(
FilePath::new("/p/src/orders/order.rs").unwrap(),
Language::Rust,
)];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new().with_result(
"/p/src/orders/order.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("/p/src/orders/order.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
);
AnalyzeCodebase::new(discovery, analyzer)
.execute(Path::new("/p"), &AnalysisConfig::default())
.unwrap()
.graph()
.clone()
}
fn graph_with_violation() -> NormalizedGraph {
let mut cg = CodeGraph::new();
let a = CodeElement::new(
"A",
CodeElementKind::Struct,
FilePath::new("a.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Alpha").unwrap());
let b = CodeElement::new(
"B",
CodeElementKind::Struct,
FilePath::new("b.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Beta").unwrap());
cg.add_element(a);
cg.add_element(b);
cg.add_relationship(Relationship::new("A", "B", RelationshipKind::Composition).unwrap());
NormalizedGraph::from_project(cg)
}
#[test]
fn execute_returns_render_output_without_writing_files() {
let use_case = GenerateDiagram {
graph: empty_graph(),
renderer: Box::new(FakeDiagramRenderer::new()),
allow_rules: vec![],
deny_rules: vec![],
split_by_module: false,
};
let result = use_case.execute().unwrap();
assert!(!result.output.files().is_empty());
}
#[test]
fn execute_returns_empty_violations_when_no_rules_set() {
let use_case = GenerateDiagram {
graph: empty_graph(),
renderer: Box::new(FakeDiagramRenderer::new()),
allow_rules: vec![],
deny_rules: vec![],
split_by_module: false,
};
let result = use_case.execute().unwrap();
assert!(result.violations.is_empty());
}
#[test]
fn execute_returns_violations_as_rule_violation_type() {
let deny = vec![BoundaryRule::parse("Alpha --> Beta").unwrap()];
let use_case = GenerateDiagram {
graph: graph_with_violation(),
renderer: Box::new(FakeDiagramRenderer::new()),
allow_rules: vec![],
deny_rules: deny,
split_by_module: false,
};
let result = use_case.execute().unwrap();
assert_eq!(result.violations.len(), 1);
assert!(result.violations[0].message().contains("Alpha"));
assert!(result.violations[0].message().contains("Beta"));
}
#[test]
fn split_by_module_returns_multiple_rendered_files() {
let use_case = GenerateDiagram {
graph: graph_with_one_module(),
renderer: Box::new(FakeDiagramRenderer::new()),
allow_rules: vec![],
deny_rules: vec![],
split_by_module: true,
};
let result = use_case.execute().unwrap();
// overview + at least one module file
assert!(result.output.files().len() >= 2);
}

View File

@@ -2,17 +2,15 @@ mod cli;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use archlens_application::queries::AnalyzeCodebase;
use archlens_application::use_cases::{ use archlens_application::use_cases::{
check_freshness::CheckFreshness, build_code_graph::BuildCodeGraph, check_freshness::CheckFreshness, diff_diagram::DiffDiagram,
diff_diagram::DiffDiagram, generate_diagram::GenerateDiagram,
generate_diagram::{GenerateDiagram, write_split},
}; };
use archlens_ascii::AsciiRenderer; use archlens_ascii::AsciiRenderer;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer; use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_d2::D2Renderer; use archlens_d2::D2Renderer;
use archlens_domain::{ use archlens_domain::{
BoundaryRule, DiagramLevel, NormalizedGraph, BoundaryRule, DiagramLevel, NormalizedGraph, RenderOutput,
ports::{ConfigLoader, ProjectAnalyzer}, ports::{ConfigLoader, ProjectAnalyzer},
}; };
use archlens_html::HtmlRenderer; use archlens_html::HtmlRenderer;
@@ -46,10 +44,12 @@ pub fn run(args: Cli) -> Result<()> {
let existing_path = args.output.as_ref().ok_or_else(|| { let existing_path = args.output.as_ref().ok_or_else(|| {
anyhow::anyhow!("--check requires --output to specify the file to check against") 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 { let up_to_date = CheckFreshness {
graph: &graph, graph: &graph,
renderer: &*renderer, renderer: &*renderer,
existing_path: std::path::Path::new(existing_path), existing_content: &existing_content,
} }
.execute()?; .execute()?;
if up_to_date { if up_to_date {
@@ -70,7 +70,6 @@ pub fn run(args: Cli) -> Result<()> {
.iter() .iter()
.filter_map(|s| BoundaryRule::parse(s)) .filter_map(|s| BoundaryRule::parse(s))
.collect(); .collect();
let output_dir = args.output.as_ref().map(std::path::PathBuf::from);
let use_case = GenerateDiagram { let use_case = GenerateDiagram {
graph, graph,
@@ -78,22 +77,48 @@ pub fn run(args: Cli) -> Result<()> {
allow_rules: allow, allow_rules: allow,
deny_rules: deny, deny_rules: deny,
split_by_module: args.split_by_module, split_by_module: args.split_by_module,
format_ext: format_extension(&args.format).to_string(),
output_dir,
}; };
let violations = use_case.check_violations_only(); let result = use_case.execute()?;
if args.strict && !violations.is_empty() { if args.strict && !result.violations.is_empty() {
bail!( bail!(
"{} boundary rule violation(s) in strict mode", "{} boundary rule violation(s) in strict mode",
violations.len() result.violations.len()
); );
} }
use_case.execute()?; for v in &result.violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
write_diagram_output(&result.output, args.output.as_deref(), args.split_by_module)?;
Ok(()) Ok(())
} }
fn write_diagram_output(
output: &RenderOutput,
output_path: Option<&str>,
split: bool,
) -> Result<()> {
if split {
let dir = std::path::PathBuf::from(output_path.unwrap_or("."));
std::fs::create_dir_all(&dir)?;
for file in output.files() {
std::fs::write(dir.join(file.name()), file.content())?;
}
} else if let Some(path) = output_path {
let p = std::path::Path::new(path);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
let content = output.files().first().map(|f| f.content()).unwrap_or("");
std::fs::write(p, content)?;
} else {
let content = output.files().first().map(|f| f.content()).unwrap_or("");
print!("{content}");
}
Ok(())
}
fn load_config(args: &Cli) -> Result<TomlConfigLoader> { fn load_config(args: &Cli) -> Result<TomlConfigLoader> {
match &args.config { match &args.config {
Some(path) => Ok(TomlConfigLoader::from_path(std::path::Path::new(path))?), Some(path) => Ok(TomlConfigLoader::from_path(std::path::Path::new(path))?),
@@ -129,23 +154,28 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
analysis_config = analysis_config.with_changed_files(changed); analysis_config = analysis_config.with_changed_files(changed);
} }
if level == DiagramLevel::Project { let project_analyzer: Option<Box<dyn ProjectAnalyzer>> = {
let cargo_toml = args.path.join("Cargo.toml"); let cargo_toml = args.path.join("Cargo.toml");
let project_graph = if cargo_toml.exists() { let pyproject = args.path.join("pyproject.toml");
CargoWorkspaceAnalyzer::new().analyze(&args.path)? if cargo_toml.exists() {
Some(Box::new(CargoWorkspaceAnalyzer::new()))
} else if pyproject.exists() {
Some(Box::new(PythonProjectAnalyzer::new()))
} else { } else {
PythonProjectAnalyzer::new().analyze(&args.path)? None
}; }
return Ok(NormalizedGraph::from_project(project_graph)); };
}
let discovery = WalkdirDiscovery::new(); let use_case = BuildCodeGraph {
let analyzer = TreeSitterAnalyzer::new(); discovery: WalkdirDiscovery::new(),
let analyze = AnalyzeCodebase::new(discovery, analyzer); source_analyzer: TreeSitterAnalyzer::new(),
let result = analyze.execute(&args.path, &analysis_config)?; project_analyzer,
};
if !result.warnings().is_empty() { let result = use_case.execute(&args.path, &analysis_config, level)?;
for warning in result.warnings() {
if !result.warnings.is_empty() {
for warning in &result.warnings {
eprintln!( eprintln!(
"WARNING: {}:{} {}", "WARNING: {}:{} {}",
warning.file_path().as_str(), warning.file_path().as_str(),
@@ -156,26 +186,12 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
if args.strict { if args.strict {
bail!( bail!(
"analysis produced {} warning(s) in strict mode", "analysis produced {} warning(s) in strict mode",
result.warnings().len() result.warnings.len()
); );
} }
} }
let mut graph = result.graph().clone(); Ok(result.graph)
if level == DiagramLevel::Module {
let workspace_toml = args.path.join("Cargo.toml");
let project_graph = if workspace_toml.exists() {
CargoWorkspaceAnalyzer::new().analyze(&args.path).ok()
} else {
PythonProjectAnalyzer::new().analyze(&args.path).ok()
};
if let Some(pg) = project_graph {
graph.merge_project_edges(&pg);
}
}
Ok(graph)
} }
fn create_renderer( fn create_renderer(
@@ -194,15 +210,6 @@ fn create_renderer(
} }
} }
fn format_extension(format: &str) -> &str {
match format {
"mermaid" => "mmd",
"d2" => "d2",
"html" => "html",
_ => "txt",
}
}
fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> { fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
init_tracing(args.verbose); init_tracing(args.verbose);
@@ -210,10 +217,12 @@ fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
let graph = build_graph(args, level)?; let graph = build_graph(args, level)?;
let renderer = create_renderer(&args.format, level, !args.no_weights)?; 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 { let diff = DiffDiagram {
graph: &graph, graph: &graph,
renderer: &*renderer, renderer: &*renderer,
existing_path, existing_content: &existing_content,
} }
.execute()?; .execute()?;
@@ -332,25 +341,12 @@ fn run_watch(args: Cli) -> Result<()> {
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
let level = parse_level(&args.level); let level = parse_level(&args.level);
let ext = format_extension(&args.format);
let debounce = Duration::from_millis(500); let debounce = Duration::from_millis(500);
let run_once = |args: &Cli| -> Result<()> { let run_once = |args: &Cli| -> Result<()> {
let config_loader = load_config(args)?; let config_loader = load_config(args)?;
let graph = build_graph(args, level)?; let graph = build_graph(args, level)?;
let renderer = create_renderer(&args.format, level, !args.no_weights)?; 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, &output_dir, ext)?;
} else {
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 (raw_allow, raw_deny) = config_loader.load_rules();
let allow: Vec<BoundaryRule> = raw_allow let allow: Vec<BoundaryRule> = raw_allow
@@ -361,19 +357,18 @@ fn run_watch(args: Cli) -> Result<()> {
.iter() .iter()
.filter_map(|s| BoundaryRule::parse(s)) .filter_map(|s| BoundaryRule::parse(s))
.collect(); .collect();
if !allow.is_empty() || !deny.is_empty() {
let use_case = GenerateDiagram { let use_case = GenerateDiagram {
graph, graph,
renderer, renderer,
allow_rules: allow, allow_rules: allow,
deny_rules: deny, deny_rules: deny,
split_by_module: false, split_by_module: args.split_by_module,
format_ext: ext.to_string(), };
output_dir: None, let result = use_case.execute()?;
}; write_diagram_output(&result.output, args.output.as_deref(), args.split_by_module)?;
for v in use_case.check_violations_only() { for v in &result.violations {
eprintln!("RULE VIOLATION: {v}"); eprintln!("RULE VIOLATION: {}", v.message());
}
} }
Ok(()) Ok(())
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
# Deepening Application and Adapter Interfaces
**Date:** 2026-06-17
**Status:** Approved
**Scope:** Four architectural deepening refactors across the application and adapter layers
---
## Background
Four architectural friction points identified via architecture review:
1. I/O leaks across the seam into the application layer (`GenerateDiagram`, `CheckFreshness`, `DiffDiagram`)
2. Orchestration logic (`build_graph`) lives in the presentation layer instead of application
3. Renderer adapters duplicate import filtering and identifier sanitization
4. `LanguageExtractor` trait's single `analyze()` method gives no structure to implementors
All four refactors deepen existing modules — shrinking their interfaces and concentrating behavior — without adding new capabilities.
---
## Candidate 1: Pull I/O out of the application layer
**Files:** `crates/application/src/use_cases/generate_diagram.rs`, `check_freshness.rs`, `diff_diagram.rs`
### GenerateDiagram
Remove `output_dir: Option<PathBuf>` and `format_ext: String` fields. Remove `write_split`, `write_file_to_dir` free functions, and `check_violations_only()` method.
`execute()` returns `Result<GenerateDiagramResult, DomainError>`:
```rust
pub struct GenerateDiagramResult {
pub violations: Vec<RuleViolation>, // domain type, not pre-formatted strings
pub output: RenderOutput, // caller writes; split mode produces N files
}
```
Split-by-module rendering stays inside `execute()`: it builds a `RenderOutput` with an overview file plus one file per module. Presentation receives the `RenderOutput` and hands it to `OutputWriter`.
`check_violations_only()` is removed. Callers needing early-exit on violations (strict mode, watch mode) call `execute()` and inspect `result.violations`.
### CheckFreshness
```rust
// before
pub existing_path: &'a std::path::Path,
// after
pub existing_content: &'a str,
```
`execute()` becomes a pure string comparison. Presentation reads the file with `std::fs::read_to_string()` before constructing the struct.
### DiffDiagram
Same pattern: `existing_path: &Path``existing_content: &str`. Presentation reads the file.
### Presentation impact
- Reads files before calling `CheckFreshness` / `DiffDiagram`
- After `GenerateDiagram::execute()`: inspects `result.violations` for strict-mode bail, prints violations, calls `output_writer.write(&result.output)`
- `write_split` and `write_file_to_dir` free functions move into presentation (or are inlined)
---
## Candidate 2: Sink graph-building orchestration into the application layer
**Files:** `crates/application/src/use_cases/build_code_graph.rs` (new), `crates/presentation/src/lib.rs`
### New use case: BuildCodeGraph
```rust
pub struct BuildCodeGraph<F, S> {
pub discovery: F,
pub source_analyzer: S,
pub project_analyzer: Option<Box<dyn ProjectAnalyzer>>,
}
pub struct BuildCodeGraphResult {
pub graph: NormalizedGraph,
pub warnings: Vec<AnalysisWarning>,
}
impl<F: FileDiscovery, S: SourceAnalyzer> BuildCodeGraph<F, S> {
pub fn execute(
self,
root: &Path,
config: &AnalysisConfig,
level: DiagramLevel,
) -> Result<BuildCodeGraphResult, DomainError>
}
```
Logic inside `execute()`:
- `DiagramLevel::Project` → call `project_analyzer.expect("project analyzer required for Project level").analyze(root)`, return via `NormalizedGraph::from_project()`, empty warnings
- `DiagramLevel::Module | Type` → call `AnalyzeCodebase::execute()`, collect warnings; at `Module` level, if `project_analyzer` is `Some`, merge its edges into the graph
### Presentation impact
`build_graph()` in `presentation/src/lib.rs` drops from ~70 lines to ~25:
1. Apply CLI overrides to `AnalysisConfig` (scope, excludes, include_tests, changed_files) — stays in presentation, CLI-derived
2. Detect project analyzer: `if cargo_toml.exists() { Some(CargoWorkspaceAnalyzer) } else if pyproject.exists() { Some(PythonProjectAnalyzer) } else { None }`
3. Construct `BuildCodeGraph` and call `execute(root, config, level)`
4. Handle warnings (`eprintln`, strict bail) — stays in presentation
---
## Candidate 3: Shared renderer primitives
**Files:** `crates/adapters/rendering-primitives/src/lib.rs` (new crate), all four renderer adapter crates
### New crate: rendering-primitives
Depends only on `archlens_domain`. Exports two functions:
```rust
/// Returns all relationships excluding Import kind.
pub fn non_import_rels(rels: &[Relationship]) -> impl Iterator<Item = &Relationship> {
rels.iter().filter(|r| r.kind() != RelationshipKind::Import)
}
/// Sanitize an identifier by replacing common separator characters with underscore.
/// Handles `::`, `-`, `.`, and space.
pub fn sanitize_identifier(name: &str) -> String {
name.replace("::", "_").replace(['-', '.', ' '], "_")
}
```
Each of the four renderer adapter crates (`mermaid`, `d2`, `ascii`, `html-viewer`) adds `rendering-primitives` as a dependency.
Mermaid's `sanitize_id` strips only `[-.]` (not `::`) — intentionally different because Mermaid handles qualified names via `display_name()`. It keeps its local variant; the shared `sanitize_identifier` is used by D2 and HTML. The universal win is `non_import_rels`, which replaces the identical filter expression in all four renderers.
---
## Candidate 4: Typed LanguageExtractor pipeline
**Files:** `crates/adapters/tree-sitter/src/language_extractor.rs`, `rust/mod.rs`, `python/mod.rs`, `tree_sitter_analyzer.rs`
### Revised LanguageExtractor trait
Replace the current single-method trait with a 3-method trait plus a `run_extraction` free function:
```rust
pub trait LanguageExtractor {
fn tree_sitter_language(&self) -> tree_sitter::Language;
fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
}
pub fn run_extraction(
extractor: &dyn LanguageExtractor,
source: &str,
file_path: &FilePath,
) -> Result<AnalysisResult, DomainError>
```
`run_extraction` owns: create parser, set language, parse, call all 3 methods in order on the root node, return `ctx.into_result()`.
### Extractor changes
`RustExtractor` and `PythonExtractor` implement the new trait. Existing free functions are reorganised into the 3 methods — no logic changes:
- `extract_types` → wraps `collect_types`
- `extract_relationships` → wraps `collect_relationships`
- `extract_imports` → wraps `collect_mod_declarations` + `collect_use_imports`
`TreeSitterAnalyzer::analyze_file()` replaces `extractor.analyze(source, path)` with `run_extraction(extractor, source, path)`.
Adding CSharp = implement 4 methods. No guesswork about what's required.
---
## Execution order
1. Candidate 1 — cleanest standalone win; establishes the pattern (pure use cases return data)
2. Candidate 2 — independent of Candidate 1 but benefits from the pattern being in place; `BuildCodeGraph` and `GenerateDiagram` are separate use cases with no shared code
3. Candidates 3 and 4 — independent of 1+2 and of each other; can run in parallel
`FileOutputWriter` (Directory variant) already iterates over all `RenderedFile`s in a `RenderOutput`, so split-by-module output producing N files in one `RenderOutput` requires no changes to the writer.
## TDD behaviors to verify (by candidate)
### Candidate 1
- `GenerateDiagram::execute()` returns `RenderOutput` with rendered content (no filesystem side effects)
- `GenerateDiagram::execute()` returns `Vec<RuleViolation>` when rules are violated
- `GenerateDiagram::execute()` with `split_by_module=true` returns `RenderOutput` with multiple files (overview + per-module)
- `CheckFreshness::execute()` returns `true` when rendered content equals `existing_content`
- `CheckFreshness::execute()` returns `false` when they differ
- `DiffDiagram::execute()` returns correct added/removed lines
### Candidate 2
- `BuildCodeGraph::execute()` at `Project` level delegates to `ProjectAnalyzer`, returns its graph
- `BuildCodeGraph::execute()` at `Module` level merges project edges when `project_analyzer` is `Some`
- `BuildCodeGraph::execute()` at `Type` level does not merge edges even when `project_analyzer` is `Some`
- `BuildCodeGraph::execute()` with `None` project analyzer at `Module` level skips merge cleanly
- `BuildCodeGraph::execute()` propagates `AnalysisWarning`s from `AnalyzeCodebase`
### Candidate 3
- `non_import_rels` excludes `Import` relationships, passes through `Inheritance` and `Composition`
- `sanitize_identifier` replaces `::`, `-`, `.`, space with `_`
### Candidate 4
- `run_extraction` calls `extract_types`, `extract_relationships`, `extract_imports` in order
- `RustExtractor` extracts struct/enum/trait types via `extract_types`
- `PythonExtractor` extracts classes via `extract_types`