# 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`, `format_ext: String` fields from `GenerateDiagram` - Removes: `check_violations_only()` method, `write_split` free function - Changes: `execute(self) -> Result` where `GenerateDiagramResult { violations: Vec, 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, pub output: RenderOutput, } pub struct GenerateDiagram { pub graph: NormalizedGraph, pub renderer: Box, pub allow_rules: Vec, pub deny_rules: Vec, pub split_by_module: bool, } impl GenerateDiagram { pub fn execute(self) -> Result { 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 { 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 = raw_allow .iter() .filter_map(|s| BoundaryRule::parse(s)) .collect(); let deny: Vec = 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 { 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, pub removed: Vec, } 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 { 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 = current_lines .difference(&existing_lines) .filter(|l| !l.trim().is_empty()) .map(|l| format!("+ {l}")) .collect(); let removed: Vec = 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` struct with `execute(self, root, config, level) -> Result` where `BuildCodeGraphResult { graph: NormalizedGraph, warnings: Vec }` - [ ] **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 { 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, } pub struct BuildCodeGraph where F: FileDiscovery + Send + Sync, S: SourceAnalyzer, { pub discovery: F, pub source_analyzer: S, pub project_analyzer: Option>, } impl BuildCodeGraph where F: FileDiscovery + Send + Sync, S: SourceAnalyzer, { pub fn execute( self, root: &Path, config: &AnalysisConfig, level: DiagramLevel, ) -> Result { 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 { 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> = { 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` 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 { 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 { 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 { // 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` 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 { 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 { 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" ```