1519 lines
46 KiB
Markdown
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(¤t_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"
|
|
```
|