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

46 KiB

Deepening Application and Adapter Interfaces — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Deepen four under-specified interfaces — pull I/O out of the application layer, sink graph-building orchestration into a new use case, share renderer primitives across adapters, and formalise the LanguageExtractor pipeline.

Architecture: Application use cases become pure (no std::fs); a new BuildCodeGraph use case absorbs orchestration logic from presentation; a new rendering-primitives crate eliminates cross-adapter duplication; the LanguageExtractor trait gains explicit three-phase structure so adding a new language has a clear template.

Tech Stack: Rust, Cargo workspace, archlens-domain types (NormalizedGraph, RuleViolation, RenderOutput, RenderedFile, BoundaryRule, ModuleName, CodeGraph), tree-sitter 0.24

Global Constraints

  • No unwrap() in production code; use ? or map_err
  • Test files live in tests/ per crate; one test file per unit under test
  • No new external dependencies beyond those already in workspace Cargo.toml
  • Run cargo test --workspace after every task; it must pass before committing
  • Commit message: imperative, ≤72 chars, no co-author line

File Map

Task 1 — GenerateDiagram pure

  • Modify: crates/application/src/use_cases/generate_diagram.rs
  • Create: crates/application/tests/generate_diagram_tests.rs
  • Modify: crates/presentation/src/lib.rs (callers: run(), run_watch())

Task 2 — CheckFreshness + DiffDiagram pure

  • Modify: crates/application/src/use_cases/check_freshness.rs
  • Modify: crates/application/src/use_cases/diff_diagram.rs
  • Create: crates/application/tests/check_freshness_tests.rs
  • Create: crates/application/tests/diff_diagram_tests.rs
  • Modify: crates/presentation/src/lib.rs (callers: run(), run_diff())

Task 3 — BuildCodeGraph use case

  • Create: crates/application/src/use_cases/build_code_graph.rs
  • Modify: crates/application/src/use_cases/mod.rs
  • Create: crates/application/tests/fakes/project_analyzer.rs
  • Modify: crates/application/tests/fakes/mod.rs
  • Create: crates/application/tests/build_code_graph_tests.rs
  • Modify: crates/presentation/src/lib.rs (replace build_graph())

Task 4 — rendering-primitives crate

  • Create: crates/adapters/rendering-primitives/Cargo.toml
  • Create: crates/adapters/rendering-primitives/src/lib.rs
  • Create: crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs
  • Modify: Cargo.toml (workspace members + workspace.dependencies)
  • Modify: crates/adapters/mermaid/Cargo.toml
  • Modify: crates/adapters/d2/Cargo.toml
  • Modify: crates/adapters/ascii/Cargo.toml
  • Modify: crates/adapters/html-viewer/Cargo.toml
  • Modify: crates/adapters/mermaid/src/mermaid_renderer.rs
  • Modify: crates/adapters/d2/src/d2_renderer.rs
  • Modify: crates/adapters/ascii/src/ascii_renderer.rs
  • Modify: crates/adapters/html-viewer/src/html_renderer.rs

Task 5 — Typed LanguageExtractor pipeline

  • Modify: crates/adapters/tree-sitter/src/language_extractor.rs
  • Modify: crates/adapters/tree-sitter/src/rust/mod.rs
  • Modify: crates/adapters/tree-sitter/src/python/mod.rs
  • Modify: crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs

Task 1: Make GenerateDiagram pure

Interfaces:

  • Removes: output_dir: Option<PathBuf>, format_ext: String fields from GenerateDiagram

  • Removes: check_violations_only() method, write_split free function

  • Changes: execute(self) -> Result<GenerateDiagramResult, DomainError> where GenerateDiagramResult { violations: Vec<RuleViolation>, output: RenderOutput }

  • Produces: presentation callers use result.violations and result.output directly

  • Step 1: Write the failing tests

Create crates/application/tests/generate_diagram_tests.rs:

mod fakes;

use std::path::Path;

use archlens_application::queries::AnalyzeCodebase;
use archlens_application::use_cases::generate_diagram::GenerateDiagram;
use archlens_domain::{
    AnalysisConfig, AnalysisResult, BoundaryRule, CodeElement, CodeElementKind, CodeGraph,
    FilePath, Language, ModuleName, NormalizedGraph, Relationship, RelationshipKind, SourceFile,
};

use fakes::{FakeDiagramRenderer, FakeFileDiscovery, FakeSourceAnalyzer};

fn empty_graph() -> NormalizedGraph {
    NormalizedGraph::from_project(CodeGraph::new())
}

fn graph_with_one_module() -> NormalizedGraph {
    let files = vec![SourceFile::new(
        FilePath::new("/p/src/orders/order.rs").unwrap(),
        Language::Rust,
    )];
    let discovery = FakeFileDiscovery::new(files);
    let analyzer = FakeSourceAnalyzer::new().with_result(
        "/p/src/orders/order.rs",
        AnalysisResult::new(
            vec![CodeElement::new(
                "Order",
                CodeElementKind::Struct,
                FilePath::new("/p/src/orders/order.rs").unwrap(),
                1,
            )
            .unwrap()],
            vec![],
            vec![],
        ),
    );
    AnalyzeCodebase::new(discovery, analyzer)
        .execute(Path::new("/p"), &AnalysisConfig::default())
        .unwrap()
        .graph()
        .clone()
}

