46 KiB
Deepening Application and Adapter Interfaces — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Deepen four under-specified interfaces — pull I/O out of the application layer, sink graph-building orchestration into a new use case, share renderer primitives across adapters, and formalise the LanguageExtractor pipeline.
Architecture: Application use cases become pure (no std::fs); a new BuildCodeGraph use case absorbs orchestration logic from presentation; a new rendering-primitives crate eliminates cross-adapter duplication; the LanguageExtractor trait gains explicit three-phase structure so adding a new language has a clear template.
Tech Stack: Rust, Cargo workspace, archlens-domain types (NormalizedGraph, RuleViolation, RenderOutput, RenderedFile, BoundaryRule, ModuleName, CodeGraph), tree-sitter 0.24
Global Constraints
- No
unwrap()in production code; use?ormap_err - Test files live in
tests/per crate; one test file per unit under test - No new external dependencies beyond those already in workspace
Cargo.toml - Run
cargo test --workspaceafter every task; it must pass before committing - Commit message: imperative, ≤72 chars, no co-author line
File Map
Task 1 — GenerateDiagram pure
- Modify:
crates/application/src/use_cases/generate_diagram.rs - Create:
crates/application/tests/generate_diagram_tests.rs - Modify:
crates/presentation/src/lib.rs(callers:run(),run_watch())
Task 2 — CheckFreshness + DiffDiagram pure
- Modify:
crates/application/src/use_cases/check_freshness.rs - Modify:
crates/application/src/use_cases/diff_diagram.rs - Create:
crates/application/tests/check_freshness_tests.rs - Create:
crates/application/tests/diff_diagram_tests.rs - Modify:
crates/presentation/src/lib.rs(callers:run(),run_diff())
Task 3 — BuildCodeGraph use case
- Create:
crates/application/src/use_cases/build_code_graph.rs - Modify:
crates/application/src/use_cases/mod.rs - Create:
crates/application/tests/fakes/project_analyzer.rs - Modify:
crates/application/tests/fakes/mod.rs - Create:
crates/application/tests/build_code_graph_tests.rs - Modify:
crates/presentation/src/lib.rs(replacebuild_graph())
Task 4 — rendering-primitives crate
- Create:
crates/adapters/rendering-primitives/Cargo.toml - Create:
crates/adapters/rendering-primitives/src/lib.rs - Create:
crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs - Modify:
Cargo.toml(workspace members + workspace.dependencies) - Modify:
crates/adapters/mermaid/Cargo.toml - Modify:
crates/adapters/d2/Cargo.toml - Modify:
crates/adapters/ascii/Cargo.toml - Modify:
crates/adapters/html-viewer/Cargo.toml - Modify:
crates/adapters/mermaid/src/mermaid_renderer.rs - Modify:
crates/adapters/d2/src/d2_renderer.rs - Modify:
crates/adapters/ascii/src/ascii_renderer.rs - Modify:
crates/adapters/html-viewer/src/html_renderer.rs
Task 5 — Typed LanguageExtractor pipeline
- Modify:
crates/adapters/tree-sitter/src/language_extractor.rs - Modify:
crates/adapters/tree-sitter/src/rust/mod.rs - Modify:
crates/adapters/tree-sitter/src/python/mod.rs - Modify:
crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs
Task 1: Make GenerateDiagram pure
Interfaces:
-
Removes:
output_dir: Option<PathBuf>,format_ext: Stringfields fromGenerateDiagram -
Removes:
check_violations_only()method,write_splitfree function -
Changes:
execute(self) -> Result<GenerateDiagramResult, DomainError>whereGenerateDiagramResult { violations: Vec<RuleViolation>, output: RenderOutput } -
Produces: presentation callers use
result.violationsandresult.outputdirectly -
Step 1: Write the failing tests
Create crates/application/tests/generate_diagram_tests.rs:
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);
}
- Step 2: Run tests — verify they fail
cargo test --package archlens-application generate_diagram_tests
Expected: compile error — GenerateDiagram still has output_dir, format_ext fields and execute() returns Result<(), DomainError>.
- Step 3: Rewrite
generate_diagram.rs
Replace the entire contents of crates/application/src/use_cases/generate_diagram.rs:
use archlens_domain::{
BoundaryRule, DomainError, NormalizedGraph, RenderOutput, RenderedFile, RuleViolation,
check_boundary_rules, ports::DiagramRenderer,
};
pub struct GenerateDiagramResult {
pub violations: Vec<RuleViolation>,
pub output: RenderOutput,
}
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,
}
impl GenerateDiagram {
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()
};
let output = if self.split_by_module {
render_split(&self.graph, &*self.renderer)?
} else {
self.renderer.render(self.graph.as_graph())?
};
Ok(GenerateDiagramResult { violations, output })
}
}
fn render_split(
graph: &NormalizedGraph,
renderer: &dyn DiagramRenderer,
) -> Result<RenderOutput, DomainError> {
let mut files = Vec::new();
let overview = renderer.render(graph.as_graph())?;
let ext = overview
.files()
.first()
.and_then(|f| std::path::Path::new(f.name()).extension())
.and_then(|e| e.to_str())
.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)?;
if let Some(f) = module_output.files().first() {
files.push(RenderedFile::new(
&format!("{}.{ext}", module.as_str().to_lowercase()),
f.content(),
)?);
}
}
Ok(RenderOutput::new(files))
}
- Step 4: Update
presentation/src/lib.rs
Remove the write_split import. Replace the run() body section from "let use_case = GenerateDiagram {" through "use_case.execute()?;" with:
let use_case = GenerateDiagram {
graph,
renderer,
allow_rules: allow,
deny_rules: deny,
split_by_module: args.split_by_module,
};
let result = use_case.execute()?;
if args.strict && !result.violations.is_empty() {
bail!(
"{} boundary rule violation(s) in strict mode",
result.violations.len()
);
}
for v in &result.violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
write_diagram_output(&result.output, args.output.as_deref(), args.split_by_module)?;
Replace the run_watch() closure body with:
let run_once = |args: &Cli| -> Result<()> {
let config_loader = load_config(args)?;
let graph = build_graph(args, level)?;
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
let (raw_allow, raw_deny) = config_loader.load_rules();
let allow: Vec<BoundaryRule> = raw_allow
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
let deny: Vec<BoundaryRule> = raw_deny
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
let use_case = GenerateDiagram {
graph,
renderer,
allow_rules: allow,
deny_rules: deny,
split_by_module: args.split_by_module,
};
let result = use_case.execute()?;
write_diagram_output(&result.output, args.output.as_deref(), args.split_by_module)?;
for v in &result.violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
Ok(())
};
Add this helper to presentation/src/lib.rs (before run()):
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(())
}
Update the presentation's import block:
-
Remove
write_splitfrom thegenerate_diagramimport (or remove{GenerateDiagram, write_split}→GenerateDiagram) -
Add
use archlens_domain::RenderOutput;sowrite_diagram_outputcan reference it in its signature -
Step 5: Run tests — verify they pass
cargo test --workspace
Expected: all tests pass, including the four new generate_diagram_tests tests.
- Step 6: Commit
git add crates/application/src/use_cases/generate_diagram.rs \
crates/application/tests/generate_diagram_tests.rs \
crates/presentation/src/lib.rs
git commit -m "refactor: make GenerateDiagram pure — return RenderOutput instead of writing files"
Task 2: Make CheckFreshness and DiffDiagram pure
Interfaces:
-
CheckFreshness:existing_path: &'a Path→existing_content: &'a str -
DiffDiagram:existing_path: &'a Path→existing_content: &'a str -
Presentation reads file content before constructing either struct
-
Step 1: Write the failing tests
Create crates/application/tests/check_freshness_tests.rs:
mod fakes;
use archlens_domain::{CodeGraph, NormalizedGraph};
use archlens_application::use_cases::check_freshness::CheckFreshness;
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);
}
Create crates/application/tests/diff_diagram_tests.rs:
mod fakes;
use archlens_domain::{CodeGraph, NormalizedGraph};
use archlens_application::use_cases::diff_diagram::DiffDiagram;
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());
}
- Step 2: Run tests — verify they fail
cargo test --package archlens-application check_freshness_tests diff_diagram_tests
Expected: compile errors — structs still have existing_path: &Path.
- Step 3: Rewrite
check_freshness.rs
Replace crates/application/src/use_cases/check_freshness.rs:
use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};
pub struct CheckFreshness<'a> {
pub graph: &'a NormalizedGraph,
pub renderer: &'a dyn DiagramRenderer,
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("");
Ok(current == self.existing_content)
}
}
- Step 4: Rewrite
diff_diagram.rs
Replace crates/application/src/use_cases/diff_diagram.rs:
use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};
pub struct DiffResult {
pub added: Vec<String>,
pub removed: Vec<String>,
}
impl DiffResult {
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty()
}
}
pub struct DiffDiagram<'a> {
pub graph: &'a NormalizedGraph,
pub renderer: &'a dyn DiagramRenderer,
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 current_lines: std::collections::HashSet<&str> = current.lines().collect();
let existing_lines: std::collections::HashSet<&str> = self.existing_content.lines().collect();
let added: Vec<String> = current_lines
.difference(&existing_lines)
.filter(|l| !l.trim().is_empty())
.map(|l| format!("+ {l}"))
.collect();
let removed: Vec<String> = existing_lines
.difference(¤t_lines)
.filter(|l| !l.trim().is_empty())
.map(|l| format!("- {l}"))
.collect();
Ok(DiffResult { added, removed })
}
}
- Step 5: Update
presentation/src/lib.rscallers
In run(), replace the CheckFreshness block:
// before
let up_to_date = CheckFreshness {
graph: &graph,
renderer: &*renderer,
existing_path: std::path::Path::new(existing_path),
}
.execute()?;
// after
let existing_content = std::fs::read_to_string(existing_path)
.map_err(|e| anyhow::anyhow!("cannot read {existing_path}: {e}"))?;
let up_to_date = CheckFreshness {
graph: &graph,
renderer: &*renderer,
existing_content: &existing_content,
}
.execute()?;
In run_diff(), replace the DiffDiagram block:
// before
let diff = DiffDiagram {
graph: &graph,
renderer: &*renderer,
existing_path,
}
.execute()?;
// after
let existing_content = std::fs::read_to_string(existing_path)
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", existing_path.display()))?;
let diff = DiffDiagram {
graph: &graph,
renderer: &*renderer,
existing_content: &existing_content,
}
.execute()?;
- Step 6: Run tests — verify they pass
cargo test --workspace
Expected: all tests pass.
- Step 7: Commit
git add crates/application/src/use_cases/check_freshness.rs \
crates/application/src/use_cases/diff_diagram.rs \
crates/application/tests/check_freshness_tests.rs \
crates/application/tests/diff_diagram_tests.rs \
crates/presentation/src/lib.rs
git commit -m "refactor: CheckFreshness and DiffDiagram take &str instead of &Path"
Task 3: BuildCodeGraph use case
Interfaces:
-
Consumes:
FakeFileDiscovery,FakeSourceAnalyzer,NormalizedGraph,AnalysisConfig,DiagramLevel,ProjectAnalyzerport from domain -
Produces:
BuildCodeGraph<F, S>struct withexecute(self, root, config, level) -> Result<BuildCodeGraphResult, DomainError>whereBuildCodeGraphResult { graph: NormalizedGraph, warnings: Vec<AnalysisWarning> } -
Step 1: Create
FakeProjectAnalyzer
Create crates/application/tests/fakes/project_analyzer.rs:
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())
}
}
Add to crates/application/tests/fakes/mod.rs:
mod project_analyzer;
pub use project_analyzer::FakeProjectAnalyzer;
- Step 2: Write the failing tests
Create crates/application/tests/build_code_graph_tests.rs:
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");
}
- Step 3: Run tests — verify they fail
cargo test --package archlens-application build_code_graph_tests
Expected: compile error — BuildCodeGraph does not exist.
- Step 4: Create
build_code_graph.rs
Create crates/application/src/use_cases/build_code_graph.rs:
use std::path::Path;
use archlens_domain::{
AnalysisConfig, AnalysisWarning, DiagramLevel, DomainError, NormalizedGraph,
ports::{FileDiscovery, ProjectAnalyzer, SourceAnalyzer},
};
use crate::queries::AnalyzeCodebase;
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 {
if let Some(pa) = self.project_analyzer {
let project_cg = pa.analyze(root)?;
graph.merge_project_edges(&project_cg);
}
}
Ok(BuildCodeGraphResult {
graph,
warnings: result.warnings().to_vec(),
})
}
}
}
}
- Step 5: Export from
use_cases/mod.rs
Add to crates/application/src/use_cases/mod.rs:
pub mod build_code_graph;
pub mod check_freshness;
pub mod diff_diagram;
pub mod generate_diagram;
- Step 6: Run tests — verify they pass
cargo test --package archlens-application build_code_graph_tests
Expected: all 5 tests pass.
- Step 7: Update
presentation/src/lib.rs— replacebuild_graph()
Update imports in presentation/src/lib.rs:
- Add:
use archlens_application::use_cases::build_code_graph::BuildCodeGraph; - Remove:
use archlens_application::queries::AnalyzeCodebase;(now used internally byBuildCodeGraph)
Replace the entire build_graph() function:
fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
let config_loader = load_config(args)?;
let mut analysis_config = config_loader.load_analysis_config()?;
analysis_config = analysis_config.with_level(level);
if let Some(ref scope) = args.scope {
analysis_config = analysis_config.with_scope(scope.clone());
}
if !args.exclude.is_empty() {
let mut excludes = analysis_config.excludes().to_vec();
excludes.extend(args.exclude.iter().cloned());
analysis_config = analysis_config.with_excludes(excludes);
}
if args.include_tests {
analysis_config = analysis_config.with_include_tests(true);
}
if let Some(ref git_ref) = args.since {
let changed = get_changed_files(&args.path, git_ref)?;
analysis_config = analysis_config.with_changed_files(changed);
}
// ProjectAnalyzer is already imported in presentation via `use archlens_domain::ports::{..., ProjectAnalyzer}`
let project_analyzer: Option<Box<dyn ProjectAnalyzer>> = {
let cargo_toml = args.path.join("Cargo.toml");
let pyproject = args.path.join("pyproject.toml");
if cargo_toml.exists() {
Some(Box::new(CargoWorkspaceAnalyzer::new()))
} else if pyproject.exists() {
Some(Box::new(PythonProjectAnalyzer::new()))
} else {
None
}
};
let use_case = BuildCodeGraph {
discovery: WalkdirDiscovery::new(),
source_analyzer: TreeSitterAnalyzer::new(),
project_analyzer,
};
let result = use_case.execute(&args.path, &analysis_config, level)?;
if !result.warnings.is_empty() {
for warning in &result.warnings {
eprintln!(
"WARNING: {}:{} {}",
warning.file_path().as_str(),
warning.line(),
warning.message()
);
}
if args.strict {
bail!(
"analysis produced {} warning(s) in strict mode",
result.warnings.len()
);
}
}
Ok(result.graph)
}
- Step 8: Run all tests
cargo test --workspace
Expected: all tests pass.
- Step 9: Commit
git add crates/application/src/use_cases/build_code_graph.rs \
crates/application/src/use_cases/mod.rs \
crates/application/tests/fakes/project_analyzer.rs \
crates/application/tests/fakes/mod.rs \
crates/application/tests/build_code_graph_tests.rs \
crates/presentation/src/lib.rs
git commit -m "feat: add BuildCodeGraph use case, sink orchestration out of presentation"
Task 4: rendering-primitives crate
Interfaces:
-
Produces:
archlens-rendering-primitivescrate withnon_import_rels(rels: &[Relationship]) -> impl Iterator<Item = &Relationship>andsanitize_identifier(name: &str) -> String -
Consumes:
archlens-domaintypes (Relationship,RelationshipKind) -
Step 1: Write the failing tests
Create crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs (create parent dirs first):
use archlens_domain::{
FilePath, 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");
}
- Step 2: Run tests — verify they fail
cargo test --package archlens-rendering-primitives 2>&1 | head -20
Expected: error — crate doesn't exist yet.
- Step 3: Create the crate
Create crates/adapters/rendering-primitives/Cargo.toml:
[package]
name = "archlens-rendering-primitives"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
Create crates/adapters/rendering-primitives/src/lib.rs:
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(['-', '.', ' '], "_")
}
- Step 4: Register crate in workspace
In the root Cargo.toml, add to [workspace] members:
"crates/adapters/rendering-primitives",
Add to [workspace.dependencies]:
archlens-rendering-primitives = { path = "crates/adapters/rendering-primitives" }
- Step 5: Run tests — verify they pass
cargo test --package archlens-rendering-primitives
Expected: all 6 tests pass.
- Step 6: Wire
non_import_relsinto all four renderer adapters
Add to each renderer's Cargo.toml under [dependencies]:
archlens-rendering-primitives.workspace = true
Files to update: crates/adapters/mermaid/Cargo.toml, crates/adapters/d2/Cargo.toml, crates/adapters/ascii/Cargo.toml, crates/adapters/html-viewer/Cargo.toml.
In crates/adapters/mermaid/src/mermaid_renderer.rs, add import and replace import filter in render_class_diagram:
// add at top
use archlens_rendering_primitives::non_import_rels;
// replace in render_class_diagram()
// before:
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
// after:
for rel in non_import_rels(graph.relationships()) {
Also remove the RelationshipKind::Import arm from the arrow match since Import is now filtered:
// before:
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "<|--",
RelationshipKind::Composition => "-->",
RelationshipKind::Import => "..>",
};
// after:
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "<|--",
RelationshipKind::Composition => "-->",
RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
};
In crates/adapters/d2/src/d2_renderer.rs, add import and replace filter + use sanitize_identifier:
// add at top
use archlens_rendering_primitives::{non_import_rels, sanitize_identifier};
// remove local fn sanitize() entirely
// replace all calls to sanitize() with sanitize_identifier()
// in render_type(), replace:
for rel in graph.relationships() {
use archlens_domain::RelationshipKind;
let src = sanitize(rel.source());
let tgt = sanitize(rel.target());
let arrow = match rel.kind() {
RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"),
RelationshipKind::Composition => format!("{src} -> {tgt}"),
RelationshipKind::Import => continue,
};
lines.push(arrow);
}
// with:
for rel in non_import_rels(graph.relationships()) {
use archlens_domain::RelationshipKind;
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 => unreachable!("imports filtered by non_import_rels"),
};
lines.push(arrow);
}
Apply the same sanitize → sanitize_identifier rename throughout d2_renderer.rs (also in render_project and render_module).
In crates/adapters/ascii/src/ascii_renderer.rs, add import and replace filter:
// add at top
use archlens_rendering_primitives::non_import_rels;
// replace the non_import_rels filter block (~line 97):
// before:
let non_import_rels: Vec<_> = graph
.relationships()
.iter()
.filter(|r| r.kind() != RelationshipKind::Import)
.collect();
// after:
let filtered_rels: Vec<_> = non_import_rels(graph.relationships()).collect();
Update subsequent usages of non_import_rels variable to filtered_rels in ascii_renderer.rs.
Also replace the import filter for the imports section:
// before:
let import_rels: Vec<_> = graph
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
// after (keep as-is — this is the opposite filter, not shared):
let import_rels: Vec<_> = graph
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
In crates/adapters/html-viewer/src/html_renderer.rs, add import and replace filter:
// add at top
use archlens_rendering_primitives::non_import_rels;
// replace:
let edges = graph
.relationships()
.iter()
.filter(|r| r.kind() != RelationshipKind::Import)
.filter_map(|r| { ... })
.collect();
// with:
let edges = non_import_rels(graph.relationships())
.filter_map(|r| { ... })
.collect();
Remove use archlens_domain::RelationshipKind; from html_renderer.rs if it's no longer needed.
- Step 7: Run all tests
cargo test --workspace
Expected: all tests pass, including existing renderer tests.
- Step 8: Commit
git add Cargo.toml \
crates/adapters/rendering-primitives/ \
crates/adapters/mermaid/Cargo.toml \
crates/adapters/mermaid/src/mermaid_renderer.rs \
crates/adapters/d2/Cargo.toml \
crates/adapters/d2/src/d2_renderer.rs \
crates/adapters/ascii/Cargo.toml \
crates/adapters/ascii/src/ascii_renderer.rs \
crates/adapters/html-viewer/Cargo.toml \
crates/adapters/html-viewer/src/html_renderer.rs
git commit -m "feat: add rendering-primitives crate, share non_import_rels across renderers"
Task 5: Typed LanguageExtractor pipeline
Interfaces:
-
Replaces:
trait LanguageExtractor { fn analyze(...) }with a 4-method trait +run_extractionfree function -
RustExtractorandPythonExtractorimplement the new trait -
TreeSitterAnalyzer::analyze_file()callsrun_extraction -
Existing tests in
rust_analyzer_tests.rsandpython_analyzer_tests.rsverify behavior is unchanged -
Step 1: Run existing extractor tests to establish baseline
cargo test --package archlens-tree-sitter
Expected: all existing tests pass. Note the count — they must all still pass after the refactor.
- Step 2: Replace
language_extractor.rs
Replace crates/adapters/tree-sitter/src/language_extractor.rs:
use tree_sitter::{Node, Parser};
use archlens_domain::{AnalysisResult, DomainError, FilePath};
use crate::extraction_context::ExtractionContext;
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> {
let mut parser = Parser::new();
parser
.set_language(&extractor.tree_sitter_language().into())
.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()
}
- Step 3: Update
RustExtractorto implement the new trait
In crates/adapters/tree-sitter/src/rust/mod.rs, replace the LanguageExtractor impl block for RustExtractor:
// remove the old impl:
// impl LanguageExtractor for RustExtractor {
// fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
// analyze(source, file_path)
// }
// }
// replace with:
impl LanguageExtractor for RustExtractor {
fn tree_sitter_language(&self) -> tree_sitter::Language {
tree_sitter_rust::LANGUAGE.into()
}
fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
collect_types(root, source, ctx);
}
fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
collect_relationships(root, source, ctx);
}
fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
collect_mod_declarations(root, source, ctx);
collect_use_imports(root, source, ctx);
}
}
Remove the pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> free function (it's replaced by run_extraction).
Update imports at the top of rust/mod.rs — add Node from tree_sitter if not already imported:
use tree_sitter::{Node, Parser};
Remove the use archlens_domain::AnalysisResult; import if it's no longer used directly.
- Step 4: Update
PythonExtractorto implement the new trait
In crates/adapters/tree-sitter/src/python/mod.rs, replace the LanguageExtractor impl:
impl LanguageExtractor for PythonExtractor {
fn tree_sitter_language(&self) -> tree_sitter::Language {
tree_sitter_python::LANGUAGE.into()
}
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);
}
fn extract_relationships(&self, _root: &Node, _source: &str, _ctx: &mut ExtractionContext) {
// Relationships are collected inside collect_classes for Python
}
fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
collect_imports(root, source, ctx);
}
}
Remove the old fn analyze(...) free function. Node is already imported at the top of python/mod.rs.
- Step 5: Update
TreeSitterAnalyzer
In crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs, replace the analyze_file body:
// add import at top
use crate::language_extractor::run_extraction;
// replace:
impl SourceAnalyzer for TreeSitterAnalyzer {
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError> {
let source = std::fs::read_to_string(file.path().as_str())
.map_err(|e| DomainError::IoError(e.to_string()))?;
match self.extractor_for(file.language()) {
Some(extractor) => extractor.analyze(&source, file.path()),
None => Ok(AnalysisResult::empty()),
}
}
}
// with:
impl SourceAnalyzer for TreeSitterAnalyzer {
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError> {
let source = std::fs::read_to_string(file.path().as_str())
.map_err(|e| DomainError::IoError(e.to_string()))?;
match self.extractor_for(file.language()) {
Some(extractor) => run_extraction(extractor, &source, file.path()),
None => Ok(AnalysisResult::empty()),
}
}
}
- Step 6: Run extractor tests — verify same count passes
cargo test --package archlens-tree-sitter
Expected: same number of tests pass as in Step 1. Zero new failures.
- Step 7: Run full workspace
cargo test --workspace
Expected: all tests pass.
- Step 8: Commit
git add crates/adapters/tree-sitter/src/language_extractor.rs \
crates/adapters/tree-sitter/src/rust/mod.rs \
crates/adapters/tree-sitter/src/python/mod.rs \
crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs
git commit -m "refactor: LanguageExtractor gains explicit 3-phase trait + run_extraction pipeline"