Files
archlens/docs/superpowers/plans/2026-06-17-deepening-interfaces.md
Gabriel Kaszewski 009c821f48
All checks were successful
CI / Check / Test (push) Successful in 3m1s
Architecture Docs / Generate diagrams (push) Successful in 2m43s
style: cargo fmt
2026-06-17 13:44:22 +02:00

1519 lines
46 KiB
Markdown

# 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 `?` or `map_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 --workspace` after 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` (replace `build_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: String` fields from `GenerateDiagram`
- Removes: `check_violations_only()` method, `write_split` free function
- Changes: `execute(self) -> Result<GenerateDiagramResult, DomainError>` where `GenerateDiagramResult { violations: Vec<RuleViolation>, output: RenderOutput }`
- Produces: presentation callers use `result.violations` and `result.output` directly
- [ ] **Step 1: Write the failing tests**
Create `crates/application/tests/generate_diagram_tests.rs`:
```rust
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**
```bash
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`:
```rust
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:
```rust
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:
```rust
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()`):
```rust
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_split` from the `generate_diagram` import (or remove `{GenerateDiagram, write_split}``GenerateDiagram`)
- Add `use archlens_domain::RenderOutput;` so `write_diagram_output` can reference it in its signature
- [ ] **Step 5: Run tests — verify they pass**
```bash
cargo test --workspace
```
Expected: all tests pass, including the four new `generate_diagram_tests` tests.
- [ ] **Step 6: Commit**
```bash
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`:
```rust
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`:
```rust
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**
```bash
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`:
```rust
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`:
```rust
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(&current_lines)
.filter(|l| !l.trim().is_empty())
.map(|l| format!("- {l}"))
.collect();
Ok(DiffResult { added, removed })
}
}
```
- [ ] **Step 5: Update `presentation/src/lib.rs` callers**
In `run()`, replace the `CheckFreshness` block:
```rust
// 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:
```rust
// 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**
```bash
cargo test --workspace
```
Expected: all tests pass.
- [ ] **Step 7: Commit**
```bash
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`, `ProjectAnalyzer` port from domain
- Produces: `BuildCodeGraph<F, S>` struct with `execute(self, root, config, level) -> Result<BuildCodeGraphResult, DomainError>` where `BuildCodeGraphResult { graph: NormalizedGraph, warnings: Vec<AnalysisWarning> }`
- [ ] **Step 1: Create `FakeProjectAnalyzer`**
Create `crates/application/tests/fakes/project_analyzer.rs`:
```rust
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`:
```rust
mod project_analyzer;
pub use project_analyzer::FakeProjectAnalyzer;
```
- [ ] **Step 2: Write the failing tests**
Create `crates/application/tests/build_code_graph_tests.rs`:
```rust
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**
```bash
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`:
```rust
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`:
```rust
pub mod build_code_graph;
pub mod check_freshness;
pub mod diff_diagram;
pub mod generate_diagram;
```
- [ ] **Step 6: Run tests — verify they pass**
```bash
cargo test --package archlens-application build_code_graph_tests
```
Expected: all 5 tests pass.
- [ ] **Step 7: Update `presentation/src/lib.rs` — replace `build_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 by `BuildCodeGraph`)
Replace the entire `build_graph()` function:
```rust
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**
```bash
cargo test --workspace
```
Expected: all tests pass.
- [ ] **Step 9: Commit**
```bash
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-primitives` crate with `non_import_rels(rels: &[Relationship]) -> impl Iterator<Item = &Relationship>` and `sanitize_identifier(name: &str) -> String`
- Consumes: `archlens-domain` types (`Relationship`, `RelationshipKind`)
- [ ] **Step 1: Write the failing tests**
Create `crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs` (create parent dirs first):
```rust
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**
```bash
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`:
```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`:
```rust
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`:
```toml
"crates/adapters/rendering-primitives",
```
Add to `[workspace.dependencies]`:
```toml
archlens-rendering-primitives = { path = "crates/adapters/rendering-primitives" }
```
- [ ] **Step 5: Run tests — verify they pass**
```bash
cargo test --package archlens-rendering-primitives
```
Expected: all 6 tests pass.
- [ ] **Step 6: Wire `non_import_rels` into all four renderer adapters**
Add to each renderer's `Cargo.toml` under `[dependencies]`:
```toml
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`:
```rust
// 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:
```rust
// 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`:
```rust
// 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:
```rust
// 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:
```rust
// 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:
```rust
// 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**
```bash
cargo test --workspace
```
Expected: all tests pass, including existing renderer tests.
- [ ] **Step 8: Commit**
```bash
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_extraction` free function
- `RustExtractor` and `PythonExtractor` implement the new trait
- `TreeSitterAnalyzer::analyze_file()` calls `run_extraction`
- Existing tests in `rust_analyzer_tests.rs` and `python_analyzer_tests.rs` verify behavior is unchanged
- [ ] **Step 1: Run existing extractor tests to establish baseline**
```bash
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`:
```rust
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 `RustExtractor` to implement the new trait**
In `crates/adapters/tree-sitter/src/rust/mod.rs`, replace the `LanguageExtractor` impl block for `RustExtractor`:
```rust
// 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:
```rust
use tree_sitter::{Node, Parser};
```
Remove the `use archlens_domain::AnalysisResult;` import if it's no longer used directly.
- [ ] **Step 4: Update `PythonExtractor` to implement the new trait**
In `crates/adapters/tree-sitter/src/python/mod.rs`, replace the `LanguageExtractor` impl:
```rust
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:
```rust
// 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**
```bash
cargo test --package archlens-tree-sitter
```
Expected: same number of tests pass as in Step 1. Zero new failures.
- [ ] **Step 7: Run full workspace**
```bash
cargo test --workspace
```
Expected: all tests pass.
- [ ] **Step 8: Commit**
```bash
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"
```