fn graph_with_violation() -> NormalizedGraph {
    let mut cg = CodeGraph::new();
    let a = CodeElement::new("A", CodeElementKind::Struct, FilePath::new("a.rs").unwrap(), 1)
        .unwrap()
        .with_module(ModuleName::new("Alpha").unwrap());
    let b = CodeElement::new("B", CodeElementKind::Struct, FilePath::new("b.rs").unwrap(), 1)
        .unwrap()
        .with_module(ModuleName::new("Beta").unwrap());
    cg.add_element(a);
    cg.add_element(b);
    cg.add_relationship(Relationship::new("A", "B", RelationshipKind::Composition).unwrap());
    NormalizedGraph::from_project(cg)
}

#[test]
fn execute_returns_render_output_without_writing_files() {
    let use_case = GenerateDiagram {
        graph: empty_graph(),
        renderer: Box::new(FakeDiagramRenderer::new()),
        allow_rules: vec![],
        deny_rules: vec![],
        split_by_module: false,
    };
    let result = use_case.execute().unwrap();
    assert!(!result.output.files().is_empty());
}

#[test]
fn execute_returns_empty_violations_when_no_rules_set() {
    let use_case = GenerateDiagram {
        graph: empty_graph(),
        renderer: Box::new(FakeDiagramRenderer::new()),
        allow_rules: vec![],
        deny_rules: vec![],
        split_by_module: false,
    };
    let result = use_case.execute().unwrap();
    assert!(result.violations.is_empty());
}

#[test]
fn execute_returns_violations_as_rule_violation_type() {
    let deny = vec![BoundaryRule::parse("Alpha --> Beta").unwrap()];
    let use_case = GenerateDiagram {
        graph: graph_with_violation(),
        renderer: Box::new(FakeDiagramRenderer::new()),
        allow_rules: vec![],
        deny_rules: deny,
        split_by_module: false,
    };
    let result = use_case.execute().unwrap();
    assert_eq!(result.violations.len(), 1);
    assert!(result.violations[0].message().contains("Alpha"));
    assert!(result.violations[0].message().contains("Beta"));
}

#[test]
fn split_by_module_returns_multiple_rendered_files() {
    let use_case = GenerateDiagram {
        graph: graph_with_one_module(),
        renderer: Box::new(FakeDiagramRenderer::new()),
        allow_rules: vec![],
        deny_rules: vec![],
        split_by_module: true,
    };
    let result = use_case.execute().unwrap();
    // overview + at least one module file
    assert!(result.output.files().len() >= 2);
}
  • Step 2: Run tests — verify they fail
cargo test --package archlens-application generate_diagram_tests

Expected: compile error — GenerateDiagram still has output_dir, format_ext fields and execute() returns Result<(), DomainError>.

  • Step 3: Rewrite generate_diagram.rs

Replace the entire contents of crates/application/src/use_cases/generate_diagram.rs:

use archlens_domain::{
    BoundaryRule, DomainError, NormalizedGraph, RenderOutput, RenderedFile, RuleViolation,
    check_boundary_rules, ports::DiagramRenderer,
};

pub struct GenerateDiagramResult {
    pub violations: Vec<RuleViolation>,
    pub output: RenderOutput,
}

pub struct GenerateDiagram {
    pub graph: NormalizedGraph,
    pub renderer: Box<dyn DiagramRenderer>,
    pub allow_rules: Vec<BoundaryRule>,
    pub deny_rules: Vec<BoundaryRule>,
    pub split_by_module: bool,
}

impl GenerateDiagram {
    pub fn execute(self) -> Result<GenerateDiagramResult, DomainError> {
        let violations = if !self.allow_rules.is_empty() || !self.deny_rules.is_empty() {
            check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules)
        } else {
            Vec::new()
        };

        let output = if self.split_by_module {
            render_split(&self.graph, &*self.renderer)?
        } else {
            self.renderer.render(self.graph.as_graph())?
        };

        Ok(GenerateDiagramResult { violations, output })
    }
}

fn render_split(
    graph: &NormalizedGraph,
    renderer: &dyn DiagramRenderer,
) -> Result<RenderOutput, DomainError> {
    let mut files = Vec::new();

    let overview = renderer.render(graph.as_graph())?;
    let ext = overview
        .files()
        .first()
        .and_then(|f| std::path::Path::new(f.name()).extension())
        .and_then(|e| e.to_str())
        .unwrap_or("txt");

    if let Some(f) = overview.files().first() {
        files.push(RenderedFile::new(&format!("overview.{ext}"), f.content())?);
    }

    for module in graph.modules() {
        let subgraph = graph.subgraph_by_module(&module);
        let cross_deps = graph.cross_module_deps_for(&module);
        let module_output = renderer.render_for_module(&subgraph, &module, &cross_deps)?;
        if let Some(f) = module_output.files().first() {
            files.push(RenderedFile::new(
                &format!("{}.{ext}", module.as_str().to_lowercase()),
                f.content(),
            )?);
        }
    }

    Ok(RenderOutput::new(files))
}
  • Step 4: Update presentation/src/lib.rs

Remove the write_split import. Replace the run() body section from "let use_case = GenerateDiagram {" through "use_case.execute()?;" with:

    let use_case = GenerateDiagram {
        graph,
        renderer,
        allow_rules: allow,
        deny_rules: deny,
        split_by_module: args.split_by_module,
    };

    let result = use_case.execute()?;
    if args.strict && !result.violations.is_empty() {
        bail!(
            "{} boundary rule violation(s) in strict mode",
            result.violations.len()
        );
    }
    for v in &result.violations {
        eprintln!("RULE VIOLATION: {}", v.message());
    }
    write_diagram_output(&result.output, args.output.as_deref(), args.split_by_module)?;

