Compare commits

...

10 Commits

35 changed files with 2697 additions and 436 deletions

11
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ use archlens_domain::{
CodeElement, CodeGraph, DiagramLevel, DomainError, ModuleName, RelationshipKind, RenderOutput,
RenderedFile, Visibility, ports::DiagramRenderer,
};
use archlens_rendering_primitives::non_import_rels;
pub struct MermaidRenderer {
level: DiagramLevel,
@@ -94,14 +95,11 @@ impl MermaidRenderer {
lines.extend(deferred_members);
let mut rel_seen: HashSet<String> = HashSet::new();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
for rel in non_import_rels(graph.relationships()) {
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "<|--",
RelationshipKind::Composition => "-->",
RelationshipKind::Import => "..>",
RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
};
let src = Self::display_name(rel.source());
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 crate::extraction_context::ExtractionContext;
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::{
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
RelationshipKind,
};
use archlens_domain::{CodeElement, CodeElementKind, Relationship, RelationshipKind};
use crate::extraction_context::ExtractionContext;
use crate::language_extractor::LanguageExtractor;
@@ -11,28 +8,23 @@ use crate::language_extractor::LanguageExtractor;
pub struct PythonExtractor;
impl LanguageExtractor for PythonExtractor {
fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
analyze(source, file_path)
fn tree_sitter_language(&self) -> tree_sitter::Language {
tree_sitter_python::LANGUAGE.into()
}
}
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
// collect_classes handles class elements, inheritance, and field compositions
// in a single pass — Python's relationship extraction is interleaved with type extraction
collect_classes(root, source, ctx);
}
let tree = parser
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
fn extract_relationships(&self, _root: &Node, _source: &str, _ctx: &mut ExtractionContext) {
// Relationships are collected inside collect_classes for Python
}
let mut ctx = ExtractionContext::new(file_path.clone());
let root = tree.root_node();
collect_classes(&root, source, &mut ctx);
collect_imports(&root, source, &mut ctx);
ctx.into_result()
fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
collect_imports(root, source, ctx);
}
}
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] = &[
"bool",
@@ -35,10 +35,7 @@ const RUST_PRIMITIVES: &[&str] = &[
"Self",
];
use archlens_domain::{
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
RelationshipKind, Visibility,
};
use archlens_domain::{CodeElement, CodeElementKind, Relationship, RelationshipKind, Visibility};
use crate::extraction_context::ExtractionContext;
use crate::language_extractor::LanguageExtractor;
@@ -46,30 +43,22 @@ use crate::language_extractor::LanguageExtractor;
pub struct RustExtractor;
impl LanguageExtractor for RustExtractor {
fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
analyze(source, file_path)
fn tree_sitter_language(&self) -> tree_sitter::Language {
tree_sitter_rust::LANGUAGE.into()
}
}
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_rust::LANGUAGE.into())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
collect_types(root, source, ctx);
}
let tree = parser
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
collect_relationships(root, source, ctx);
}
let mut ctx = ExtractionContext::new(file_path.clone());
let root = tree.root_node();
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 extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
collect_mod_declarations(root, source, ctx);
collect_use_imports(root, source, ctx);
}
}
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 crate::language_extractor::LanguageExtractor;
use crate::language_extractor::{LanguageExtractor, run_extraction};
use crate::python::PythonExtractor;
use crate::rust::RustExtractor;
@@ -38,7 +38,7 @@ impl SourceAnalyzer for TreeSitterAnalyzer {
.map_err(|e| DomainError::IoError(e.to_string()))?;
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()),
}
}

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};
/// 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.
pub struct CheckFreshness<'a> {
pub graph: &'a NormalizedGraph,
pub renderer: &'a dyn DiagramRenderer,
pub existing_path: &'a std::path::Path,
pub existing_content: &'a str,
}
impl<'a> CheckFreshness<'a> {
pub fn execute(&self) -> Result<bool, DomainError> {
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)
Ok(current == self.existing_content)
}
}

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.
pub struct DiffDiagram<'a> {
pub graph: &'a NormalizedGraph,
pub renderer: &'a dyn DiagramRenderer,
pub existing_path: &'a std::path::Path,
pub existing_content: &'a str,
}
impl<'a> DiffDiagram<'a> {
pub fn execute(&self) -> Result<DiffResult, DomainError> {
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 existing_lines: std::collections::HashSet<&str> =
self.existing_content.lines().collect();
let added: Vec<String> = current_lines
.difference(&existing_lines)

View File

@@ -1,122 +1,67 @@
use std::path::PathBuf;
use archlens_domain::{
BoundaryRule, DomainError, NormalizedGraph, RenderOutput, RenderedFile, check_boundary_rules,
ports::DiagramRenderer,
BoundaryRule, DomainError, NormalizedGraph, RenderOutput, RenderedFile, RuleViolation,
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<String>,
pub violations: Vec<RuleViolation>,
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<dyn DiagramRenderer>,
pub allow_rules: Vec<BoundaryRule>,
pub deny_rules: Vec<BoundaryRule>,
pub split_by_module: bool,
pub format_ext: String,
pub output_dir: Option<PathBuf>,
}
impl GenerateDiagram {
pub fn execute(self) -> Result<(), DomainError> {
// Boundary rule checking
pub fn execute(self) -> Result<GenerateDiagramResult, DomainError> {
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,
)?;
let output = if self.split_by_module {
render_split(&self.graph, &*self.renderer)?
} else {
let rendered = self.renderer.render(self.graph.as_graph())?;
write_to_output(rendered, &self.output_dir)?;
}
self.renderer.render(self.graph.as_graph())?
};
// 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<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()
Ok(GenerateDiagramResult { violations, output })
}
}
pub fn write_split(
fn render_split(
graph: &NormalizedGraph,
renderer: &dyn DiagramRenderer,
output_dir: &Option<PathBuf>,
ext: &str,
) -> Result<(), DomainError> {
let dir = output_dir.clone().unwrap_or_else(|| PathBuf::from("."));
) -> Result<RenderOutput, DomainError> {
let mut files = Vec::new();
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)?;
let ext = overview
.files()
.first()
.and_then(|f| f.name().rsplit_once('.').map(|(_, e)| e))
.unwrap_or("txt");
if let Some(f) = overview.files().first() {
files.push(RenderedFile::new(&format!("overview.{ext}"), f.content())?);
}
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(
if let Some(f) = module_output.files().first() {
files.push(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)?;
f.content(),
)?);
}
}
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 diff_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 file_discovery;
mod output_writer;
mod project_analyzer;
mod source_analyzer;
pub use diagram_renderer::FakeDiagramRenderer;
pub use file_discovery::FakeFileDiscovery;
pub use output_writer::FakeOutputWriter;
pub use project_analyzer::FakeProjectAnalyzer;
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 archlens_application::queries::AnalyzeCodebase;
use archlens_application::use_cases::{
check_freshness::CheckFreshness,
diff_diagram::DiffDiagram,
generate_diagram::{GenerateDiagram, write_split},
build_code_graph::BuildCodeGraph, check_freshness::CheckFreshness, diff_diagram::DiffDiagram,
generate_diagram::GenerateDiagram,
};
use archlens_ascii::AsciiRenderer;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_d2::D2Renderer;
use archlens_domain::{
BoundaryRule, DiagramLevel, NormalizedGraph,
BoundaryRule, DiagramLevel, NormalizedGraph, RenderOutput,
ports::{ConfigLoader, ProjectAnalyzer},
};
use archlens_html::HtmlRenderer;
@@ -46,10 +44,12 @@ pub fn run(args: Cli) -> Result<()> {
let existing_path = args.output.as_ref().ok_or_else(|| {
anyhow::anyhow!("--check requires --output to specify the file to check against")
})?;
let existing_content = std::fs::read_to_string(existing_path)
.map_err(|e| anyhow::anyhow!("cannot read {existing_path}: {e}"))?;
let up_to_date = CheckFreshness {
graph: &graph,
renderer: &*renderer,
existing_path: std::path::Path::new(existing_path),
existing_content: &existing_content,
}
.execute()?;
if up_to_date {
@@ -70,7 +70,6 @@ pub fn run(args: Cli) -> Result<()> {
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
let output_dir = args.output.as_ref().map(std::path::PathBuf::from);
let use_case = GenerateDiagram {
graph,
@@ -78,22 +77,48 @@ pub fn run(args: Cli) -> Result<()> {
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() {
let result = use_case.execute()?;
if args.strict && !result.violations.is_empty() {
bail!(
"{} 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(())
}
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> {
match &args.config {
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);
}
if level == DiagramLevel::Project {
let project_analyzer: Option<Box<dyn ProjectAnalyzer>> = {
let cargo_toml = args.path.join("Cargo.toml");
let project_graph = if cargo_toml.exists() {
CargoWorkspaceAnalyzer::new().analyze(&args.path)?
let pyproject = args.path.join("pyproject.toml");
if cargo_toml.exists() {
Some(Box::new(CargoWorkspaceAnalyzer::new()))
} else if pyproject.exists() {
Some(Box::new(PythonProjectAnalyzer::new()))
} else {
PythonProjectAnalyzer::new().analyze(&args.path)?
};
return Ok(NormalizedGraph::from_project(project_graph));
None
}
};
let discovery = WalkdirDiscovery::new();
let analyzer = TreeSitterAnalyzer::new();
let analyze = AnalyzeCodebase::new(discovery, analyzer);
let result = analyze.execute(&args.path, &analysis_config)?;
let use_case = BuildCodeGraph {
discovery: WalkdirDiscovery::new(),
source_analyzer: TreeSitterAnalyzer::new(),
project_analyzer,
};
if !result.warnings().is_empty() {
for warning in result.warnings() {
let result = use_case.execute(&args.path, &analysis_config, level)?;
if !result.warnings.is_empty() {
for warning in &result.warnings {
eprintln!(
"WARNING: {}:{} {}",
warning.file_path().as_str(),
@@ -156,26 +186,12 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
if args.strict {
bail!(
"analysis produced {} warning(s) in strict mode",
result.warnings().len()
result.warnings.len()
);
}
}
let mut graph = result.graph().clone();
if level == DiagramLevel::Module {
let workspace_toml = args.path.join("Cargo.toml");
let project_graph = if workspace_toml.exists() {
CargoWorkspaceAnalyzer::new().analyze(&args.path).ok()
} else {
PythonProjectAnalyzer::new().analyze(&args.path).ok()
};
if let Some(pg) = project_graph {
graph.merge_project_edges(&pg);
}
}
Ok(graph)
Ok(result.graph)
}
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<()> {
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 renderer = create_renderer(&args.format, level, !args.no_weights)?;
let existing_content = std::fs::read_to_string(existing_path)
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", existing_path.display()))?;
let diff = DiffDiagram {
graph: &graph,
renderer: &*renderer,
existing_path,
existing_content: &existing_content,
}
.execute()?;
@@ -332,25 +341,12 @@ fn run_watch(args: Cli) -> Result<()> {
use std::time::{Duration, Instant};
let level = parse_level(&args.level);
let ext = format_extension(&args.format);
let debounce = Duration::from_millis(500);
let run_once = |args: &Cli| -> Result<()> {
let config_loader = load_config(args)?;
let graph = build_graph(args, level)?;
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
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 allow: Vec<BoundaryRule> = raw_allow
@@ -361,19 +357,18 @@ fn run_watch(args: Cli) -> Result<()> {
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
if !allow.is_empty() || !deny.is_empty() {
let use_case = GenerateDiagram {
graph,
renderer,
allow_rules: allow,
deny_rules: deny,
split_by_module: false,
format_ext: ext.to_string(),
output_dir: None,
split_by_module: args.split_by_module,
};
for v in use_case.check_violations_only() {
eprintln!("RULE VIOLATION: {v}");
}
let result = use_case.execute()?;
write_diagram_output(&result.output, args.output.as_deref(), args.split_by_module)?;
for v in &result.violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
Ok(())
};

View File

@@ -3,8 +3,8 @@ graph TD
Presentation[Presentation]
Adapters[Adapters]
Application[Application]
Presentation -->|1 dep| Application
Adapters -->|25 deps| Domain
Presentation -->|1 dep| Domain
Adapters -->|26 deps| Domain
Presentation -->|11 deps| Adapters
Application -->|4 deps| Domain
Application -->|5 deps| Domain
Presentation -->|1 dep| Application

View File

@@ -14,29 +14,35 @@ graph TD
archlens_python_project[archlens-python-project]
archlens_d2[archlens-d2]
archlens_html[archlens-html]
archlens_rendering_primitives[archlens-rendering-primitives]
end
archlens_application --> archlens_domain
archlens --> archlens_application
archlens --> archlens_mermaid
archlens --> archlens_stdout_writer
archlens --> archlens_ascii
archlens --> archlens_domain
archlens --> archlens_file_writer
archlens --> archlens_python_project
archlens --> archlens_walkdir
archlens --> archlens_toml_config
archlens --> archlens_html
archlens --> archlens_cargo_workspace
archlens --> archlens_d2
archlens --> archlens_tree_sitter
archlens --> archlens_walkdir
archlens --> archlens_application
archlens --> archlens_stdout_writer
archlens --> archlens_html
archlens --> archlens_file_writer
archlens --> archlens_mermaid
archlens --> archlens_toml_config
archlens --> archlens_cargo_workspace
archlens --> archlens_python_project
archlens --> archlens_ascii
archlens --> archlens_d2
archlens --> archlens_domain
archlens_tree_sitter --> archlens_domain
archlens_walkdir --> archlens_domain
archlens_mermaid --> archlens_domain
archlens_mermaid --> archlens_rendering_primitives
archlens_ascii --> archlens_rendering_primitives
archlens_ascii --> archlens_domain
archlens_file_writer --> archlens_domain
archlens_stdout_writer --> archlens_domain
archlens_toml_config --> archlens_domain
archlens_cargo_workspace --> archlens_domain
archlens_python_project --> archlens_domain
archlens_d2 --> archlens_rendering_primitives
archlens_d2 --> archlens_domain
archlens_html --> archlens_rendering_primitives
archlens_html --> archlens_domain
archlens_rendering_primitives --> archlens_domain

View File

@@ -2,6 +2,8 @@ classDiagram
namespace Application {
class AnalyzeCodebase
class AnalyzeCodebaseResult
class BuildCodeGraphResult
class BuildCodeGraph
class DiffResult
class DiffDiagram
class CheckFreshness
@@ -16,16 +18,22 @@ classDiagram
AnalyzeCodebaseResult : warnings Vec
AnalyzeCodebaseResult : +graph() -] NormalizedGraph
AnalyzeCodebaseResult : +warnings() -] [AnalysisWarning]
BuildCodeGraphResult : graph NormalizedGraph
BuildCodeGraphResult : warnings Vec
BuildCodeGraph : discovery F
BuildCodeGraph : source_analyzer S
BuildCodeGraph : project_analyzer Option
BuildCodeGraph : +execute(root Path, config AnalysisConfig, level DiagramLevel) -] Result[BuildCodeGraphResult, DomainError]
DiffResult : added Vec
DiffResult : removed Vec
DiffResult : +is_empty() -] bool
DiffDiagram : graph a NormalizedGraph
DiffDiagram : renderer a dyn DiagramRenderer
DiffDiagram : existing_path a std path Path
DiffDiagram : existing_content a str
DiffDiagram : +execute() -] Result[DiffResult, DomainError]
CheckFreshness : graph a NormalizedGraph
CheckFreshness : renderer a dyn DiagramRenderer
CheckFreshness : existing_path a std path Path
CheckFreshness : existing_content a str
CheckFreshness : +execute() -] Result[bool, DomainError]
GenerateDiagramResult : violations Vec
GenerateDiagramResult : output RenderOutput
@@ -34,14 +42,11 @@ classDiagram
GenerateDiagram : allow_rules Vec
GenerateDiagram : deny_rules Vec
GenerateDiagram : split_by_module bool
GenerateDiagram : format_ext String
GenerateDiagram : output_dir Option
GenerateDiagram : +execute() -] Result[(), DomainError]
GenerateDiagram : +check_violations_only() -] Vec[String]
GenerateDiagram : +execute() -] Result[GenerateDiagramResult, DomainError]
class application_module["Application"] {
<<module>>
}
class domain_module["Domain"] {
<<module>>
}
application_module --> domain_module : 3 deps
application_module --> domain_module : 4 deps

View File

@@ -1,7 +1,34 @@
classDiagram
namespace Presentation {
class Cli
class Command
namespace Domain {
class OutputConfig
class DiagramLevel
class RenderOutput
class RenderedFile
class SourceFile
class ModuleName
class ModuleAssignment
class Language
class FilePath
class RelationshipKind
class Visibility
class CodeElementKind
class RuleKind
class RuleViolation
class BoundaryRule
class AnalysisConfig
class AnalysisResult
class AnalysisWarning
class Relationship
class CodeElement
class FileDiscovery
class ConfigLoader
class ProjectAnalyzer
class OutputWriter
class DiagramRenderer
class SourceAnalyzer
class NormalizedGraph
class CodeGraph
class DomainError
}
namespace Adapters {
class MermaidRenderer
@@ -36,158 +63,21 @@ classDiagram
class MemberToml
class PackageSection
}
namespace Domain {
class OutputConfig
class DiagramLevel
class RenderOutput
class RenderedFile
class SourceFile
class ModuleName
class ModuleAssignment
class Language
class FilePath
class RelationshipKind
class Visibility
class CodeElementKind
class RuleKind
class RuleViolation
class BoundaryRule
class AnalysisConfig
class AnalysisResult
class AnalysisWarning
class Relationship
class CodeElement
class FileDiscovery
class ConfigLoader
class ProjectAnalyzer
class OutputWriter
class DiagramRenderer
class SourceAnalyzer
class NormalizedGraph
class CodeGraph
class DomainError
namespace Presentation {
class Cli
class Command
}
namespace Application {
class AnalyzeCodebase
class AnalyzeCodebaseResult
class BuildCodeGraphResult
class BuildCodeGraph
class DiffResult
class DiffDiagram
class CheckFreshness
class GenerateDiagramResult
class GenerateDiagram
}
Cli : command Option
Cli : path PathBuf
Cli : level String
Cli : format String
Cli : output Option
Cli : config Option
Cli : scope Option
Cli : exclude Vec
Cli : include_tests bool
Cli : no_weights bool
Cli : watch bool
Cli : since Option
Cli : split_by_module bool
Cli : strict bool
Cli : check bool
Cli : verbose u8
MermaidRenderer : level DiagramLevel
MermaidRenderer : show_weights bool
MermaidRenderer : +new() -] Self
MermaidRenderer : +with_level(level DiagramLevel) -] Self
MermaidRenderer : +with_weights(show bool) -] Self
MermaidRenderer : -display_name(qualified str) -] str
MermaidRenderer : -format_element_name(element CodeElement) -] String
MermaidRenderer : -format_visibility(visibility Visibility) -] static str
MermaidRenderer : -render_class_diagram(graph CodeGraph) -] String
MermaidRenderer : -push_class_lines(lines mut Vec[String], deferred mut Vec[String], element CodeElement, indent str, in_namespace bool)
MermaidRenderer : -render_module_flowchart(graph CodeGraph) -] String
MermaidRenderer : -render_project_flowchart(graph CodeGraph) -] String
MermaidRenderer : -sanitize_id(name str) -] String
AsciiRenderer : +new() -] Self
AsciiRenderer : -format_kind(element CodeElement) -] static str
PythonProjectAnalyzer : +new() -] Self
<<private>> ProjectSection
ProjectSection : name Option
ProjectSection : dependencies Vec
<<private>> PoetrySection
PoetrySection : name Option
PoetrySection : dependencies HashMap
<<private>> ToolSection
ToolSection : poetry PoetrySection
<<private>> PyprojectToml
PyprojectToml : project Option
PyprojectToml : tool ToolSection
WalkdirDiscovery : +new() -] Self
WalkdirDiscovery : -detect_language(path Path) -] Option[Language]
WalkdirDiscovery : -is_excluded(path Path, root Path, excludes [String]) -] bool
ExtractionContext : elements Vec
ExtractionContext : relationships Vec
ExtractionContext : warnings Vec
ExtractionContext : local_types HashSet
ExtractionContext : file_path FilePath
ExtractionContext : +new(file_path FilePath) -] Self
ExtractionContext : +add_element(element CodeElement)
ExtractionContext : +add_relationship(rel Relationship)
ExtractionContext : +add_warning(file_path FilePath, line usize, message str)
ExtractionContext : +file_path() -] FilePath
ExtractionContext : +into_result() -] Result[AnalysisResult, DomainError]
TreeSitterAnalyzer : rust RustExtractor
TreeSitterAnalyzer : python PythonExtractor
TreeSitterAnalyzer : +new() -] Self
TreeSitterAnalyzer : -extractor_for(language Language) -] Option[dyn LanguageExtractor]
FileOutputWriter : output_path OutputPath
FileOutputWriter : +new(output_dir PathBuf) -] Self
FileOutputWriter : +single_file(path PathBuf) -] Self
<<private>> OutputPath
StdoutOutputWriter : +new() -] Self
<<private>> RawRules
RawRules : allow Vec
RawRules : deny Vec
<<private>> RawConfig
RawConfig : analysis RawAnalysis
RawConfig : output RawOutput
RawConfig : modules HashMap
RawConfig : rules RawRules
<<private>> RawAnalysis
RawAnalysis : exclude Vec
RawAnalysis : level Option
<<private>> RawOutput
RawOutput : format Option
RawOutput : path Option
RawOutput : split_by_module bool
TomlConfigLoader : raw RawConfig
TomlConfigLoader : +from_path(path Path) -] Result[Self, DomainError]
TomlConfigLoader : -parse_level(level Option[String]) -] DiagramLevel
D2Renderer : level DiagramLevel
D2Renderer : +new() -] Self
D2Renderer : +with_level(level DiagramLevel) -] Self
HtmlRenderer : +new() -] Self
<<private>> GraphData
GraphData : nodes Vec
GraphData : edges Vec
<<private>> NodeData
NodeData : id String
NodeData : label String
NodeData : module String
NodeData : kind String
NodeData : fields Vec
NodeData : methods Vec
<<private>> EdgeData
EdgeData : source String
EdgeData : target String
EdgeData : kind String
CargoWorkspaceAnalyzer : +new() -] Self
<<private>> WorkspaceToml
WorkspaceToml : workspace Option
<<private>> WorkspaceSection
WorkspaceSection : members Vec
<<private>> MemberToml
MemberToml : package Option
MemberToml : dependencies HashMap
<<private>> PackageSection
PackageSection : name String
OutputConfig : split_by_module bool
OutputConfig : output_path Option
OutputConfig : +with_split_by_module(split bool) -] Self
@@ -336,6 +226,118 @@ classDiagram
CodeGraph : +subgraph_by_module(module ModuleName) -] CodeGraph
CodeGraph : +merge_project_edges(project_graph CodeGraph)
CodeGraph : +module_edges() -] HashMap[(String, String), usize]
MermaidRenderer : level DiagramLevel
MermaidRenderer : show_weights bool
MermaidRenderer : +new() -] Self
MermaidRenderer : +with_level(level DiagramLevel) -] Self
MermaidRenderer : +with_weights(show bool) -] Self
MermaidRenderer : -display_name(qualified str) -] str
MermaidRenderer : -format_element_name(element CodeElement) -] String
MermaidRenderer : -format_visibility(visibility Visibility) -] static str
MermaidRenderer : -render_class_diagram(graph CodeGraph) -] String
MermaidRenderer : -push_class_lines(lines mut Vec[String], deferred mut Vec[String], element CodeElement, indent str, in_namespace bool)
MermaidRenderer : -render_module_flowchart(graph CodeGraph) -] String
MermaidRenderer : -render_project_flowchart(graph CodeGraph) -] String
MermaidRenderer : -sanitize_id(name str) -] String
AsciiRenderer : +new() -] Self
AsciiRenderer : -format_kind(element CodeElement) -] static str
PythonProjectAnalyzer : +new() -] Self
<<private>> ProjectSection
ProjectSection : name Option
ProjectSection : dependencies Vec
<<private>> PoetrySection
PoetrySection : name Option
PoetrySection : dependencies HashMap
<<private>> ToolSection
ToolSection : poetry PoetrySection
<<private>> PyprojectToml
PyprojectToml : project Option
PyprojectToml : tool ToolSection
WalkdirDiscovery : +new() -] Self
WalkdirDiscovery : -detect_language(path Path) -] Option[Language]
WalkdirDiscovery : -is_excluded(path Path, root Path, excludes [String]) -] bool
ExtractionContext : elements Vec
ExtractionContext : relationships Vec
ExtractionContext : warnings Vec
ExtractionContext : local_types HashSet
ExtractionContext : file_path FilePath
ExtractionContext : +new(file_path FilePath) -] Self
ExtractionContext : +add_element(element CodeElement)
ExtractionContext : +add_relationship(rel Relationship)
ExtractionContext : +add_warning(file_path FilePath, line usize, message str)
ExtractionContext : +file_path() -] FilePath
ExtractionContext : +into_result() -] Result[AnalysisResult, DomainError]
TreeSitterAnalyzer : rust RustExtractor
TreeSitterAnalyzer : python PythonExtractor
TreeSitterAnalyzer : +new() -] Self
TreeSitterAnalyzer : -extractor_for(language Language) -] Option[dyn LanguageExtractor]
FileOutputWriter : output_path OutputPath
FileOutputWriter : +new(output_dir PathBuf) -] Self
FileOutputWriter : +single_file(path PathBuf) -] Self
<<private>> OutputPath
StdoutOutputWriter : +new() -] Self
<<private>> RawRules
RawRules : allow Vec
RawRules : deny Vec
<<private>> RawConfig
RawConfig : analysis RawAnalysis
RawConfig : output RawOutput
RawConfig : modules HashMap
RawConfig : rules RawRules
<<private>> RawAnalysis
RawAnalysis : exclude Vec
RawAnalysis : level Option
<<private>> RawOutput
RawOutput : format Option
RawOutput : path Option
RawOutput : split_by_module bool
TomlConfigLoader : raw RawConfig
TomlConfigLoader : +from_path(path Path) -] Result[Self, DomainError]
TomlConfigLoader : -parse_level(level Option[String]) -] DiagramLevel
D2Renderer : level DiagramLevel
D2Renderer : +new() -] Self
D2Renderer : +with_level(level DiagramLevel) -] Self
HtmlRenderer : +new() -] Self
<<private>> GraphData
GraphData : nodes Vec
GraphData : edges Vec
<<private>> NodeData
NodeData : id String
NodeData : label String
NodeData : module String
NodeData : kind String
NodeData : fields Vec
NodeData : methods Vec
<<private>> EdgeData
EdgeData : source String
EdgeData : target String
EdgeData : kind String
CargoWorkspaceAnalyzer : +new() -] Self
<<private>> WorkspaceToml
WorkspaceToml : workspace Option
<<private>> WorkspaceSection
WorkspaceSection : members Vec
<<private>> MemberToml
MemberToml : package Option
MemberToml : dependencies HashMap
<<private>> PackageSection
PackageSection : name String
Cli : command Option
Cli : path PathBuf
Cli : level String
Cli : format String
Cli : output Option
Cli : config Option
Cli : scope Option
Cli : exclude Vec
Cli : include_tests bool
Cli : no_weights bool
Cli : watch bool
Cli : since Option
Cli : split_by_module bool
Cli : strict bool
Cli : check bool
Cli : verbose u8
AnalyzeCodebase : file_discovery F
AnalyzeCodebase : source_analyzer S
AnalyzeCodebase : +new(file_discovery F, source_analyzer S) -] Self
@@ -344,16 +346,22 @@ classDiagram
AnalyzeCodebaseResult : warnings Vec
AnalyzeCodebaseResult : +graph() -] NormalizedGraph
AnalyzeCodebaseResult : +warnings() -] [AnalysisWarning]
BuildCodeGraphResult : graph NormalizedGraph
BuildCodeGraphResult : warnings Vec
BuildCodeGraph : discovery F
BuildCodeGraph : source_analyzer S
BuildCodeGraph : project_analyzer Option
BuildCodeGraph : +execute(root Path, config AnalysisConfig, level DiagramLevel) -] Result[BuildCodeGraphResult, DomainError]
DiffResult : added Vec
DiffResult : removed Vec
DiffResult : +is_empty() -] bool
DiffDiagram : graph a NormalizedGraph
DiffDiagram : renderer a dyn DiagramRenderer
DiffDiagram : existing_path a std path Path
DiffDiagram : existing_content a str
DiffDiagram : +execute() -] Result[DiffResult, DomainError]
CheckFreshness : graph a NormalizedGraph
CheckFreshness : renderer a dyn DiagramRenderer
CheckFreshness : existing_path a std path Path
CheckFreshness : existing_content a str
CheckFreshness : +execute() -] Result[bool, DomainError]
GenerateDiagramResult : violations Vec
GenerateDiagramResult : output RenderOutput
@@ -362,10 +370,7 @@ classDiagram
GenerateDiagram : allow_rules Vec
GenerateDiagram : deny_rules Vec
GenerateDiagram : split_by_module bool
GenerateDiagram : format_ext String
GenerateDiagram : output_dir Option
GenerateDiagram : +execute() -] Result[(), DomainError]
GenerateDiagram : +check_violations_only() -] Vec[String]
GenerateDiagram : +execute() -] Result[GenerateDiagramResult, DomainError]
SourceFile --> FilePath
SourceFile --> Language
RuleViolation --> RuleKind
@@ -401,5 +406,6 @@ classDiagram
HtmlRenderer <|-- DiagramRenderer
CargoWorkspaceAnalyzer <|-- ProjectAnalyzer
AnalyzeCodebaseResult --> NormalizedGraph
BuildCodeGraphResult --> NormalizedGraph
GenerateDiagramResult --> RenderOutput
GenerateDiagram --> NormalizedGraph

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`