From 009c821f4870a34e55c0e74f304f87a038fdf5bf Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 17 Jun 2026 13:44:19 +0200 Subject: [PATCH] style: cargo fmt --- Cargo.lock | 11 + crates/adapters/d2/src/d2_renderer.rs | 12 +- .../adapters/html-viewer/src/html_renderer.rs | 4 +- .../tests/rendering_primitives_tests.rs | 10 +- crates/adapters/tree-sitter/src/rust/mod.rs | 4 +- .../application/src/use_cases/diff_diagram.rs | 3 +- .../tests/build_code_graph_tests.rs | 60 +- .../tests/check_freshness_tests.rs | 2 +- .../application/tests/diff_diagram_tests.rs | 2 +- .../tests/fakes/project_analyzer.rs | 4 +- .../tests/generate_diagram_tests.rs | 38 +- crates/presentation/src/lib.rs | 10 +- .../plans/2026-06-17-deepening-interfaces.md | 1518 +++++++++++++++++ .../2026-06-17-deepening-interfaces-design.md | 207 +++ 14 files changed, 1834 insertions(+), 51 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-17-deepening-interfaces.md create mode 100644 docs/superpowers/specs/2026-06-17-deepening-interfaces-design.md diff --git a/Cargo.lock b/Cargo.lock index 726ed5a..f735230 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,6 +107,7 @@ name = "archlens-ascii" version = "0.1.0" dependencies = [ "archlens-domain", + "archlens-rendering-primitives", "thiserror", "tracing", ] @@ -128,6 +129,7 @@ name = "archlens-d2" version = "0.1.0" dependencies = [ "archlens-domain", + "archlens-rendering-primitives", "tempfile", ] @@ -153,6 +155,7 @@ name = "archlens-html" version = "0.1.0" dependencies = [ "archlens-domain", + "archlens-rendering-primitives", "serde", "serde_json", "tempfile", @@ -163,6 +166,7 @@ name = "archlens-mermaid" version = "0.1.0" dependencies = [ "archlens-domain", + "archlens-rendering-primitives", "thiserror", "tracing", ] @@ -177,6 +181,13 @@ dependencies = [ "toml", ] +[[package]] +name = "archlens-rendering-primitives" +version = "0.1.0" +dependencies = [ + "archlens-domain", +] + [[package]] name = "archlens-stdout-writer" version = "0.1.0" diff --git a/crates/adapters/d2/src/d2_renderer.rs b/crates/adapters/d2/src/d2_renderer.rs index 9875c95..4a218a7 100644 --- a/crates/adapters/d2/src/d2_renderer.rs +++ b/crates/adapters/d2/src/d2_renderer.rs @@ -97,7 +97,11 @@ fn render_module(graph: &CodeGraph) -> String { } for (src, tgt) in graph.module_edges().keys() { - lines.push(format!("{} -> {}", sanitize_identifier(src), sanitize_identifier(tgt))); + lines.push(format!( + "{} -> {}", + sanitize_identifier(src), + sanitize_identifier(tgt) + )); } lines.join("\n") @@ -113,7 +117,11 @@ fn render_project(graph: &CodeGraph) -> String { let mod_id = sanitize_identifier(module); lines.push(format!("{mod_id}: {{")); for el in elements { - lines.push(format!(" {}: {}", sanitize_identifier(el.name()), el.name())); + lines.push(format!( + " {}: {}", + sanitize_identifier(el.name()), + el.name() + )); } lines.push("}".to_string()); } diff --git a/crates/adapters/html-viewer/src/html_renderer.rs b/crates/adapters/html-viewer/src/html_renderer.rs index f2ffede..14f9cd4 100644 --- a/crates/adapters/html-viewer/src/html_renderer.rs +++ b/crates/adapters/html-viewer/src/html_renderer.rs @@ -2,9 +2,7 @@ use std::collections::HashMap; use serde::Serialize; -use archlens_domain::{ - CodeGraph, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer, -}; +use archlens_domain::{CodeGraph, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer}; use archlens_rendering_primitives::non_import_rels; pub struct HtmlRenderer; diff --git a/crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs b/crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs index af9384c..9740543 100644 --- a/crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs +++ b/crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs @@ -1,6 +1,4 @@ -use archlens_domain::{ - Relationship, RelationshipKind, -}; +use archlens_domain::{Relationship, RelationshipKind}; use archlens_rendering_primitives::{non_import_rels, sanitize_identifier}; fn rel(src: &str, tgt: &str, kind: RelationshipKind) -> Relationship { @@ -16,7 +14,11 @@ fn non_import_rels_excludes_import_relationships() { ]; let filtered: Vec<_> = non_import_rels(&rels).collect(); assert_eq!(filtered.len(), 2); - assert!(filtered.iter().all(|r| r.kind() != RelationshipKind::Import)); + assert!( + filtered + .iter() + .all(|r| r.kind() != RelationshipKind::Import) + ); } #[test] diff --git a/crates/adapters/tree-sitter/src/rust/mod.rs b/crates/adapters/tree-sitter/src/rust/mod.rs index b9ca748..e854fa1 100644 --- a/crates/adapters/tree-sitter/src/rust/mod.rs +++ b/crates/adapters/tree-sitter/src/rust/mod.rs @@ -35,9 +35,7 @@ const RUST_PRIMITIVES: &[&str] = &[ "Self", ]; -use archlens_domain::{ - CodeElement, CodeElementKind, Relationship, RelationshipKind, Visibility, -}; +use archlens_domain::{CodeElement, CodeElementKind, Relationship, RelationshipKind, Visibility}; use crate::extraction_context::ExtractionContext; use crate::language_extractor::LanguageExtractor; diff --git a/crates/application/src/use_cases/diff_diagram.rs b/crates/application/src/use_cases/diff_diagram.rs index 6ae4b37..028c6da 100644 --- a/crates/application/src/use_cases/diff_diagram.rs +++ b/crates/application/src/use_cases/diff_diagram.rs @@ -26,7 +26,8 @@ impl<'a> DiffDiagram<'a> { 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 existing_lines: std::collections::HashSet<&str> = + self.existing_content.lines().collect(); let added: Vec = current_lines .difference(&existing_lines) diff --git a/crates/application/tests/build_code_graph_tests.rs b/crates/application/tests/build_code_graph_tests.rs index d431374..a8f215a 100644 --- a/crates/application/tests/build_code_graph_tests.rs +++ b/crates/application/tests/build_code_graph_tests.rs @@ -30,7 +30,11 @@ fn project_level_returns_project_analyzer_graph() { }; let result = use_case - .execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Project) + .execute( + Path::new("."), + &AnalysisConfig::default(), + DiagramLevel::Project, + ) .unwrap(); assert_eq!(result.graph.elements().len(), 1); @@ -47,7 +51,11 @@ fn project_level_without_analyzer_returns_error() { }; let err = use_case - .execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Project) + .execute( + Path::new("."), + &AnalysisConfig::default(), + DiagramLevel::Project, + ) .unwrap_err(); assert!(err.to_string().contains("no project analyzer")); @@ -63,13 +71,15 @@ fn type_level_uses_source_analyzer_not_project() { 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![ + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap(), + ], vec![], vec![], ), @@ -93,7 +103,11 @@ fn type_level_uses_source_analyzer_not_project() { }; let result = use_case - .execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Type) + .execute( + Path::new("."), + &AnalysisConfig::default(), + DiagramLevel::Type, + ) .unwrap(); // Source element present, project element NOT merged (Type level skips merge) @@ -110,7 +124,11 @@ fn module_level_without_project_analyzer_succeeds() { }; let result = use_case - .execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Module) + .execute( + Path::new("."), + &AnalysisConfig::default(), + DiagramLevel::Module, + ) .unwrap(); assert!(result.graph.elements().is_empty()); @@ -128,12 +146,14 @@ fn warnings_from_source_analysis_are_propagated() { AnalysisResult::new( vec![], vec![], - vec![AnalysisWarning::new( - FilePath::new("src/broken.rs").unwrap(), - 5, - "unparseable block", - ) - .unwrap()], + vec![ + AnalysisWarning::new( + FilePath::new("src/broken.rs").unwrap(), + 5, + "unparseable block", + ) + .unwrap(), + ], ), ); @@ -144,7 +164,11 @@ fn warnings_from_source_analysis_are_propagated() { }; let result = use_case - .execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Module) + .execute( + Path::new("."), + &AnalysisConfig::default(), + DiagramLevel::Module, + ) .unwrap(); assert_eq!(result.warnings.len(), 1); diff --git a/crates/application/tests/check_freshness_tests.rs b/crates/application/tests/check_freshness_tests.rs index 08a1458..bb0598b 100644 --- a/crates/application/tests/check_freshness_tests.rs +++ b/crates/application/tests/check_freshness_tests.rs @@ -1,7 +1,7 @@ mod fakes; -use archlens_domain::{CodeGraph, NormalizedGraph, ports::DiagramRenderer}; use archlens_application::use_cases::check_freshness::CheckFreshness; +use archlens_domain::{CodeGraph, NormalizedGraph, ports::DiagramRenderer}; use fakes::FakeDiagramRenderer; fn empty_graph() -> NormalizedGraph { diff --git a/crates/application/tests/diff_diagram_tests.rs b/crates/application/tests/diff_diagram_tests.rs index 6aab1db..325dd02 100644 --- a/crates/application/tests/diff_diagram_tests.rs +++ b/crates/application/tests/diff_diagram_tests.rs @@ -1,7 +1,7 @@ mod fakes; -use archlens_domain::{CodeGraph, NormalizedGraph, ports::DiagramRenderer}; use archlens_application::use_cases::diff_diagram::DiffDiagram; +use archlens_domain::{CodeGraph, NormalizedGraph, ports::DiagramRenderer}; use fakes::FakeDiagramRenderer; fn empty_graph() -> NormalizedGraph { diff --git a/crates/application/tests/fakes/project_analyzer.rs b/crates/application/tests/fakes/project_analyzer.rs index 6c6ad8a..d060712 100644 --- a/crates/application/tests/fakes/project_analyzer.rs +++ b/crates/application/tests/fakes/project_analyzer.rs @@ -12,7 +12,9 @@ impl FakeProjectAnalyzer { } pub fn empty() -> Self { - Self { graph: CodeGraph::new() } + Self { + graph: CodeGraph::new(), + } } } diff --git a/crates/application/tests/generate_diagram_tests.rs b/crates/application/tests/generate_diagram_tests.rs index 9b5ff07..b18e90e 100644 --- a/crates/application/tests/generate_diagram_tests.rs +++ b/crates/application/tests/generate_diagram_tests.rs @@ -24,13 +24,15 @@ fn graph_with_one_module() -> NormalizedGraph { 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![ + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("/p/src/orders/order.rs").unwrap(), + 1, + ) + .unwrap(), + ], vec![], vec![], ), @@ -44,12 +46,22 @@ fn graph_with_one_module() -> NormalizedGraph { 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()); + 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()); diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 5fd7c04..fd213b7 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -3,9 +3,7 @@ mod cli; use anyhow::{Result, bail}; use archlens_application::use_cases::{ - build_code_graph::BuildCodeGraph, - check_freshness::CheckFreshness, - diff_diagram::DiffDiagram, + build_code_graph::BuildCodeGraph, check_freshness::CheckFreshness, diff_diagram::DiffDiagram, generate_diagram::GenerateDiagram, }; use archlens_ascii::AsciiRenderer; @@ -96,7 +94,11 @@ pub fn run(args: Cli) -> Result<()> { Ok(()) } -fn write_diagram_output(output: &RenderOutput, output_path: Option<&str>, split: bool) -> Result<()> { +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)?; diff --git a/docs/superpowers/plans/2026-06-17-deepening-interfaces.md b/docs/superpowers/plans/2026-06-17-deepening-interfaces.md new file mode 100644 index 0000000..23f507b --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-deepening-interfaces.md @@ -0,0 +1,1518 @@ +# 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" +``` diff --git a/docs/superpowers/specs/2026-06-17-deepening-interfaces-design.md b/docs/superpowers/specs/2026-06-17-deepening-interfaces-design.md new file mode 100644 index 0000000..383b843 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-deepening-interfaces-design.md @@ -0,0 +1,207 @@ +# Deepening Application and Adapter Interfaces + +**Date:** 2026-06-17 +**Status:** Approved +**Scope:** Four architectural deepening refactors across the application and adapter layers + +--- + +## Background + +Four architectural friction points identified via architecture review: + +1. I/O leaks across the seam into the application layer (`GenerateDiagram`, `CheckFreshness`, `DiffDiagram`) +2. Orchestration logic (`build_graph`) lives in the presentation layer instead of application +3. Renderer adapters duplicate import filtering and identifier sanitization +4. `LanguageExtractor` trait's single `analyze()` method gives no structure to implementors + +All four refactors deepen existing modules — shrinking their interfaces and concentrating behavior — without adding new capabilities. + +--- + +## Candidate 1: Pull I/O out of the application layer + +**Files:** `crates/application/src/use_cases/generate_diagram.rs`, `check_freshness.rs`, `diff_diagram.rs` + +### GenerateDiagram + +Remove `output_dir: Option` and `format_ext: String` fields. Remove `write_split`, `write_file_to_dir` free functions, and `check_violations_only()` method. + +`execute()` returns `Result`: + +```rust +pub struct GenerateDiagramResult { + pub violations: Vec, // domain type, not pre-formatted strings + pub output: RenderOutput, // caller writes; split mode produces N files +} +``` + +Split-by-module rendering stays inside `execute()`: it builds a `RenderOutput` with an overview file plus one file per module. Presentation receives the `RenderOutput` and hands it to `OutputWriter`. + +`check_violations_only()` is removed. Callers needing early-exit on violations (strict mode, watch mode) call `execute()` and inspect `result.violations`. + +### CheckFreshness + +```rust +// before +pub existing_path: &'a std::path::Path, + +// after +pub existing_content: &'a str, +``` + +`execute()` becomes a pure string comparison. Presentation reads the file with `std::fs::read_to_string()` before constructing the struct. + +### DiffDiagram + +Same pattern: `existing_path: &Path` → `existing_content: &str`. Presentation reads the file. + +### Presentation impact + +- Reads files before calling `CheckFreshness` / `DiffDiagram` +- After `GenerateDiagram::execute()`: inspects `result.violations` for strict-mode bail, prints violations, calls `output_writer.write(&result.output)` +- `write_split` and `write_file_to_dir` free functions move into presentation (or are inlined) + +--- + +## Candidate 2: Sink graph-building orchestration into the application layer + +**Files:** `crates/application/src/use_cases/build_code_graph.rs` (new), `crates/presentation/src/lib.rs` + +### New use case: BuildCodeGraph + +```rust +pub struct BuildCodeGraph { + pub discovery: F, + pub source_analyzer: S, + pub project_analyzer: Option>, +} + +pub struct BuildCodeGraphResult { + pub graph: NormalizedGraph, + pub warnings: Vec, +} + +impl BuildCodeGraph { + pub fn execute( + self, + root: &Path, + config: &AnalysisConfig, + level: DiagramLevel, + ) -> Result +} +``` + +Logic inside `execute()`: +- `DiagramLevel::Project` → call `project_analyzer.expect("project analyzer required for Project level").analyze(root)`, return via `NormalizedGraph::from_project()`, empty warnings +- `DiagramLevel::Module | Type` → call `AnalyzeCodebase::execute()`, collect warnings; at `Module` level, if `project_analyzer` is `Some`, merge its edges into the graph + +### Presentation impact + +`build_graph()` in `presentation/src/lib.rs` drops from ~70 lines to ~25: + +1. Apply CLI overrides to `AnalysisConfig` (scope, excludes, include_tests, changed_files) — stays in presentation, CLI-derived +2. Detect project analyzer: `if cargo_toml.exists() { Some(CargoWorkspaceAnalyzer) } else if pyproject.exists() { Some(PythonProjectAnalyzer) } else { None }` +3. Construct `BuildCodeGraph` and call `execute(root, config, level)` +4. Handle warnings (`eprintln`, strict bail) — stays in presentation + +--- + +## Candidate 3: Shared renderer primitives + +**Files:** `crates/adapters/rendering-primitives/src/lib.rs` (new crate), all four renderer adapter crates + +### New crate: rendering-primitives + +Depends only on `archlens_domain`. Exports two functions: + +```rust +/// Returns all relationships excluding Import kind. +pub fn non_import_rels(rels: &[Relationship]) -> impl Iterator { + rels.iter().filter(|r| r.kind() != RelationshipKind::Import) +} + +/// Sanitize an identifier by replacing common separator characters with underscore. +/// Handles `::`, `-`, `.`, and space. +pub fn sanitize_identifier(name: &str) -> String { + name.replace("::", "_").replace(['-', '.', ' '], "_") +} +``` + +Each of the four renderer adapter crates (`mermaid`, `d2`, `ascii`, `html-viewer`) adds `rendering-primitives` as a dependency. + +Mermaid's `sanitize_id` strips only `[-.]` (not `::`) — intentionally different because Mermaid handles qualified names via `display_name()`. It keeps its local variant; the shared `sanitize_identifier` is used by D2 and HTML. The universal win is `non_import_rels`, which replaces the identical filter expression in all four renderers. + +--- + +## Candidate 4: Typed LanguageExtractor pipeline + +**Files:** `crates/adapters/tree-sitter/src/language_extractor.rs`, `rust/mod.rs`, `python/mod.rs`, `tree_sitter_analyzer.rs` + +### Revised LanguageExtractor trait + +Replace the current single-method trait with a 3-method trait plus a `run_extraction` free function: + +```rust +pub trait LanguageExtractor { + fn tree_sitter_language(&self) -> tree_sitter::Language; + fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext); + fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext); + fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext); +} + +pub fn run_extraction( + extractor: &dyn LanguageExtractor, + source: &str, + file_path: &FilePath, +) -> Result +``` + +`run_extraction` owns: create parser, set language, parse, call all 3 methods in order on the root node, return `ctx.into_result()`. + +### Extractor changes + +`RustExtractor` and `PythonExtractor` implement the new trait. Existing free functions are reorganised into the 3 methods — no logic changes: +- `extract_types` → wraps `collect_types` +- `extract_relationships` → wraps `collect_relationships` +- `extract_imports` → wraps `collect_mod_declarations` + `collect_use_imports` + +`TreeSitterAnalyzer::analyze_file()` replaces `extractor.analyze(source, path)` with `run_extraction(extractor, source, path)`. + +Adding CSharp = implement 4 methods. No guesswork about what's required. + +--- + +## Execution order + +1. Candidate 1 — cleanest standalone win; establishes the pattern (pure use cases return data) +2. Candidate 2 — independent of Candidate 1 but benefits from the pattern being in place; `BuildCodeGraph` and `GenerateDiagram` are separate use cases with no shared code +3. Candidates 3 and 4 — independent of 1+2 and of each other; can run in parallel + +`FileOutputWriter` (Directory variant) already iterates over all `RenderedFile`s in a `RenderOutput`, so split-by-module output producing N files in one `RenderOutput` requires no changes to the writer. + +## TDD behaviors to verify (by candidate) + +### Candidate 1 +- `GenerateDiagram::execute()` returns `RenderOutput` with rendered content (no filesystem side effects) +- `GenerateDiagram::execute()` returns `Vec` when rules are violated +- `GenerateDiagram::execute()` with `split_by_module=true` returns `RenderOutput` with multiple files (overview + per-module) +- `CheckFreshness::execute()` returns `true` when rendered content equals `existing_content` +- `CheckFreshness::execute()` returns `false` when they differ +- `DiffDiagram::execute()` returns correct added/removed lines + +### Candidate 2 +- `BuildCodeGraph::execute()` at `Project` level delegates to `ProjectAnalyzer`, returns its graph +- `BuildCodeGraph::execute()` at `Module` level merges project edges when `project_analyzer` is `Some` +- `BuildCodeGraph::execute()` at `Type` level does not merge edges even when `project_analyzer` is `Some` +- `BuildCodeGraph::execute()` with `None` project analyzer at `Module` level skips merge cleanly +- `BuildCodeGraph::execute()` propagates `AnalysisWarning`s from `AnalyzeCodebase` + +### Candidate 3 +- `non_import_rels` excludes `Import` relationships, passes through `Inheritance` and `Composition` +- `sanitize_identifier` replaces `::`, `-`, `.`, space with `_` + +### Candidate 4 +- `run_extraction` calls `extract_types`, `extract_relationships`, `extract_imports` in order +- `RustExtractor` extracts struct/enum/trait types via `extract_types` +- `PythonExtractor` extracts classes via `extract_types`