Replace the run_watch() closure body with:

    let run_once = |args: &Cli| -> Result<()> {
        let config_loader = load_config(args)?;
        let graph = build_graph(args, level)?;
        let renderer = create_renderer(&args.format, level, !args.no_weights)?;

        let (raw_allow, raw_deny) = config_loader.load_rules();
        let allow: Vec<BoundaryRule> = raw_allow
            .iter()
            .filter_map(|s| BoundaryRule::parse(s))
            .collect();
        let deny: Vec<BoundaryRule> = raw_deny
            .iter()
            .filter_map(|s| BoundaryRule::parse(s))
            .collect();

        let use_case = GenerateDiagram {
            graph,
            renderer,
            allow_rules: allow,
            deny_rules: deny,
            split_by_module: args.split_by_module,
        };
        let result = use_case.execute()?;
        write_diagram_output(&result.output, args.output.as_deref(), args.split_by_module)?;
        for v in &result.violations {
            eprintln!("RULE VIOLATION: {}", v.message());
        }
        Ok(())
    };

Add this helper to presentation/src/lib.rs (before run()):

fn write_diagram_output(output: &RenderOutput, output_path: Option<&str>, split: bool) -> Result<()> {
    if split {
        let dir = std::path::PathBuf::from(output_path.unwrap_or("."));
        std::fs::create_dir_all(&dir)?;
        for file in output.files() {
            std::fs::write(dir.join(file.name()), file.content())?;
        }
    } else if let Some(path) = output_path {
        let p = std::path::Path::new(path);
        if let Some(parent) = p.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let content = output.files().first().map(|f| f.content()).unwrap_or("");
        std::fs::write(p, content)?;
    } else {
        let content = output.files().first().map(|f| f.content()).unwrap_or("");
        print!("{content}");
    }
    Ok(())
}

Update the presentation's import block:

  • Remove write_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

cargo test --workspace

Expected: all tests pass, including the four new generate_diagram_tests tests.

  • Step 6: Commit
git add crates/application/src/use_cases/generate_diagram.rs \
        crates/application/tests/generate_diagram_tests.rs \
        crates/presentation/src/lib.rs
git commit -m "refactor: make GenerateDiagram pure — return RenderOutput instead of writing files"

Task 2: Make CheckFreshness and DiffDiagram pure

Interfaces:

  • CheckFreshness: existing_path: &'a Pathexisting_content: &'a str

  • DiffDiagram: existing_path: &'a Pathexisting_content: &'a str

  • Presentation reads file content before constructing either struct

  • Step 1: Write the failing tests

Create crates/application/tests/check_freshness_tests.rs:

mod fakes;

use archlens_domain::{CodeGraph, NormalizedGraph};
use archlens_application::use_cases::check_freshness::CheckFreshness;
use fakes::FakeDiagramRenderer;

fn empty_graph() -> NormalizedGraph {
    NormalizedGraph::from_project(CodeGraph::new())
}

#[test]
fn returns_true_when_content_matches() {
    let graph = empty_graph();
    let renderer = FakeDiagramRenderer::new();
    let rendered = renderer.render(graph.as_graph()).unwrap();
    let existing = rendered.files().first().unwrap().content().to_string();

    let result = CheckFreshness {
        graph: &graph,
        renderer: &renderer,
        existing_content: &existing,
    }
    .execute()
    .unwrap();

    assert!(result);
}

#[test]
fn returns_false_when_content_differs() {
    let graph = empty_graph();
    let renderer = FakeDiagramRenderer::new();

    let result = CheckFreshness {
        graph: &graph,
        renderer: &renderer,
        existing_content: "stale content that does not match",
    }
    .execute()
    .unwrap();

    assert!(!result);
}

Create crates/application/tests/diff_diagram_tests.rs:

mod fakes;

use archlens_domain::{CodeGraph, NormalizedGraph};
use archlens_application::use_cases::diff_diagram::DiffDiagram;
use fakes::FakeDiagramRenderer;

fn empty_graph() -> NormalizedGraph {
    NormalizedGraph::from_project(CodeGraph::new())
}

#[test]
fn no_diff_when_content_matches() {
    let graph = empty_graph();
    let renderer = FakeDiagramRenderer::new();
    let rendered = renderer.render(graph.as_graph()).unwrap();
    let existing = rendered.files().first().unwrap().content().to_string();

    let result = DiffDiagram {
        graph: &graph,
        renderer: &renderer,
        existing_content: &existing,
    }
    .execute()
    .unwrap();

    assert!(result.is_empty());
}

#[test]
fn detects_added_lines() {
    let graph = empty_graph();
    let renderer = FakeDiagramRenderer::new();

    let result = DiffDiagram {
        graph: &graph,
        renderer: &renderer,
        existing_content: "old line that will be removed",
    }
    .execute()
    .unwrap();

    assert!(!result.added.is_empty());
    assert!(!result.removed.is_empty());
}
  • Step 2: Run tests — verify they fail
cargo test --package archlens-application check_freshness_tests diff_diagram_tests

Expected: compile errors — structs still have existing_path: &Path.

  • Step 3: Rewrite check_freshness.rs

Replace crates/application/src/use_cases/check_freshness.rs:

use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};

pub struct CheckFreshness<'a> {
    pub graph: &'a NormalizedGraph,
    pub renderer: &'a dyn DiagramRenderer,
    pub existing_content: &'a str,
}

impl<'a> CheckFreshness<'a> {
    pub fn execute(&self) -> Result<bool, DomainError> {
        let rendered = self.renderer.render(self.graph.as_graph())?;
        let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
        Ok(current == self.existing_content)
    }
}
  • Step 4: Rewrite diff_diagram.rs

Replace crates/application/src/use_cases/diff_diagram.rs:

use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};

pub struct DiffResult {
    pub added: Vec<String>,
    pub removed: Vec<String>,
}

impl DiffResult {
    pub fn is_empty(&self) -> bool {
        self.added.is_empty() && self.removed.is_empty()
    }
}

pub struct DiffDiagram<'a> {
    pub graph: &'a NormalizedGraph,
    pub renderer: &'a dyn DiagramRenderer,
    pub existing_content: &'a str,
}

impl<'a> DiffDiagram<'a> {
    pub fn execute(&self) -> Result<DiffResult, DomainError> {
        let rendered = self.renderer.render(self.graph.as_graph())?;
        let current = rendered.files().first().map(|f| f.content()).unwrap_or("");

        let current_lines: std::collections::HashSet<&str> = current.lines().collect();
        let existing_lines: std::collections::HashSet<&str> = self.existing_content.lines().collect();

        let added: Vec<String> = current_lines
            .difference(&existing_lines)
            .filter(|l| !l.trim().is_empty())
            .map(|l| format!("+ {l}"))
            .collect();

        let removed: Vec<String> = existing_lines
            .difference(&current_lines)
            .filter(|l| !l.trim().is_empty())
            .map(|l| format!("- {l}"))
            .collect();

        Ok(DiffResult { added, removed })
    }
}
  • Step 5: Update presentation/src/lib.rs callers

In run(), replace the CheckFreshness block:

    // before
    let up_to_date = CheckFreshness {
        graph: &graph,
        renderer: &*renderer,
        existing_path: std::path::Path::new(existing_path),
    }
    .execute()?;

    // after
    let existing_content = std::fs::read_to_string(existing_path)
        .map_err(|e| anyhow::anyhow!("cannot read {existing_path}: {e}"))?;
    let up_to_date = CheckFreshness {
        graph: &graph,
        renderer: &*renderer,
        existing_content: &existing_content,
    }
    .execute()?;

In run_diff(), replace the DiffDiagram block:

    // before
    let diff = DiffDiagram {
        graph: &graph,
        renderer: &*renderer,
        existing_path,
    }
    .execute()?;

    // after
    let existing_content = std::fs::read_to_string(existing_path)
        .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", existing_path.display()))?;
    let diff = DiffDiagram {
        graph: &graph,
        renderer: &*renderer,
        existing_content: &existing_content,
    }
    .execute()?;
  • Step 6: Run tests — verify they pass
cargo test --workspace

Expected: all tests pass.

  • Step 7: Commit
git add crates/application/src/use_cases/check_freshness.rs \
        crates/application/src/use_cases/diff_diagram.rs \
        crates/application/tests/check_freshness_tests.rs \
        crates/application/tests/diff_diagram_tests.rs \
        crates/presentation/src/lib.rs
git commit -m "refactor: CheckFreshness and DiffDiagram take &str instead of &Path"

Task 3: BuildCodeGraph use case

Interfaces:

  • Consumes: FakeFileDiscovery, FakeSourceAnalyzer, NormalizedGraph, AnalysisConfig, DiagramLevel, ProjectAnalyzer port from domain

  • Produces: BuildCodeGraph<F, S> struct with execute(self, root, config, level) -> Result<BuildCodeGraphResult, DomainError> where BuildCodeGraphResult { graph: NormalizedGraph, warnings: Vec<AnalysisWarning> }

  • Step 1: Create FakeProjectAnalyzer

Create crates/application/tests/fakes/project_analyzer.rs:

use std::path::Path;

use archlens_domain::{CodeGraph, DomainError, ports::ProjectAnalyzer};

pub struct FakeProjectAnalyzer {
    graph: CodeGraph,
}

impl FakeProjectAnalyzer {
    pub fn new(graph: CodeGraph) -> Self {
        Self { graph }
    }

    pub fn empty() -> Self {
        Self { graph: CodeGraph::new() }
    }
}

impl ProjectAnalyzer for FakeProjectAnalyzer {
    fn analyze(&self, _root: &Path) -> Result<CodeGraph, DomainError> {
        Ok(self.graph.clone())
    }
}

Add to crates/application/tests/fakes/mod.rs:

mod project_analyzer;
pub use project_analyzer::FakeProjectAnalyzer;
  • Step 2: Write the failing tests

Create crates/application/tests/build_code_graph_tests.rs:

mod fakes;

use std::path::Path;

use archlens_application::use_cases::build_code_graph::BuildCodeGraph;
use archlens_domain::{
    AnalysisConfig, AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, CodeGraph,
    DiagramLevel, FilePath, Language, SourceFile,
};

use fakes::{FakeFileDiscovery, FakeProjectAnalyzer, FakeSourceAnalyzer};

#[test]
fn project_level_returns_project_analyzer_graph() {
    let mut cg = CodeGraph::new();
    cg.add_element(
        CodeElement::new(
            "MyApp",
            CodeElementKind::Project,
            FilePath::new("Cargo.toml").unwrap(),
            1,
        )
        .unwrap(),
    );

    let use_case = BuildCodeGraph {
        discovery: FakeFileDiscovery::empty(),
        source_analyzer: FakeSourceAnalyzer::new(),
        project_analyzer: Some(Box::new(FakeProjectAnalyzer::new(cg))),
    };

    let result = use_case
        .execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Project)
        .unwrap();

    assert_eq!(result.graph.elements().len(), 1);
    assert_eq!(result.graph.elements()[0].name(), "MyApp");
    assert!(result.warnings.is_empty());
}

#[test]
fn project_level_without_analyzer_returns_error() {
    let use_case = BuildCodeGraph {
        discovery: FakeFileDiscovery::empty(),
        source_analyzer: FakeSourceAnalyzer::new(),
        project_analyzer: None,
    };

    let err = use_case
        .execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Project)
        .unwrap_err();

    assert!(err.to_string().contains("no project analyzer"));
}

#[test]
fn type_level_uses_source_analyzer_not_project() {
    let files = vec![SourceFile::new(
        FilePath::new("src/order.rs").unwrap(),
        Language::Rust,
    )];
    let discovery = FakeFileDiscovery::new(files);
    let analyzer = FakeSourceAnalyzer::new().with_result(
        "src/order.rs",
        AnalysisResult::new(
            vec![CodeElement::new(
                "Order",
                CodeElementKind::Struct,
                FilePath::new("src/order.rs").unwrap(),
                1,
            )
            .unwrap()],
            vec![],
            vec![],
        ),
    );

    let mut project_cg = CodeGraph::new();
    project_cg.add_element(
        CodeElement::new(
            "ProjectElement",
            CodeElementKind::Project,
            FilePath::new("Cargo.toml").unwrap(),
            1,
        )
        .unwrap(),
    );

    let use_case = BuildCodeGraph {
        discovery,
        source_analyzer: analyzer,
        project_analyzer: Some(Box::new(FakeProjectAnalyzer::new(project_cg))),
    };

    let result = use_case
        .execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Type)
        .unwrap();

    // Source element present, project element NOT merged (Type level skips merge)
    assert_eq!(result.graph.elements().len(), 1);
    assert_eq!(result.graph.elements()[0].name(), "Order");
}

#[test]
fn module_level_without_project_analyzer_succeeds() {
    let use_case = BuildCodeGraph {
        discovery: FakeFileDiscovery::empty(),
        source_analyzer: FakeSourceAnalyzer::new(),
        project_analyzer: None,
    };

    let result = use_case
        .execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Module)
        .unwrap();

    assert!(result.graph.elements().is_empty());
}

#[test]
fn warnings_from_source_analysis_are_propagated() {
    let files = vec![SourceFile::new(
        FilePath::new("src/broken.rs").unwrap(),
        Language::Rust,
    )];
    let discovery = FakeFileDiscovery::new(files);
    let analyzer = FakeSourceAnalyzer::new().with_result(
        "src/broken.rs",
        AnalysisResult::new(
            vec![],
            vec![],
            vec![AnalysisWarning::new(
                FilePath::new("src/broken.rs").unwrap(),
                5,
                "unparseable block",
            )
            .unwrap()],
        ),
    );

    let use_case = BuildCodeGraph {
        discovery,
        source_analyzer: analyzer,
        project_analyzer: None,
    };

    let result = use_case
        .execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Module)
        .unwrap();

    assert_eq!(result.warnings.len(), 1);
    assert_eq!(result.warnings[0].message(), "unparseable block");
}
  • Step 3: Run tests — verify they fail
cargo test --package archlens-application build_code_graph_tests

Expected: compile error — BuildCodeGraph does not exist.

  • Step 4: Create build_code_graph.rs

Create crates/application/src/use_cases/build_code_graph.rs:

use std::path::Path;

use archlens_domain::{
    AnalysisConfig, AnalysisWarning, DiagramLevel, DomainError, NormalizedGraph,
    ports::{FileDiscovery, ProjectAnalyzer, SourceAnalyzer},
};

use crate::queries::AnalyzeCodebase;

pub struct BuildCodeGraphResult {
    pub graph: NormalizedGraph,
    pub warnings: Vec<AnalysisWarning>,
}

pub struct BuildCodeGraph<F, S>
where
    F: FileDiscovery + Send + Sync,
    S: SourceAnalyzer,
{
    pub discovery: F,
    pub source_analyzer: S,
    pub project_analyzer: Option<Box<dyn ProjectAnalyzer>>,
}

impl<F, S> BuildCodeGraph<F, S>
where
    F: FileDiscovery + Send + Sync,
    S: SourceAnalyzer,
{
    pub fn execute(
        self,
        root: &Path,
        config: &AnalysisConfig,
        level: DiagramLevel,
    ) -> Result<BuildCodeGraphResult, DomainError> {
        match level {
            DiagramLevel::Project => {
                let pa = self.project_analyzer.ok_or_else(|| {
                    DomainError::AnalysisError(
                        "no project analyzer available for Project level".into(),
                    )
                })?;
                let cg = pa.analyze(root)?;
                Ok(BuildCodeGraphResult {
                    graph: NormalizedGraph::from_project(cg),
                    warnings: Vec::new(),
                })
            }
            DiagramLevel::Module | DiagramLevel::Type => {
                let analyze = AnalyzeCodebase::new(self.discovery, self.source_analyzer);
                let result = analyze.execute(root, config)?;
                let mut graph = result.graph().clone();
                if level == DiagramLevel::Module {
                    if let Some(pa) = self.project_analyzer {
                        let project_cg = pa.analyze(root)?;
                        graph.merge_project_edges(&project_cg);
                    }
                }
                Ok(BuildCodeGraphResult {
                    graph,
                    warnings: result.warnings().to_vec(),
                })
            }
        }
    }
}
  • Step 5: Export from use_cases/mod.rs

Add to crates/application/src/use_cases/mod.rs:

pub mod build_code_graph;
pub mod check_freshness;
pub mod diff_diagram;
pub mod generate_diagram;
  • Step 6: Run tests — verify they pass
cargo test --package archlens-application build_code_graph_tests

Expected: all 5 tests pass.

  • Step 7: Update presentation/src/lib.rs — 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:

fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
    let config_loader = load_config(args)?;
    let mut analysis_config = config_loader.load_analysis_config()?;
    analysis_config = analysis_config.with_level(level);

    if let Some(ref scope) = args.scope {
        analysis_config = analysis_config.with_scope(scope.clone());
    }
    if !args.exclude.is_empty() {
        let mut excludes = analysis_config.excludes().to_vec();
        excludes.extend(args.exclude.iter().cloned());
        analysis_config = analysis_config.with_excludes(excludes);
    }
    if args.include_tests {
        analysis_config = analysis_config.with_include_tests(true);
    }
    if let Some(ref git_ref) = args.since {
        let changed = get_changed_files(&args.path, git_ref)?;
        analysis_config = analysis_config.with_changed_files(changed);
    }

    // ProjectAnalyzer is already imported in presentation via `use archlens_domain::ports::{..., ProjectAnalyzer}`
    let project_analyzer: Option<Box<dyn ProjectAnalyzer>> = {
        let cargo_toml = args.path.join("Cargo.toml");
        let pyproject = args.path.join("pyproject.toml");
        if cargo_toml.exists() {
            Some(Box::new(CargoWorkspaceAnalyzer::new()))
        } else if pyproject.exists() {
            Some(Box::new(PythonProjectAnalyzer::new()))
        } else {
            None
        }
    };

    let use_case = BuildCodeGraph {
        discovery: WalkdirDiscovery::new(),
        source_analyzer: TreeSitterAnalyzer::new(),
        project_analyzer,
    };

    let result = use_case.execute(&args.path, &analysis_config, level)?;

    if !result.warnings.is_empty() {
        for warning in &result.warnings {
            eprintln!(
                "WARNING: {}:{} {}",
                warning.file_path().as_str(),
                warning.line(),
                warning.message()
            );
        }
        if args.strict {
            bail!(
                "analysis produced {} warning(s) in strict mode",
                result.warnings.len()
            );
        }
    }

    Ok(result.graph)
}
  • Step 8: Run all tests
cargo test --workspace

Expected: all tests pass.

  • Step 9: Commit
git add crates/application/src/use_cases/build_code_graph.rs \
        crates/application/src/use_cases/mod.rs \
        crates/application/tests/fakes/project_analyzer.rs \
        crates/application/tests/fakes/mod.rs \
        crates/application/tests/build_code_graph_tests.rs \
        crates/presentation/src/lib.rs
git commit -m "feat: add BuildCodeGraph use case, sink orchestration out of presentation"

Task 4: rendering-primitives crate

Interfaces:

  • Produces: archlens-rendering-primitives crate with non_import_rels(rels: &[Relationship]) -> impl Iterator<Item = &Relationship> and sanitize_identifier(name: &str) -> String

  • Consumes: archlens-domain types (Relationship, RelationshipKind)

  • Step 1: Write the failing tests

Create crates/adapters/rendering-primitives/tests/rendering_primitives_tests.rs (create parent dirs first):

use archlens_domain::{
    FilePath, Relationship, RelationshipKind,
};
use archlens_rendering_primitives::{non_import_rels, sanitize_identifier};

fn rel(src: &str, tgt: &str, kind: RelationshipKind) -> Relationship {
    Relationship::new(src, tgt, kind).unwrap()
}

#[test]
fn non_import_rels_excludes_import_relationships() {
    let rels = vec![
        rel("A", "B", RelationshipKind::Composition),
        rel("C", "D", RelationshipKind::Import),
        rel("E", "F", RelationshipKind::Inheritance),
    ];
    let filtered: Vec<_> = non_import_rels(&rels).collect();
    assert_eq!(filtered.len(), 2);
    assert!(filtered.iter().all(|r| r.kind() != RelationshipKind::Import));
}

#[test]
fn non_import_rels_passes_all_non_import_kinds() {
    let rels = vec![
        rel("A", "B", RelationshipKind::Composition),
        rel("C", "D", RelationshipKind::Inheritance),
    ];
    let filtered: Vec<_> = non_import_rels(&rels).collect();
    assert_eq!(filtered.len(), 2);
}

#[test]
fn sanitize_identifier_replaces_double_colon_with_underscore() {
    assert_eq!(sanitize_identifier("foo::bar"), "foo_bar");
}

#[test]
fn sanitize_identifier_replaces_hyphen_with_underscore() {
    assert_eq!(sanitize_identifier("my-crate"), "my_crate");
}

#[test]
fn sanitize_identifier_replaces_dot_with_underscore() {
    assert_eq!(sanitize_identifier("v1.2"), "v1_2");
}

#[test]
fn sanitize_identifier_replaces_space_with_underscore() {
    assert_eq!(sanitize_identifier("my crate"), "my_crate");
}
  • Step 2: Run tests — verify they fail
cargo test --package archlens-rendering-primitives 2>&1 | head -20

Expected: error — crate doesn't exist yet.

  • Step 3: Create the crate

Create crates/adapters/rendering-primitives/Cargo.toml:

[package]
name = "archlens-rendering-primitives"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
archlens-domain.workspace = true

Create crates/adapters/rendering-primitives/src/lib.rs:

use archlens_domain::{Relationship, RelationshipKind};

/// Returns an iterator over all relationships except those with kind `Import`.
pub fn non_import_rels(rels: &[Relationship]) -> impl Iterator<Item = &Relationship> {
    rels.iter().filter(|r| r.kind() != RelationshipKind::Import)
}

/// Replaces `::`, `-`, `.`, and space with `_`.
pub fn sanitize_identifier(name: &str) -> String {
    name.replace("::", "_").replace(['-', '.', ' '], "_")
}
  • Step 4: Register crate in workspace

In the root Cargo.toml, add to [workspace] members:

"crates/adapters/rendering-primitives",

Add to [workspace.dependencies]:

archlens-rendering-primitives = { path = "crates/adapters/rendering-primitives" }
  • Step 5: Run tests — verify they pass
cargo test --package archlens-rendering-primitives

Expected: all 6 tests pass.

  • Step 6: Wire non_import_rels into all four renderer adapters

Add to each renderer's Cargo.toml under [dependencies]:

archlens-rendering-primitives.workspace = true

Files to update: crates/adapters/mermaid/Cargo.toml, crates/adapters/d2/Cargo.toml, crates/adapters/ascii/Cargo.toml, crates/adapters/html-viewer/Cargo.toml.

In crates/adapters/mermaid/src/mermaid_renderer.rs, add import and replace import filter in render_class_diagram:

// add at top
use archlens_rendering_primitives::non_import_rels;

// replace in render_class_diagram()
// before:
for rel in graph.relationships() {
    if rel.kind() == RelationshipKind::Import {
        continue;
    }

// after:
for rel in non_import_rels(graph.relationships()) {

Also remove the RelationshipKind::Import arm from the arrow match since Import is now filtered:

// before:
let arrow = match rel.kind() {
    RelationshipKind::Inheritance => "<|--",
    RelationshipKind::Composition => "-->",
    RelationshipKind::Import => "..>",
};

// after:
let arrow = match rel.kind() {
    RelationshipKind::Inheritance => "<|--",
    RelationshipKind::Composition => "-->",
    RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
};

In crates/adapters/d2/src/d2_renderer.rs, add import and replace filter + use sanitize_identifier:

// add at top
use archlens_rendering_primitives::{non_import_rels, sanitize_identifier};

// remove local fn sanitize() entirely

// replace all calls to sanitize() with sanitize_identifier()

// in render_type(), replace:
for rel in graph.relationships() {
    use archlens_domain::RelationshipKind;
    let src = sanitize(rel.source());
    let tgt = sanitize(rel.target());
    let arrow = match rel.kind() {
        RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"),
        RelationshipKind::Composition => format!("{src} -> {tgt}"),
        RelationshipKind::Import => continue,
    };
    lines.push(arrow);
}

// with:
for rel in non_import_rels(graph.relationships()) {
    use archlens_domain::RelationshipKind;
    let src = sanitize_identifier(rel.source());
    let tgt = sanitize_identifier(rel.target());
    let arrow = match rel.kind() {
        RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"),
        RelationshipKind::Composition => format!("{src} -> {tgt}"),
        RelationshipKind::Import => unreachable!("imports filtered by non_import_rels"),
    };
    lines.push(arrow);
}

Apply the same sanitizesanitize_identifier rename throughout d2_renderer.rs (also in render_project and render_module).

In crates/adapters/ascii/src/ascii_renderer.rs, add import and replace filter:

// add at top
use archlens_rendering_primitives::non_import_rels;

// replace the non_import_rels filter block (~line 97):
// before:
let non_import_rels: Vec<_> = graph
    .relationships()
    .iter()
    .filter(|r| r.kind() != RelationshipKind::Import)
    .collect();

// after:
let filtered_rels: Vec<_> = non_import_rels(graph.relationships()).collect();

Update subsequent usages of non_import_rels variable to filtered_rels in ascii_renderer.rs.

Also replace the import filter for the imports section:

// before:
let import_rels: Vec<_> = graph
    .relationships()
    .iter()
    .filter(|r| r.kind() == RelationshipKind::Import)
    .collect();

// after (keep as-is — this is the opposite filter, not shared):
let import_rels: Vec<_> = graph
    .relationships()
    .iter()
    .filter(|r| r.kind() == RelationshipKind::Import)
    .collect();

In crates/adapters/html-viewer/src/html_renderer.rs, add import and replace filter:

// add at top
use archlens_rendering_primitives::non_import_rels;

// replace:
let edges = graph
    .relationships()
    .iter()
    .filter(|r| r.kind() != RelationshipKind::Import)
    .filter_map(|r| { ... })
    .collect();

// with:
let edges = non_import_rels(graph.relationships())
    .filter_map(|r| { ... })
    .collect();

Remove use archlens_domain::RelationshipKind; from html_renderer.rs if it's no longer needed.

  • Step 7: Run all tests
cargo test --workspace

Expected: all tests pass, including existing renderer tests.

  • Step 8: Commit
git add Cargo.toml \
        crates/adapters/rendering-primitives/ \
        crates/adapters/mermaid/Cargo.toml \
        crates/adapters/mermaid/src/mermaid_renderer.rs \
        crates/adapters/d2/Cargo.toml \
        crates/adapters/d2/src/d2_renderer.rs \
        crates/adapters/ascii/Cargo.toml \
        crates/adapters/ascii/src/ascii_renderer.rs \
        crates/adapters/html-viewer/Cargo.toml \
        crates/adapters/html-viewer/src/html_renderer.rs
git commit -m "feat: add rendering-primitives crate, share non_import_rels across renderers"

Task 5: Typed LanguageExtractor pipeline

Interfaces:

  • Replaces: trait LanguageExtractor { fn analyze(...) } with a 4-method trait + run_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

cargo test --package archlens-tree-sitter

Expected: all existing tests pass. Note the count — they must all still pass after the refactor.

  • Step 2: Replace language_extractor.rs

Replace crates/adapters/tree-sitter/src/language_extractor.rs:

use tree_sitter::{Node, Parser};

use archlens_domain::{AnalysisResult, DomainError, FilePath};

use crate::extraction_context::ExtractionContext;

pub trait LanguageExtractor {
    fn tree_sitter_language(&self) -> tree_sitter::Language;

    fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);

    fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);

    fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
}

pub fn run_extraction(
    extractor: &dyn LanguageExtractor,
    source: &str,
    file_path: &FilePath,
) -> Result<AnalysisResult, DomainError> {
    let mut parser = Parser::new();
    parser
        .set_language(&extractor.tree_sitter_language().into())
        .map_err(|e| DomainError::AnalysisError(e.to_string()))?;

    let tree = parser
        .parse(source, None)
        .ok_or_else(|| DomainError::AnalysisError("failed to parse source".into()))?;

    let mut ctx = ExtractionContext::new(file_path.clone());
    let root = tree.root_node();

    extractor.extract_types(&root, source, &mut ctx);
    extractor.extract_relationships(&root, source, &mut ctx);
    extractor.extract_imports(&root, source, &mut ctx);

    ctx.into_result()
}
  • Step 3: Update RustExtractor to implement the new trait

In crates/adapters/tree-sitter/src/rust/mod.rs, replace the LanguageExtractor impl block for RustExtractor:

// remove the old impl:
// impl LanguageExtractor for RustExtractor {
//     fn analyze(&self, source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
//         analyze(source, file_path)
//     }
// }

// replace with:
impl LanguageExtractor for RustExtractor {
    fn tree_sitter_language(&self) -> tree_sitter::Language {
        tree_sitter_rust::LANGUAGE.into()
    }

    fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
        collect_types(root, source, ctx);
    }

    fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
        collect_relationships(root, source, ctx);
    }

    fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
        collect_mod_declarations(root, source, ctx);
        collect_use_imports(root, source, ctx);
    }
}

Remove the pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> free function (it's replaced by run_extraction).

Update imports at the top of rust/mod.rs — add Node from tree_sitter if not already imported:

use tree_sitter::{Node, Parser};

Remove the use archlens_domain::AnalysisResult; import if it's no longer used directly.

  • Step 4: Update PythonExtractor to implement the new trait

In crates/adapters/tree-sitter/src/python/mod.rs, replace the LanguageExtractor impl:

impl LanguageExtractor for PythonExtractor {
    fn tree_sitter_language(&self) -> tree_sitter::Language {
        tree_sitter_python::LANGUAGE.into()
    }

    fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
        // collect_classes handles class elements, inheritance, and field compositions
        // in a single pass — Python's relationship extraction is interleaved with type extraction
        collect_classes(root, source, ctx);
    }

    fn extract_relationships(&self, _root: &Node, _source: &str, _ctx: &mut ExtractionContext) {
        // Relationships are collected inside collect_classes for Python
    }

    fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext) {
        collect_imports(root, source, ctx);
    }
}

Remove the old fn analyze(...) free function. Node is already imported at the top of python/mod.rs.

  • Step 5: Update TreeSitterAnalyzer

In crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs, replace the analyze_file body:

// add import at top
use crate::language_extractor::run_extraction;

// replace:
impl SourceAnalyzer for TreeSitterAnalyzer {
    fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError> {
        let source = std::fs::read_to_string(file.path().as_str())
            .map_err(|e| DomainError::IoError(e.to_string()))?;

        match self.extractor_for(file.language()) {
            Some(extractor) => extractor.analyze(&source, file.path()),
            None => Ok(AnalysisResult::empty()),
        }
    }
}

// with:
impl SourceAnalyzer for TreeSitterAnalyzer {
    fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError> {
        let source = std::fs::read_to_string(file.path().as_str())
            .map_err(|e| DomainError::IoError(e.to_string()))?;

        match self.extractor_for(file.language()) {
            Some(extractor) => run_extraction(extractor, &source, file.path()),
            None => Ok(AnalysisResult::empty()),
        }
    }
}
  • Step 6: Run extractor tests — verify same count passes
cargo test --package archlens-tree-sitter

Expected: same number of tests pass as in Step 1. Zero new failures.

  • Step 7: Run full workspace
cargo test --workspace

Expected: all tests pass.

  • Step 8: Commit
git add crates/adapters/tree-sitter/src/language_extractor.rs \
        crates/adapters/tree-sitter/src/rust/mod.rs \
        crates/adapters/tree-sitter/src/python/mod.rs \
        crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs
git commit -m "refactor: LanguageExtractor gains explicit 3-phase trait + run_extraction pipeline"