init: archlens — architecture diagram generator
Some checks failed
CI / Check / Test (push) Failing after 1m24s

Hex arch + DDD, tree-sitter parsing, Mermaid/ASCII output.
Supports Rust + Python. 92 tests. CI, diff, --check for staleness detection.
This commit is contained in:
2026-06-16 16:13:04 +02:00
commit 35f27d00b0
106 changed files with 6744 additions and 0 deletions

42
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,42 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
check:
name: Check / Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --all-targets -- -D warnings
- name: test
run: cargo test

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target/

55
CONTEXT.md Normal file
View File

@@ -0,0 +1,55 @@
# Archlens — Domain Glossary
> This file defines the canonical language of the Archlens domain. No implementation details — just terms and their meanings.
## Core Concepts
- **CodeGraph**: The aggregate root. A complete representation of a codebase's architecture — all elements, relationships, and module groupings. Can produce filtered subgraphs of itself by module.
- **CodeElement**: A type-level construct in source code — a class, struct, trait, interface, or enum. Carries a name, kind, location (file + line), visibility, generic parameters, and attributes/decorators.
- **Relationship**: A directed edge between two CodeElements. Has a kind (inheritance or composition) and references source and target elements.
- **Module**: A logical grouping of CodeElements. By default inferred from directory structure, overridable via configuration. Represented as metadata on each CodeElement (`module_path`), not a separate hierarchy.
- **Language**: The programming language of a source file. A closed set: Rust, CSharp, Python. Determines which analysis strategy is used.
- **SourceFile**: A file to be analyzed. Carries a FilePath and its detected Language.
## Analysis
- **AnalysisResult**: The output of analyzing a single file — extracted CodeElements, Relationships, and any AnalysisWarnings.
- **AnalysisWarning**: A non-fatal problem encountered during analysis — unparseable construct, unsupported syntax, etc. Carries file path, line, and message.
- **AnalysisConfig**: Settings that control what gets analyzed — path excludes, granularity level, module-to-directory mappings.
## Output
- **RenderOutput**: The result of rendering a CodeGraph — a collection of RenderedFiles.
- **RenderedFile**: A single output artifact — a named file with content (e.g., `overview.mmd` with Mermaid syntax).
- **OutputConfig**: Settings that control rendering — format, output path, whether to split by module.
## Relationships (kinds)
- **Inheritance**: A type extends or implements another type (class extends class, struct implements trait, class implements interface).
- **Composition**: A type owns or depends on another type via a field, property, or constructor-injected dependency.
## Metadata on CodeElements
- **Visibility**: Whether a type is public, private, internal, or other access level. Used as a filterable property, not shown on diagrams by default.
- **Generics**: Type parameters on a CodeElement. Stored with the element, displayed in simplified form (e.g., `Repository<Order>` not full generic constraints).
- **Attributes**: Decorators, annotations, or attributes on a type (e.g., `[ApiController]`, `#[derive(Clone)]`, `@dataclass`). Stored as metadata, usable for categorization and filtering.
## Diagram Levels
- **Project level**: Dependencies between projects, crates, or packages. Rendered as a flowchart.
- **Module level**: Dependencies between logical modules (directory-based groupings). Rendered as a flowchart with subgraphs.
- **Type level**: Individual types with their inheritance and composition relationships. Rendered as a UML class diagram.

1120
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

59
Cargo.toml Normal file
View File

@@ -0,0 +1,59 @@
[workspace]
resolver = "2"
members = [
"crates/domain",
"crates/application",
"crates/presentation",
"crates/adapters/tree-sitter",
"crates/adapters/walkdir",
"crates/adapters/mermaid",
"crates/adapters/ascii",
"crates/adapters/file-writer",
"crates/adapters/stdout-writer",
"crates/adapters/toml-config",
"crates/adapters/cargo-workspace",
]
[workspace.dependencies]
# Internal crates
archlens-domain = { path = "crates/domain" }
archlens-application = { path = "crates/application" }
archlens-tree-sitter = { path = "crates/adapters/tree-sitter" }
archlens-walkdir = { path = "crates/adapters/walkdir" }
archlens-mermaid = { path = "crates/adapters/mermaid" }
archlens-ascii = { path = "crates/adapters/ascii" }
archlens-file-writer = { path = "crates/adapters/file-writer" }
archlens-stdout-writer = { path = "crates/adapters/stdout-writer" }
archlens-toml-config = { path = "crates/adapters/toml-config" }
archlens-cargo-workspace = { path = "crates/adapters/cargo-workspace" }
# Error handling
thiserror = "2"
anyhow = "1"
# Parsing
tree-sitter = "0.24"
tree-sitter-rust = "0.23"
tree-sitter-python = "0.23"
tree-sitter-c-sharp = "0.23"
# File discovery
walkdir = "2"
ignore = "0.4"
# Config
toml = "0.8"
serde = { version = "1", features = ["derive"] }
# CLI
clap = { version = "4", features = ["derive"] }
# Observability
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Parallelism
rayon = "1"
# Testing
tempfile = "3"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabriel Kaszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View File

@@ -0,0 +1,28 @@
.DEFAULT_GOAL := check
# Run the full local check suite — same order as CI would.
check: fmt-check clippy test
@echo "✅ All checks passed"
# Apply rustfmt to all files.
fmt:
cargo fmt
# Check formatting without modifying files (CI-safe).
fmt-check:
cargo fmt --check
# Run Clippy and treat warnings as errors.
clippy:
cargo clippy -- -D warnings
# Run the test suite.
test:
cargo test
# Apply fmt + clippy auto-fixes in one shot.
fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
.PHONY: check fmt fmt-check clippy test fix

123
README.md Normal file
View File

@@ -0,0 +1,123 @@
# archlens
Generate architecture diagrams from source code. Runs on CI to keep docs fresh.
Supports Rust and Python. Produces Mermaid or ASCII output.
## Install
```bash
cargo install --path crates/presentation
```
## Usage
```bash
# Module-level dependency graph (default)
archlens .
# Project-level (crate/package dependencies from manifests)
archlens . --level project
# Type-level class diagram
archlens . --level type
# Write to file
archlens . --output docs/architecture.mmd
# Split by module (one file per module + overview)
archlens . --level type --split-by-module --output docs/arch/
# ASCII output to terminal
archlens . --format ascii
# Scope to a subtree
archlens . --scope src/domain
# Exclude directories
archlens . --exclude tests/ --exclude generated/
# Verbose logging
archlens . -v # info
archlens . -vv # debug
```
## CI Integration
Check if committed diagrams are up to date:
```bash
archlens . --level project --check --output docs/architecture.mmd
```
Exit code 1 if the diagram has changed. Use `--strict` to also fail on parse warnings.
Compare current state against an existing file:
```bash
archlens diff docs/architecture.mmd --level project
```
## Config
Generate a config file:
```bash
archlens init
```
Creates `archlens.toml`:
```toml
[analysis]
exclude = ["tests/", "vendor/", "generated/"]
level = "module"
[modules]
# "src/infra" = "Infrastructure"
[output]
format = "mermaid"
# path = "docs/architecture.mmd"
split_by_module = false
```
## Diagram Levels
| Level | What it shows | Source |
|-------|--------------|--------|
| `project` | Crate/package dependencies | `Cargo.toml` |
| `module` | Module-level dependency graph | Imports + manifest deps |
| `type` | Class diagram with fields, methods, relationships | Source code (tree-sitter) |
## Supported Languages
| Language | Types | Inheritance | Composition | Imports |
|----------|-------|-------------|-------------|---------|
| Rust | struct, enum, trait | `impl Trait for Type` | struct fields | `use`, `mod` |
| Python | class | `class Foo(Bar)` | `__init__` params, type annotations | `import`, `from ... import` |
| C# | planned | - | - | - |
## Architecture
Built with hexagonal architecture (ports and adapters) + DDD.
```
crates/
domain/ # Core model, zero external deps
application/ # Use cases, orchestration
adapters/
tree-sitter/ # Source code parsing (Rust, Python)
cargo-workspace/ # Cargo.toml dependency extraction
walkdir/ # File discovery
mermaid/ # Mermaid diagram output
ascii/ # Terminal output
file-writer/ # Write to disk
stdout-writer/ # Write to stdout
toml-config/ # Config file parsing
presentation/ # CLI (clap), composition root
```
## License
MIT

View File

@@ -0,0 +1,10 @@
[package]
name = "archlens-ascii"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,153 @@
use std::collections::HashMap;
use archlens_domain::{
CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile,
ports::DiagramRenderer,
};
pub struct AsciiRenderer;
impl Default for AsciiRenderer {
fn default() -> Self {
Self::new()
}
}
impl AsciiRenderer {
pub fn new() -> Self {
Self
}
fn format_kind(element: &CodeElement) -> &'static str {
match element.kind() {
archlens_domain::CodeElementKind::Class => "cls",
archlens_domain::CodeElementKind::Struct => "str",
archlens_domain::CodeElementKind::Trait => "trt",
archlens_domain::CodeElementKind::Interface => "ifc",
archlens_domain::CodeElementKind::Enum => "enm",
archlens_domain::CodeElementKind::Project => "prj",
}
}
}
impl DiagramRenderer for AsciiRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let mut lines = Vec::new();
let total_elements = graph.elements().len();
let total_rels = graph.relationships().len();
let total_modules = graph.modules().len();
lines.push("╔══════════════════════════════════════╗".to_string());
lines.push("║ Architecture Overview ║".to_string());
lines.push("╠══════════════════════════════════════╣".to_string());
lines.push(format!(
"║ Elements: {:<5} Modules: {:<5}",
total_elements, total_modules
));
lines.push(format!("║ Relationships: {:<19}", total_rels));
lines.push("╚══════════════════════════════════════╝".to_string());
if graph.elements().is_empty() {
lines.push(" (no elements found)".to_string());
let content = lines.join("\n");
let file = RenderedFile::new("diagram.txt", &content)?;
return Ok(RenderOutput::single(file));
}
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
if !ungrouped.is_empty() {
lines.push(String::new());
lines.push("┌─ (ungrouped)".to_string());
for el in &ungrouped {
lines.push(format!("│ [{}] {}", Self::format_kind(el), el.name()));
}
lines.push("└───".to_string());
}
let mut module_names: Vec<&String> = grouped.keys().collect();
module_names.sort();
for module in module_names {
let elements = &grouped[module];
lines.push(String::new());
lines.push(format!("┌─ {} ({} types)", module, elements.len()));
lines.push("".to_string());
for (i, el) in elements.iter().enumerate() {
let prefix = if i == elements.len() - 1 {
"└──"
} else {
"├──"
};
let generics = if el.generics().is_empty() {
String::new()
} else {
format!("<{}>", el.generics().join(", "))
};
lines.push(format!(
"{} [{}] {}{}",
prefix,
Self::format_kind(el),
el.name(),
generics
));
}
lines.push("└───".to_string());
}
let non_import_rels: Vec<_> = graph
.relationships()
.iter()
.filter(|r| r.kind() != RelationshipKind::Import)
.collect();
if !non_import_rels.is_empty() {
lines.push(String::new());
lines.push("── Relationships ──".to_string());
for rel in &non_import_rels {
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "extends",
RelationshipKind::Composition => "has",
RelationshipKind::Import => "imports",
};
lines.push(format!(
" {} ─[{}]─> {}",
rel.source(),
arrow,
rel.target()
));
}
}
let import_rels: Vec<_> = graph
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
if !import_rels.is_empty() {
lines.push(String::new());
lines.push(format!("── Imports ({}) ──", import_rels.len()));
for rel in &import_rels {
lines.push(format!(" {} ···> {}", rel.source(), rel.target()));
}
}
let content = lines.join("\n");
let file = RenderedFile::new("diagram.txt", &content)?;
Ok(RenderOutput::single(file))
}
}

View File

@@ -0,0 +1,3 @@
mod ascii_renderer;
pub use ascii_renderer::AsciiRenderer;

View File

@@ -0,0 +1,48 @@
use archlens_ascii::AsciiRenderer;
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, FilePath, Relationship, RelationshipKind,
ports::DiagramRenderer,
};
#[test]
fn renders_elements_and_relationships_as_text() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/service.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
let renderer = AsciiRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("OrderService"));
assert!(content.contains("Order"));
assert!(content.contains("has"));
}
#[test]
fn empty_graph_produces_header_only() {
let renderer = AsciiRenderer::new();
let output = renderer.render(&CodeGraph::new()).unwrap();
let content = output.files()[0].content();
assert!(content.contains("Architecture"));
}

View File

@@ -0,0 +1,15 @@
[package]
name = "archlens-cargo-workspace"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
toml.workspace = true
serde.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,123 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use serde::Deserialize;
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, DomainError, FilePath, ModuleName, Relationship,
RelationshipKind, ports::ProjectAnalyzer,
};
pub struct CargoWorkspaceAnalyzer;
impl Default for CargoWorkspaceAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl CargoWorkspaceAnalyzer {
pub fn new() -> Self {
Self
}
}
#[derive(Deserialize)]
struct WorkspaceToml {
workspace: Option<WorkspaceSection>,
}
#[derive(Deserialize)]
struct WorkspaceSection {
#[serde(default)]
members: Vec<String>,
}
#[derive(Deserialize)]
struct MemberToml {
package: Option<PackageSection>,
#[serde(default)]
dependencies: HashMap<String, toml::Value>,
}
#[derive(Deserialize)]
struct PackageSection {
name: String,
}
impl ProjectAnalyzer for CargoWorkspaceAnalyzer {
fn analyze(&self, root: &Path) -> Result<CodeGraph, DomainError> {
let workspace_toml_path = root.join("Cargo.toml");
let content = std::fs::read_to_string(&workspace_toml_path)
.map_err(|e| DomainError::IoError(e.to_string()))?;
let workspace: WorkspaceToml =
toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?;
let members = workspace.workspace.map(|w| w.members).unwrap_or_default();
let mut graph = CodeGraph::new();
let mut name_set: HashSet<String> = HashSet::new();
let mut member_names: Vec<(String, String)> = Vec::new(); // (member_path, package_name)
for member_path in &members {
let member_cargo = root.join(member_path).join("Cargo.toml");
let member_content = std::fs::read_to_string(&member_cargo)
.map_err(|e| DomainError::IoError(e.to_string()))?;
let member: MemberToml = toml::from_str(&member_content)
.map_err(|e| DomainError::ConfigError(e.to_string()))?;
let package_name = member
.package
.map(|p| p.name)
.unwrap_or_else(|| member_path.clone());
name_set.insert(package_name.clone());
member_names.push((member_path.clone(), package_name));
}
for (member_path, package_name) in &member_names {
let file_path = FilePath::new(&format!("{}/Cargo.toml", member_path))
.map_err(|e| DomainError::IoError(e.to_string()))?;
let mut element =
CodeElement::new(package_name, CodeElementKind::Project, file_path, 1)?;
if let Some(module) = infer_group(member_path) {
element = element.with_module(module);
}
graph.add_element(element);
}
for (member_path, package_name) in &member_names {
let member_cargo = root.join(member_path).join("Cargo.toml");
let member_content = std::fs::read_to_string(&member_cargo)
.map_err(|e| DomainError::IoError(e.to_string()))?;
let member: MemberToml = toml::from_str(&member_content)
.map_err(|e| DomainError::ConfigError(e.to_string()))?;
for dep_name in member.dependencies.keys() {
let normalized = dep_name.replace('_', "-");
if name_set.contains(&normalized)
&& let Ok(rel) =
Relationship::new(package_name, &normalized, RelationshipKind::Composition)
{
graph.add_relationship(rel);
}
}
}
Ok(graph)
}
}
fn infer_group(member_path: &str) -> Option<ModuleName> {
let parts: Vec<&str> = member_path.split('/').collect();
if parts.len() < 3 {
return None;
}
let group = parts[parts.len() - 2];
let capitalized = format!("{}{}", group[..1].to_uppercase(), &group[1..]);
ModuleName::new(&capitalized).ok()
}

View File

@@ -0,0 +1,3 @@
mod cargo_workspace_analyzer;
pub use cargo_workspace_analyzer::CargoWorkspaceAnalyzer;

View File

@@ -0,0 +1,132 @@
use std::fs;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_domain::{CodeElementKind, RelationshipKind, ports::ProjectAnalyzer};
fn create_workspace(dir: &std::path::Path) {
fs::write(
dir.join("Cargo.toml"),
r#"
[workspace]
members = ["crates/domain", "crates/application", "crates/adapters/sqlite"]
"#,
)
.unwrap();
fs::create_dir_all(dir.join("crates/domain/src")).unwrap();
fs::write(
dir.join("crates/domain/Cargo.toml"),
r#"
[package]
name = "myapp-domain"
version = "0.1.0"
edition = "2024"
[dependencies]
"#,
)
.unwrap();
fs::write(dir.join("crates/domain/src/lib.rs"), "").unwrap();
fs::create_dir_all(dir.join("crates/application/src")).unwrap();
fs::write(
dir.join("crates/application/Cargo.toml"),
r#"
[package]
name = "myapp-application"
version = "0.1.0"
edition = "2024"
[dependencies]
myapp-domain = { path = "../domain" }
"#,
)
.unwrap();
fs::write(dir.join("crates/application/src/lib.rs"), "").unwrap();
fs::create_dir_all(dir.join("crates/adapters/sqlite/src")).unwrap();
fs::write(
dir.join("crates/adapters/sqlite/Cargo.toml"),
r#"
[package]
name = "myapp-sqlite"
version = "0.1.0"
edition = "2024"
[dependencies]
myapp-domain = { path = "../../domain" }
"#,
)
.unwrap();
fs::write(dir.join("crates/adapters/sqlite/src/lib.rs"), "").unwrap();
}
#[test]
fn discovers_workspace_members_as_project_elements() {
let dir = tempfile::tempdir().unwrap();
create_workspace(dir.path());
let analyzer = CargoWorkspaceAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
assert_eq!(graph.elements().len(), 3);
assert!(
graph
.elements()
.iter()
.all(|e| e.kind() == CodeElementKind::Project)
);
let names: Vec<&str> = graph.elements().iter().map(|e| e.name()).collect();
assert!(names.contains(&"myapp-domain"));
assert!(names.contains(&"myapp-application"));
assert!(names.contains(&"myapp-sqlite"));
}
#[test]
fn extracts_dependencies_between_workspace_members() {
let dir = tempfile::tempdir().unwrap();
create_workspace(dir.path());
let analyzer = CargoWorkspaceAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
assert_eq!(graph.relationships().len(), 2);
assert!(
graph
.relationships()
.iter()
.all(|r| r.kind() == RelationshipKind::Composition)
);
let deps: Vec<(&str, &str)> = graph
.relationships()
.iter()
.map(|r| (r.source(), r.target()))
.collect();
assert!(deps.contains(&("myapp-application", "myapp-domain")));
assert!(deps.contains(&("myapp-sqlite", "myapp-domain")));
}
#[test]
fn assigns_module_from_directory_grouping() {
let dir = tempfile::tempdir().unwrap();
create_workspace(dir.path());
let analyzer = CargoWorkspaceAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
let sqlite = graph
.elements()
.iter()
.find(|e| e.name() == "myapp-sqlite")
.unwrap();
assert_eq!(sqlite.module().unwrap().as_str(), "Adapters");
let domain = graph
.elements()
.iter()
.find(|e| e.name() == "myapp-domain")
.unwrap();
assert!(domain.module().is_none() || domain.module().unwrap().as_str() != "Adapters");
}

View File

@@ -0,0 +1,13 @@
[package]
name = "archlens-file-writer"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,52 @@
use std::fs;
use std::path::PathBuf;
use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter};
pub struct FileOutputWriter {
output_path: OutputPath,
}
enum OutputPath {
Directory(PathBuf),
File(PathBuf),
}
impl FileOutputWriter {
pub fn new(output_dir: PathBuf) -> Self {
Self {
output_path: OutputPath::Directory(output_dir),
}
}
pub fn single_file(path: PathBuf) -> Self {
Self {
output_path: OutputPath::File(path),
}
}
}
impl OutputWriter for FileOutputWriter {
fn write(&self, output: &RenderOutput) -> Result<(), DomainError> {
match &self.output_path {
OutputPath::File(path) => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| DomainError::IoError(e.to_string()))?;
}
let content = output.files().first().map(|f| f.content()).unwrap_or("");
fs::write(path, content).map_err(|e| DomainError::IoError(e.to_string()))?;
}
OutputPath::Directory(dir) => {
fs::create_dir_all(dir).map_err(|e| DomainError::IoError(e.to_string()))?;
for file in output.files() {
let path = dir.join(file.name());
fs::write(&path, file.content())
.map_err(|e| DomainError::IoError(e.to_string()))?;
}
}
}
Ok(())
}
}

View File

@@ -0,0 +1,3 @@
mod file_output_writer;
pub use file_output_writer::FileOutputWriter;

View File

@@ -0,0 +1,35 @@
use std::fs;
use archlens_domain::{RenderOutput, RenderedFile, ports::OutputWriter};
use archlens_file_writer::FileOutputWriter;
#[test]
fn writes_single_file_to_directory() {
let dir = tempfile::tempdir().unwrap();
let writer = FileOutputWriter::new(dir.path().to_path_buf());
let file = RenderedFile::new("arch.mmd", "classDiagram").unwrap();
let output = RenderOutput::single(file);
writer.write(&output).unwrap();
let content = fs::read_to_string(dir.path().join("arch.mmd")).unwrap();
assert_eq!(content, "classDiagram");
}
#[test]
fn writes_multiple_files_to_directory() {
let dir = tempfile::tempdir().unwrap();
let writer = FileOutputWriter::new(dir.path().to_path_buf());
let files = vec![
RenderedFile::new("overview.mmd", "graph TD").unwrap(),
RenderedFile::new("orders.mmd", "classDiagram").unwrap(),
];
let output = RenderOutput::new(files);
writer.write(&output).unwrap();
assert!(dir.path().join("overview.mmd").exists());
assert!(dir.path().join("orders.mmd").exists());
}

View File

@@ -0,0 +1,10 @@
[package]
name = "archlens-mermaid"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,3 @@
mod mermaid_renderer;
pub use mermaid_renderer::MermaidRenderer;

View File

@@ -0,0 +1,266 @@
use std::collections::{HashMap, HashSet};
use archlens_domain::{
CodeElement, CodeGraph, DiagramLevel, DomainError, RelationshipKind, RenderOutput,
RenderedFile, Visibility, ports::DiagramRenderer,
};
pub struct MermaidRenderer {
level: DiagramLevel,
}
impl Default for MermaidRenderer {
fn default() -> Self {
Self::new()
}
}
impl MermaidRenderer {
pub fn new() -> Self {
Self {
level: DiagramLevel::Type,
}
}
pub fn with_level(level: DiagramLevel) -> Self {
Self { level }
}
fn format_element_name(element: &CodeElement) -> String {
let name = element.name();
if element.generics().is_empty() {
name.to_string()
} else {
format!("{}~{}~", name, element.generics().join(", "))
}
}
fn format_visibility(visibility: Visibility) -> &'static str {
match visibility {
Visibility::Public => "public",
Visibility::Private => "private",
Visibility::Internal => "internal",
}
}
fn render_class_diagram(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["classDiagram".to_string()];
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
let has_namespaces = !grouped.is_empty();
let mut seen: HashSet<String> = HashSet::new();
for element in &ungrouped {
if seen.insert(element.name().to_string()) {
Self::push_class_lines(&mut lines, element, " ");
}
}
if has_namespaces {
for (namespace, elements) in &grouped {
lines.push(format!(" namespace {namespace} {{"));
let mut ns_seen: HashSet<String> = HashSet::new();
for element in elements {
if ns_seen.insert(element.name().to_string()) {
Self::push_class_lines(&mut lines, element, " ");
}
}
lines.push(" }".to_string());
}
}
let mut rel_seen: HashSet<String> = HashSet::new();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "<|--",
RelationshipKind::Composition => "-->",
RelationshipKind::Import => "..>",
};
let key = format!("{} {} {}", rel.source(), arrow, rel.target());
if rel_seen.insert(key.clone()) {
lines.push(format!(" {key}"));
}
}
lines.join("\n")
}
fn push_class_lines(lines: &mut Vec<String>, element: &CodeElement, indent: &str) {
lines.push(format!(
"{indent}class {}",
Self::format_element_name(element)
));
if element.visibility() != Visibility::Public {
lines.push(format!(
"{indent}<<{}>> {}",
Self::format_visibility(element.visibility()),
element.name()
));
}
let name = element.name();
for field in element.fields() {
lines.push(format!("{indent}{name} : {field}"));
}
for method in element.methods() {
lines.push(format!("{indent}{name} : {method}"));
}
}
fn render_module_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
let mut name_to_modules: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut file_to_module: HashMap<String, String> = HashMap::new();
let mut modules: HashSet<String> = HashSet::new();
for element in graph.elements() {
if let Some(module) = element.module() {
name_to_modules
.entry(element.name())
.or_default()
.insert(module.as_str());
modules.insert(module.as_str().to_string());
let file_stem = std::path::Path::new(element.file_path().as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if !file_stem.is_empty() {
file_to_module.insert(file_stem.to_string(), module.as_str().to_string());
}
}
}
for module in &modules {
lines.push(format!(" {module}[{module}]"));
}
let mut module_edges: HashSet<(String, String)> = HashSet::new();
for rel in graph.relationships() {
match rel.kind() {
RelationshipKind::Import => {
let source_mod = file_to_module.get(rel.source());
let target_top = rel.target().split('.').next().unwrap_or("");
let target_mod = Self::capitalize(target_top);
if let Some(src) = source_mod
&& modules.contains(&target_mod)
&& *src != target_mod
{
module_edges.insert((src.clone(), target_mod));
}
}
_ => {
if modules.contains(rel.source())
&& modules.contains(rel.target())
&& rel.source() != rel.target()
{
module_edges.insert((rel.source().to_string(), rel.target().to_string()));
continue;
}
let src_mods = name_to_modules.get(rel.source());
let tgt_mods = name_to_modules.get(rel.target());
if let (Some(src_set), Some(tgt_set)) = (src_mods, tgt_mods) {
for src_mod in src_set {
if tgt_set.contains(src_mod) {
continue;
}
for tgt_mod in tgt_set {
if src_mod != tgt_mod {
module_edges.insert((src_mod.to_string(), tgt_mod.to_string()));
}
}
}
}
}
}
}
for (source, target) in &module_edges {
lines.push(format!(" {source} --> {target}"));
}
lines.join("\n")
}
fn capitalize(s: &str) -> String {
if s.is_empty() {
return String::new();
}
format!("{}{}", s[..1].to_uppercase(), &s[1..])
}
fn render_project_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
for element in &ungrouped {
let id = Self::sanitize_id(element.name());
lines.push(format!(" {id}[{}]", element.name()));
}
for (group, elements) in &grouped {
lines.push(format!(" subgraph {group}"));
for element in elements {
let id = Self::sanitize_id(element.name());
lines.push(format!(" {id}[{}]", element.name()));
}
lines.push(" end".to_string());
}
for rel in graph.relationships() {
let source_id = Self::sanitize_id(rel.source());
let target_id = Self::sanitize_id(rel.target());
lines.push(format!(" {source_id} --> {target_id}"));
}
lines.join("\n")
}
fn sanitize_id(name: &str) -> String {
name.replace(['-', '.'], "_")
}
}
impl DiagramRenderer for MermaidRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let content = match self.level {
DiagramLevel::Type => self.render_class_diagram(graph),
DiagramLevel::Module => self.render_module_flowchart(graph),
DiagramLevel::Project => self.render_project_flowchart(graph),
};
let file = RenderedFile::new("diagram.mmd", &content)?;
Ok(RenderOutput::single(file))
}
}

View File

@@ -0,0 +1,328 @@
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, DiagramLevel, FilePath, ModuleName, Relationship,
RelationshipKind, Visibility, ports::DiagramRenderer,
};
use archlens_mermaid::MermaidRenderer;
fn build_type_level_graph() -> CodeGraph {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/service.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
graph
}
#[test]
fn renders_class_diagram_with_elements_and_composition() {
let renderer = MermaidRenderer::new();
let output = renderer.render(&build_type_level_graph()).unwrap();
let content = output.files()[0].content();
assert!(content.contains("classDiagram"));
assert!(content.contains("class OrderService"));
assert!(content.contains("class Order"));
assert!(content.contains("OrderService --> Order"));
}
#[test]
fn inheritance_uses_different_arrow() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"Animal",
CodeElementKind::Class,
FilePath::new("src/animal.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"Dog",
CodeElementKind::Class,
FilePath::new("src/dog.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("Dog", "Animal", RelationshipKind::Inheritance).unwrap(),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("Dog <|-- Animal"));
}
#[test]
fn elements_show_kind_and_generics() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"Repository",
CodeElementKind::Trait,
FilePath::new("src/repo.rs").unwrap(),
1,
)
.unwrap()
.with_generics(vec!["T".to_string()]),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("class Repository~T~"));
}
#[test]
fn private_elements_show_visibility_annotation() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"InternalHelper",
CodeElementKind::Class,
FilePath::new("src/helper.rs").unwrap(),
1,
)
.unwrap()
.with_visibility(Visibility::Private),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("class InternalHelper"));
assert!(content.contains("<<private>>"));
}
#[test]
fn renders_module_level_flowchart() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"BillingService",
CodeElementKind::Class,
FilePath::new("src/billing/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Billing").unwrap()),
);
graph.add_relationship(
Relationship::new(
"OrderService",
"BillingService",
RelationshipKind::Composition,
)
.unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("graph TD"));
assert!(content.contains("Orders"));
assert!(content.contains("Billing"));
assert!(content.contains("Orders --> Billing"));
}
#[test]
fn empty_graph_produces_valid_diagram() {
let renderer = MermaidRenderer::new();
let output = renderer.render(&CodeGraph::new()).unwrap();
let content = output.files()[0].content();
assert!(content.contains("classDiagram"));
}
#[test]
fn project_level_renders_subgraphs_for_grouped_projects() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"myapp-domain",
CodeElementKind::Project,
FilePath::new("crates/domain/Cargo.toml").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"myapp-sqlite",
CodeElementKind::Project,
FilePath::new("crates/adapters/sqlite/Cargo.toml").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Adapters").unwrap()),
);
graph.add_element(
CodeElement::new(
"myapp-nats",
CodeElementKind::Project,
FilePath::new("crates/adapters/nats/Cargo.toml").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Adapters").unwrap()),
);
graph.add_relationship(
Relationship::new(
"myapp-sqlite",
"myapp-domain",
RelationshipKind::Composition,
)
.unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Project);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("graph TD"));
assert!(content.contains("subgraph Adapters"));
assert!(content.contains("myapp-sqlite"));
assert!(content.contains("myapp-nats"));
assert!(content.contains("myapp-domain"));
assert!(content.contains("-->"));
}
#[test]
fn type_level_groups_elements_by_module() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/orders/order.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"Invoice",
CodeElementKind::Struct,
FilePath::new("src/billing/invoice.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Billing").unwrap()),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("namespace Orders"));
assert!(content.contains("namespace Billing"));
assert!(content.contains("OrderService"));
assert!(content.contains("Invoice"));
}
#[test]
fn module_level_aggregates_cross_module_deps_into_single_arrow() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"OrderRepo",
CodeElementKind::Trait,
FilePath::new("src/orders/repo.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"DbPool",
CodeElementKind::Struct,
FilePath::new("src/infra/db.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Infra").unwrap()),
);
graph.add_element(
CodeElement::new(
"SqliteRepo",
CodeElementKind::Struct,
FilePath::new("src/infra/sqlite.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Infra").unwrap()),
);
// Two types in Orders depend on two types in Infra
graph.add_relationship(
Relationship::new("OrderService", "DbPool", RelationshipKind::Composition).unwrap(),
);
graph.add_relationship(
Relationship::new("OrderRepo", "SqliteRepo", RelationshipKind::Composition).unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
let arrow_count = content.matches("Orders --> Infra").count();
assert_eq!(
arrow_count, 1,
"should have exactly one aggregated arrow, got:\n{content}"
);
}

View File

@@ -0,0 +1,10 @@
[package]
name = "archlens-stdout-writer"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,3 @@
mod stdout_output_writer;
pub use stdout_output_writer::StdoutOutputWriter;

View File

@@ -0,0 +1,24 @@
use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter};
pub struct StdoutOutputWriter;
impl Default for StdoutOutputWriter {
fn default() -> Self {
Self::new()
}
}
impl StdoutOutputWriter {
pub fn new() -> Self {
Self
}
}
impl OutputWriter for StdoutOutputWriter {
fn write(&self, output: &RenderOutput) -> Result<(), DomainError> {
for file in output.files() {
println!("{}", file.content());
}
Ok(())
}
}

View File

@@ -0,0 +1,12 @@
use archlens_domain::{RenderOutput, RenderedFile, ports::OutputWriter};
use archlens_stdout_writer::StdoutOutputWriter;
#[test]
fn writes_without_error() {
let writer = StdoutOutputWriter::new();
let file = RenderedFile::new("arch.mmd", "classDiagram").unwrap();
let output = RenderOutput::single(file);
let result = writer.write(&output);
assert!(result.is_ok());
}

View File

@@ -0,0 +1,15 @@
[package]
name = "archlens-toml-config"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
toml.workspace = true
serde.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,3 @@
mod toml_config_loader;
pub use toml_config_loader::TomlConfigLoader;

View File

@@ -0,0 +1,81 @@
use std::collections::HashMap;
use std::path::Path;
use serde::Deserialize;
use archlens_domain::{
AnalysisConfig, DiagramLevel, DomainError, OutputConfig, ports::ConfigLoader,
};
#[derive(Debug, Deserialize, Default)]
struct RawConfig {
#[serde(default)]
analysis: RawAnalysis,
#[serde(default)]
output: RawOutput,
#[serde(default)]
modules: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawAnalysis {
#[serde(default)]
exclude: Vec<String>,
#[serde(default)]
level: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawOutput {
#[serde(default)]
#[allow(dead_code)]
format: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
split_by_module: bool,
}
#[derive(Default)]
pub struct TomlConfigLoader {
raw: RawConfig,
}
impl TomlConfigLoader {
pub fn from_path(path: &Path) -> Result<Self, DomainError> {
let content =
std::fs::read_to_string(path).map_err(|e| DomainError::IoError(e.to_string()))?;
let raw: RawConfig =
toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?;
Ok(Self { raw })
}
fn parse_level(level: &Option<String>) -> DiagramLevel {
match level.as_deref() {
Some("type") => DiagramLevel::Type,
Some("project") => DiagramLevel::Project,
_ => DiagramLevel::Module,
}
}
}
impl ConfigLoader for TomlConfigLoader {
fn load_analysis_config(&self) -> Result<AnalysisConfig, DomainError> {
let config = AnalysisConfig::default()
.with_excludes(self.raw.analysis.exclude.clone())
.with_level(Self::parse_level(&self.raw.analysis.level))
.with_module_mappings(self.raw.modules.clone());
Ok(config)
}
fn load_output_config(&self) -> Result<OutputConfig, DomainError> {
let mut config =
OutputConfig::default().with_split_by_module(self.raw.output.split_by_module);
if let Some(path) = &self.raw.output.path {
config = config.with_output_path(path.clone());
}
Ok(config)
}
}

View File

@@ -0,0 +1,68 @@
use std::fs;
use archlens_domain::{DiagramLevel, ports::ConfigLoader};
use archlens_toml_config::TomlConfigLoader;
#[test]
fn loads_analysis_config_from_toml_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("archlens.toml");
fs::write(
&config_path,
r#"
[analysis]
exclude = ["tests/", "vendor/"]
level = "type"
[modules]
"src/orders" = "Orders"
"src/billing" = "Billing"
"#,
)
.unwrap();
let loader = TomlConfigLoader::from_path(&config_path).unwrap();
let config = loader.load_analysis_config().unwrap();
assert_eq!(config.excludes(), &["tests/", "vendor/"]);
assert_eq!(config.level(), DiagramLevel::Type);
assert_eq!(
config.module_mappings().get("src/orders").unwrap(),
"Orders"
);
}
#[test]
fn loads_output_config_from_toml_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("archlens.toml");
fs::write(
&config_path,
r#"
[output]
format = "mermaid"
path = "docs/arch.mmd"
split_by_module = true
"#,
)
.unwrap();
let loader = TomlConfigLoader::from_path(&config_path).unwrap();
let config = loader.load_output_config().unwrap();
assert!(config.split_by_module());
assert_eq!(config.output_path(), Some("docs/arch.mmd"));
}
#[test]
fn missing_file_returns_defaults() {
let loader = TomlConfigLoader::default();
let analysis = loader.load_analysis_config().unwrap();
assert!(analysis.excludes().is_empty());
assert_eq!(analysis.level(), DiagramLevel::Module);
let output = loader.load_output_config().unwrap();
assert!(!output.split_by_module());
assert!(output.output_path().is_none());
}

View File

@@ -0,0 +1,17 @@
[package]
name = "archlens-tree-sitter"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
tree-sitter.workspace = true
tree-sitter-rust.workspace = true
tree-sitter-python.workspace = true
tree-sitter-c-sharp.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,5 @@
mod python;
mod rust;
mod tree_sitter_analyzer;
pub use tree_sitter_analyzer::TreeSitterAnalyzer;

View File

@@ -0,0 +1,337 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind,
};
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
let tree = parser
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let root = tree.root_node();
collect_classes(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut relationships,
&mut warnings,
);
collect_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
Ok(AnalysisResult::new(elements, relationships, warnings))
}
fn collect_classes(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "class_definition" {
continue;
}
let Some(name_node) = child.child_by_field_name("name") else {
continue;
};
let name = &source[name_node.byte_range()];
let line = child.start_position().row + 1;
match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) {
Ok(element) => {
type_names.insert(name.to_string());
elements.push(element);
}
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
continue;
}
}
if let Some(superclasses) = child.child_by_field_name("superclasses") {
collect_inheritance(&superclasses, source, name, type_names, relationships);
}
if let Some(body) = child.child_by_field_name("body") {
collect_typed_fields(&body, source, name, type_names, relationships);
collect_constructor_params(&body, source, name, type_names, relationships);
}
}
}
fn collect_inheritance(
superclasses: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = superclasses.walk();
for child in superclasses.children(&mut cursor) {
if child.kind() == "identifier" {
let base_name = &source[child.byte_range()];
if !is_python_builtin(base_name)
&& let Ok(rel) =
Relationship::new(class_name, base_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
}
}
}
}
const PYTHON_BUILTINS: &[&str] = &[
"str",
"int",
"float",
"bool",
"bytes",
"list",
"dict",
"set",
"tuple",
"None",
"type",
"object",
"Exception",
"BaseException",
"Optional",
"Any",
"Union",
"List",
"Dict",
"Set",
"Tuple",
"Callable",
"Sequence",
"Mapping",
"Iterable",
"Iterator",
"Generator",
"Coroutine",
"AsyncGenerator",
"ClassVar",
"Final",
"Literal",
"TypeVar",
"Generic",
"Protocol",
"runtime_checkable",
"Self",
];
fn is_python_builtin(name: &str) -> bool {
PYTHON_BUILTINS.contains(&name)
}
const STDLIB_MODULES: &[&str] = &[
"os",
"sys",
"typing",
"logging",
"json",
"re",
"io",
"abc",
"collections",
"datetime",
"enum",
"functools",
"hashlib",
"http",
"importlib",
"inspect",
"itertools",
"math",
"pathlib",
"pickle",
"random",
"shutil",
"signal",
"socket",
"string",
"subprocess",
"tempfile",
"threading",
"time",
"traceback",
"unittest",
"urllib",
"uuid",
"warnings",
"contextlib",
"dataclasses",
"copy",
"struct",
"base64",
"csv",
"glob",
"operator",
"textwrap",
"asyncio",
"concurrent",
"multiprocessing",
];
fn is_external_import(module: &str) -> bool {
let top = module.split('.').next().unwrap_or(module);
if STDLIB_MODULES.contains(&top) {
return true;
}
if top.starts_with('_') {
return true;
}
false
}
fn collect_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"import_statement" => {
let mut name_cursor = child.walk();
for name_child in child.children(&mut name_cursor) {
if name_child.kind() == "dotted_name" {
let module = &source[name_child.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
}
"import_from_statement" => {
if let Some(module_node) = child.child_by_field_name("module_name") {
let module = &source[module_node.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
_ => {}
}
}
}
fn collect_constructor_params(
body: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() != "function_definition" {
continue;
}
let Some(fn_name) = child.child_by_field_name("name") else {
continue;
};
if &source[fn_name.byte_range()] != "__init__" {
continue;
}
let Some(params) = child.child_by_field_name("parameters") else {
continue;
};
let mut param_cursor = params.walk();
for param in params.children(&mut param_cursor) {
if param.kind() == "typed_parameter"
&& let Some(type_node) = param.child_by_field_name("type")
{
let type_text = &source[type_node.byte_range()];
let base_type = type_text.split('[').next().unwrap_or(type_text).trim();
if base_type != class_name
&& !base_type.is_empty()
&& !is_python_builtin(base_type)
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
}
}
}
fn collect_typed_fields(
body: &Node,
source: &str,
class_name: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
collect_typed_fields_recursive(body, source, class_name, type_names, relationships);
}
fn collect_typed_fields_recursive(
node: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if (child.kind() == "assignment" || child.kind() == "typed_assignment")
&& let Some(type_node) = child.child_by_field_name("type")
{
let type_text = &source[type_node.byte_range()];
let base_type = type_text.split('[').next().unwrap_or(type_text).trim();
if base_type != class_name
&& !base_type.is_empty()
&& !is_python_builtin(base_type)
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
collect_typed_fields_recursive(&child, source, class_name, _type_names, relationships);
}
}

View File

@@ -0,0 +1,349 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
const RUST_PRIMITIVES: &[&str] = &[
"bool",
"char",
"str",
"String",
"u8",
"u16",
"u32",
"u64",
"u128",
"usize",
"i8",
"i16",
"i32",
"i64",
"i128",
"isize",
"f32",
"f64",
"Vec",
"Option",
"Result",
"Box",
"Rc",
"Arc",
"HashMap",
"HashSet",
"BTreeMap",
"BTreeSet",
"PhantomData",
"Pin",
"Cow",
"Self",
];
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind, Visibility,
};
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_rust::LANGUAGE.into())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
let tree = parser
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let root = tree.root_node();
collect_types(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut warnings,
);
collect_relationships(
&root,
source,
&type_names,
&mut relationships,
&mut warnings,
);
collect_mod_declarations(&root, source, file_path, &mut relationships);
collect_use_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
Ok(AnalysisResult::new(elements, relationships, warnings))
}
fn collect_types(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let (kind, name_field) = match child.kind() {
"struct_item" => (CodeElementKind::Struct, "name"),
"enum_item" => (CodeElementKind::Enum, "name"),
"trait_item" => (CodeElementKind::Trait, "name"),
_ => continue,
};
if let Some(name_node) = child.child_by_field_name(name_field) {
let name = &source[name_node.byte_range()];
let line = child.start_position().row + 1;
let visibility = detect_visibility(&child, source);
match CodeElement::new(name, kind, file_path.clone(), line) {
Ok(element) => {
let fields = extract_fields(&child, source);
let methods = extract_methods(node, source, name);
let element = element
.with_visibility(visibility)
.with_fields(fields)
.with_methods(methods);
type_names.insert(name.to_string());
elements.push(element);
}
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
}
}
}
}
}
fn collect_relationships(
node: &Node,
source: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"struct_item" => {
if let Some(name_node) = child.child_by_field_name("name") {
let struct_name = source[name_node.byte_range()].to_string();
collect_field_compositions(
&child,
source,
&struct_name,
type_names,
relationships,
);
}
}
"impl_item" => {
collect_trait_impl(&child, source, type_names, relationships, warnings);
}
_ => {}
}
}
}
fn collect_field_compositions(
struct_node: &Node,
source: &str,
struct_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
if let Some(body) = struct_node.child_by_field_name("body") {
let mut cursor = body.walk();
for field in body.children(&mut cursor) {
if field.kind() == "field_declaration"
&& let Some(type_node) = field.child_by_field_name("type")
{
let type_text = extract_base_type(&type_node, source);
if type_text != struct_name
&& !type_text.is_empty()
&& !RUST_PRIMITIVES.contains(&type_text.as_str())
&& let Ok(rel) =
Relationship::new(struct_name, &type_text, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
}
}
}
fn collect_trait_impl(
impl_node: &Node,
source: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
_warnings: &mut Vec<AnalysisWarning>,
) {
let trait_node = impl_node.child_by_field_name("trait");
let type_node = impl_node.child_by_field_name("type");
if let (Some(trait_n), Some(type_n)) = (trait_node, type_node) {
let trait_name = extract_base_type(&trait_n, source);
let type_name = extract_base_type(&type_n, source);
if !trait_name.is_empty()
&& !type_name.is_empty()
&& !RUST_PRIMITIVES.contains(&trait_name.as_str())
&& !RUST_PRIMITIVES.contains(&type_name.as_str())
&& let Ok(rel) =
Relationship::new(&type_name, &trait_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
}
}
}
fn extract_fields(node: &Node, source: &str) -> Vec<String> {
let mut fields = Vec::new();
if let Some(body) = node.child_by_field_name("body") {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() == "field_declaration" {
let name = child
.child_by_field_name("name")
.map(|n| &source[n.byte_range()]);
let ty = child
.child_by_field_name("type")
.map(|n| extract_base_type(&n, source));
if let (Some(name), Some(ty)) = (name, ty) {
fields.push(format!("{name}: {ty}"));
}
}
}
}
fields
}
fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
let mut methods = Vec::new();
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() != "impl_item" {
continue;
}
if child.child_by_field_name("trait").is_some() {
continue;
}
let type_node = child.child_by_field_name("type");
if let Some(tn) = type_node {
let impl_name = extract_base_type(&tn, source);
if impl_name != type_name {
continue;
}
}
if let Some(body) = child.child_by_field_name("body") {
let mut body_cursor = body.walk();
for item in body.children(&mut body_cursor) {
if item.kind() == "function_item"
&& let Some(name_node) = item.child_by_field_name("name")
{
let fn_name = &source[name_node.byte_range()];
let vis = if detect_visibility(&item, source) == Visibility::Public {
"+"
} else {
"-"
};
methods.push(format!("{vis}{fn_name}()"));
}
}
}
}
methods
}
fn collect_mod_declarations(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "mod_item" {
continue;
}
if child.child_by_field_name("body").is_some() {
continue;
}
if let Some(name_node) = child.child_by_field_name("name") {
let mod_name = &source[name_node.byte_range()];
let target = format!("crate::{mod_name}");
if let Ok(rel) = Relationship::new(file_name, &target, RelationshipKind::Import) {
relationships.push(rel);
}
}
}
}
fn collect_use_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "use_declaration" {
continue;
}
if let Some(arg) = child.child_by_field_name("argument") {
let text = &source[arg.byte_range()];
let path = text
.split('{')
.next()
.unwrap_or(text)
.trim_end_matches("::");
if (path.starts_with("crate::") || path.starts_with("super::"))
&& let Ok(rel) = Relationship::new(file_name, path, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
}
fn extract_base_type(node: &Node, source: &str) -> String {
let text = &source[node.byte_range()];
text.split('<').next().unwrap_or(text).trim().to_string()
}
fn detect_visibility(node: &Node, source: &str) -> Visibility {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "visibility_modifier" {
let text = &source[child.byte_range()];
if text.contains("pub") {
return Visibility::Public;
}
}
}
Visibility::Private
}

View File

@@ -0,0 +1,30 @@
use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer};
use crate::{python, rust};
pub struct TreeSitterAnalyzer;
impl Default for TreeSitterAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl TreeSitterAnalyzer {
pub fn new() -> Self {
Self
}
}
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 file.language() {
Language::Rust => rust::analyze(&source, file.path()),
Language::Python => python::analyze(&source, file.path()),
Language::CSharp => Ok(AnalysisResult::empty()),
}
}
}

View File

@@ -0,0 +1,138 @@
use archlens_domain::{
CodeElementKind, FilePath, Language, RelationshipKind, SourceFile, ports::SourceAnalyzer,
};
use archlens_tree_sitter::TreeSitterAnalyzer;
fn analyze_python(source: &str, filename: &str) -> archlens_domain::AnalysisResult {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join(filename);
std::fs::write(&file_path, source).unwrap();
let analyzer = TreeSitterAnalyzer::new();
let source_file = SourceFile::new(
FilePath::new(file_path.to_str().unwrap()).unwrap(),
Language::Python,
);
analyzer.analyze_file(&source_file).unwrap()
}
#[test]
fn extracts_python_class() {
let result = analyze_python("class Order:\n pass\n", "order.py");
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Order");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Class);
}
#[test]
fn extracts_python_inheritance() {
let source = "class Animal:\n pass\n\nclass Dog(Animal):\n pass\n";
let result = analyze_python(source, "animals.py");
assert_eq!(result.elements().len(), 2);
let inheritance: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Inheritance)
.collect();
assert_eq!(inheritance.len(), 1);
assert_eq!(inheritance[0].source(), "Dog");
assert_eq!(inheritance[0].target(), "Animal");
}
#[test]
fn extracts_composition_from_type_annotated_fields() {
let source = "class Address:\n pass\n\nclass User:\n def __init__(self):\n self.address: Address = Address()\n";
let result = analyze_python(source, "user.py");
let composition: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Composition)
.collect();
assert_eq!(composition.len(), 1);
assert_eq!(composition[0].source(), "User");
assert_eq!(composition[0].target(), "Address");
}
#[test]
fn extracts_import_from_import_statement() {
let source = "import os\nfrom commons.src.schema import BaseModel\n";
let result = analyze_python(source, "service.py");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "commons.src.schema"));
assert!(
!imports.iter().any(|r| r.target() == "os"),
"stdlib should be filtered"
);
}
#[test]
fn extracts_relative_imports_from_init() {
let source = "from .schema import BaseModel\nfrom .client import ApiClient\n";
let result = analyze_python(source, "__init__.py");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert_eq!(imports.len(), 2);
}
#[test]
fn extracts_import_from_plain_import() {
let source = "import commons.utils\n";
let result = analyze_python(source, "service.py");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "commons.utils"));
}
#[test]
fn extracts_composition_from_constructor_params() {
let source = "class Config:\n pass\n\nclass Service:\n def __init__(self, config: Config):\n pass\n";
let result = analyze_python(source, "service.py");
let composition: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Composition)
.collect();
assert_eq!(composition.len(), 1);
assert_eq!(composition[0].source(), "Service");
assert_eq!(composition[0].target(), "Config");
}
#[test]
fn extracts_composition_from_class_level_annotations() {
let source = "class Gad:\n pass\n\nclass Definition:\n gad: Gad\n name: str\n";
let result = analyze_python(source, "models.py");
let composition: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Composition)
.collect();
assert_eq!(composition.len(), 1);
assert_eq!(composition[0].source(), "Definition");
assert_eq!(composition[0].target(), "Gad");
}

View File

@@ -0,0 +1,130 @@
use archlens_domain::{
CodeElementKind, FilePath, Language, RelationshipKind, SourceFile, ports::SourceAnalyzer,
};
use archlens_tree_sitter::TreeSitterAnalyzer;
fn analyze_rust(source: &str, filename: &str) -> archlens_domain::AnalysisResult {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join(filename);
std::fs::write(&file_path, source).unwrap();
let analyzer = TreeSitterAnalyzer::new();
let source_file = SourceFile::new(
FilePath::new(file_path.to_str().unwrap()).unwrap(),
Language::Rust,
);
analyzer.analyze_file(&source_file).unwrap()
}
#[test]
fn extracts_rust_struct() {
let result = analyze_rust("pub struct Order {\n id: u64,\n}", "order.rs");
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Order");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Struct);
}
#[test]
fn extracts_rust_enum() {
let result = analyze_rust(
"pub enum Status {\n Active,\n Inactive,\n}",
"status.rs",
);
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Status");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Enum);
}
#[test]
fn extracts_rust_trait() {
let result = analyze_rust("pub trait Repository {\n fn find(&self);\n}", "repo.rs");
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Repository");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Trait);
}
#[test]
fn extracts_composition_from_struct_fields() {
let source =
"pub struct Order {\n id: u64,\n}\npub struct OrderService {\n order: Order,\n}";
let result = analyze_rust(source, "service.rs");
assert_eq!(result.elements().len(), 2);
assert_eq!(result.relationships().len(), 1);
assert_eq!(result.relationships()[0].source(), "OrderService");
assert_eq!(result.relationships()[0].target(), "Order");
assert_eq!(
result.relationships()[0].kind(),
RelationshipKind::Composition
);
}
#[test]
fn extracts_inheritance_from_trait_impl() {
let source = "pub trait Printable {}\npub struct Order {}\nimpl Printable for Order {}";
let result = analyze_rust(source, "order.rs");
let inheritance: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Inheritance)
.collect();
assert_eq!(inheritance.len(), 1);
assert_eq!(inheritance[0].source(), "Order");
assert_eq!(inheritance[0].target(), "Printable");
}
#[test]
fn extracts_use_imports() {
let source =
"use crate::domain::Order;\nuse crate::ports::Repository;\n\npub struct Service {}";
let result = analyze_rust(source, "service.rs");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "crate::domain::Order"));
assert!(
imports
.iter()
.any(|r| r.target() == "crate::ports::Repository")
);
}
#[test]
fn filters_std_and_external_crate_imports() {
let source =
"use std::collections::HashMap;\nuse serde::Serialize;\nuse crate::models::Order;\n";
let result = analyze_rust(source, "lib.rs");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].target(), "crate::models::Order");
}
#[test]
fn extracts_mod_declarations() {
let source = "mod models;\nmod services;\npub struct App {}";
let result = analyze_rust(source, "lib.rs");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "crate::models"));
assert!(imports.iter().any(|r| r.target() == "crate::services"));
}

View File

@@ -0,0 +1,15 @@
[package]
name = "archlens-walkdir"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
walkdir.workspace = true
ignore.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,3 @@
mod walkdir_discovery;
pub use walkdir_discovery::WalkdirDiscovery;

View File

@@ -0,0 +1,100 @@
use std::path::Path;
use ignore::WalkBuilder;
use archlens_domain::{
AnalysisConfig, DomainError, FilePath, Language, SourceFile, ports::FileDiscovery,
};
const DEFAULT_EXCLUDES: &[&str] = &[
".venv",
"venv",
"node_modules",
"__pycache__",
".git",
"target",
"bin",
"obj",
"dist",
".tox",
".eggs",
];
pub struct WalkdirDiscovery;
impl Default for WalkdirDiscovery {
fn default() -> Self {
Self::new()
}
}
impl WalkdirDiscovery {
pub fn new() -> Self {
Self
}
fn detect_language(path: &Path) -> Option<Language> {
match path.extension()?.to_str()? {
"rs" => Some(Language::Rust),
"py" => Some(Language::Python),
"cs" => Some(Language::CSharp),
_ => None,
}
}
fn is_excluded(path: &Path, root: &Path, excludes: &[String]) -> bool {
let relative = path.strip_prefix(root).unwrap_or(path);
let relative_str = relative.to_string_lossy();
for component in relative.components() {
let name = component.as_os_str().to_string_lossy();
if DEFAULT_EXCLUDES.iter().any(|e| name == *e) {
return true;
}
}
excludes
.iter()
.any(|exclude| relative_str.contains(exclude.trim_end_matches('/')))
}
}
impl FileDiscovery for WalkdirDiscovery {
fn discover(
&self,
root: &Path,
config: &AnalysisConfig,
) -> Result<Vec<SourceFile>, DomainError> {
let mut files = Vec::new();
let walker = WalkBuilder::new(root).hidden(true).git_ignore(true).build();
for entry in walker.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
if Self::is_excluded(path, root, config.excludes()) {
continue;
}
if let Some(scope) = config.scope() {
let relative = path.strip_prefix(root).unwrap_or(path);
if !relative.starts_with(scope) {
continue;
}
}
if let Some(language) = Self::detect_language(path) {
let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let file_path = FilePath::new(&absolute.to_string_lossy())
.map_err(|e| DomainError::IoError(e.to_string()))?;
files.push(SourceFile::new(file_path, language));
}
}
Ok(files)
}
}

View File

@@ -0,0 +1,71 @@
use std::fs;
use archlens_domain::{AnalysisConfig, Language, ports::FileDiscovery};
use archlens_walkdir::WalkdirDiscovery;
fn create_test_tree(dir: &std::path::Path) {
fs::create_dir_all(dir.join("src/orders")).unwrap();
fs::create_dir_all(dir.join("src/billing")).unwrap();
fs::write(dir.join("src/orders/service.rs"), "struct OrderService;").unwrap();
fs::write(dir.join("src/orders/model.py"), "class Order: pass").unwrap();
fs::write(dir.join("src/billing/invoice.cs"), "class Invoice {}").unwrap();
fs::write(dir.join("src/readme.txt"), "not source code").unwrap();
}
#[test]
fn discovers_rust_python_and_csharp_files() {
let dir = tempfile::tempdir().unwrap();
create_test_tree(dir.path());
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
assert_eq!(files.len(), 3);
let languages: Vec<Language> = files.iter().map(|f| f.language()).collect();
assert!(languages.contains(&Language::Rust));
assert!(languages.contains(&Language::Python));
assert!(languages.contains(&Language::CSharp));
}
#[test]
fn ignores_non_source_files() {
let dir = tempfile::tempdir().unwrap();
create_test_tree(dir.path());
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path().as_str()).collect();
assert!(!paths.iter().any(|p| p.ends_with(".txt")));
}
#[test]
fn respects_exclude_patterns() {
let dir = tempfile::tempdir().unwrap();
create_test_tree(dir.path());
let config = AnalysisConfig::default().with_excludes(vec!["billing".to_string()]);
let discovery = WalkdirDiscovery::new();
let files = discovery.discover(dir.path(), &config).unwrap();
assert_eq!(files.len(), 2);
assert!(!files.iter().any(|f| f.path().as_str().contains("billing")));
}
#[test]
fn empty_directory_returns_no_files() {
let dir = tempfile::tempdir().unwrap();
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
assert!(files.is_empty());
}

View File

@@ -0,0 +1,11 @@
[package]
name = "archlens-application"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
rayon.workspace = true

View File

@@ -0,0 +1 @@
pub mod queries;

View File

@@ -0,0 +1,244 @@
use std::collections::HashSet;
use std::path::Path;
use rayon::prelude::*;
use archlens_domain::{
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship,
RelationshipKind,
ports::{FileDiscovery, SourceAnalyzer},
};
pub struct AnalyzeCodebase<F, S>
where
F: FileDiscovery + Send + Sync,
S: SourceAnalyzer,
{
file_discovery: F,
source_analyzer: S,
}
impl<F, S> AnalyzeCodebase<F, S>
where
F: FileDiscovery + Send + Sync,
S: SourceAnalyzer,
{
pub fn new(file_discovery: F, source_analyzer: S) -> Self {
Self {
file_discovery,
source_analyzer,
}
}
pub fn execute(
&self,
root: &Path,
config: &AnalysisConfig,
) -> Result<AnalyzeCodebaseResult, DomainError> {
let files = self.file_discovery.discover(root, config)?;
let file_results: Vec<(Vec<CodeElement>, Vec<Relationship>, Vec<AnalysisWarning>)> = files
.par_iter()
.map(|file| match self.source_analyzer.analyze_file(file) {
Ok(result) => {
let module = infer_module(file.path().as_str(), root, config);
let elements: Vec<CodeElement> = result
.elements()
.iter()
.map(|el| {
let mut el = el.clone();
if el.module().is_none()
&& let Some(ref m) = module
{
el = el.with_module(m.clone());
}
el
})
.collect();
(
elements,
result.relationships().to_vec(),
result.warnings().to_vec(),
)
}
Err(err) => {
let mut warnings = Vec::new();
if let Ok(warning) =
AnalysisWarning::new(file.path().clone(), 0, &err.to_string())
{
warnings.push(warning);
}
(Vec::new(), Vec::new(), warnings)
}
})
.collect();
let mut graph = CodeGraph::new();
let mut warnings = Vec::new();
for (elements, relationships, warns) in file_results {
for el in elements {
graph.add_element(el);
}
for rel in relationships {
graph.add_relationship(rel);
}
warnings.extend(warns);
}
let graph = resolve_cross_file_relationships(graph);
let graph = filter_external_imports(graph, root);
Ok(AnalyzeCodebaseResult { graph, warnings })
}
}
fn resolve_cross_file_relationships(graph: CodeGraph) -> CodeGraph {
use std::collections::HashMap;
let mut file_types: HashMap<String, HashSet<String>> = HashMap::new();
let mut name_modules: HashMap<&str, HashSet<Option<&str>>> = HashMap::new();
let all_type_names: HashSet<&str> = graph.elements().iter().map(|e| e.name()).collect();
for element in graph.elements() {
file_types
.entry(element.file_path().as_str().to_string())
.or_default()
.insert(element.name().to_string());
name_modules
.entry(element.name())
.or_default()
.insert(element.module().map(|m| m.as_str()));
}
let mut resolved = CodeGraph::new();
for element in graph.elements() {
resolved.add_element(element.clone());
}
for rel in graph.relationships() {
match rel.kind() {
RelationshipKind::Import => {
resolved.add_relationship(rel.clone());
}
_ => {
if !all_type_names.contains(rel.source()) || !all_type_names.contains(rel.target())
{
continue;
}
if let Some(src_file) = rel.source_file() {
let file_key = src_file.as_str().to_string();
if let Some(types_in_file) = file_types.get(&file_key)
&& types_in_file.contains(rel.target())
{
resolved.add_relationship(rel.clone());
continue;
}
}
let tgt_modules = &name_modules[rel.target()];
if tgt_modules.len() == 1 {
resolved.add_relationship(rel.clone());
}
}
}
}
resolved
}
fn filter_external_imports(graph: CodeGraph, root: &Path) -> CodeGraph {
let known_dirs: HashSet<String> = std::fs::read_dir(root)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.map(|s| s.to_lowercase())
.collect();
let module_names: HashSet<String> = graph
.modules()
.iter()
.map(|m| m.as_str().to_lowercase())
.collect();
let all_known: HashSet<&str> = known_dirs
.iter()
.map(|s| s.as_str())
.chain(module_names.iter().map(|s| s.as_str()))
.collect();
let mut filtered = CodeGraph::new();
for element in graph.elements() {
filtered.add_element(element.clone());
}
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
let target_top = rel.target().split('.').next().unwrap_or("").to_lowercase();
if !all_known.contains(target_top.as_str()) {
continue;
}
}
filtered.add_relationship(rel.clone());
}
filtered
}
fn infer_module(file_path: &str, root: &Path, config: &AnalysisConfig) -> Option<ModuleName> {
let relative = if let Some(stripped) = file_path.strip_prefix(root.to_str().unwrap_or("")) {
stripped.trim_start_matches('/')
} else {
file_path
};
for (pattern, module_name) in config.module_mappings() {
if relative.starts_with(pattern) {
return ModuleName::new(module_name).ok();
}
}
let parts: Vec<&str> = relative.split('/').collect();
if parts.len() <= 1 {
return None;
}
let module_dir = if parts[0] == "crates" && parts.len() > 2 {
// workspace: crates/<crate-name>/src/...
parts[1]
} else if parts[0] == "src" && parts.len() > 2 {
// single project: src/<module>/...
parts[1]
} else if parts[0] != "src" && parts.len() > 1 {
parts[0]
} else {
return None;
};
let capitalized = module_dir
.split('-')
.map(|seg| {
if seg.is_empty() {
String::new()
} else {
format!("{}{}", seg[..1].to_uppercase(), &seg[1..])
}
})
.collect::<Vec<_>>()
.join("-");
ModuleName::new(&capitalized).ok()
}
pub struct AnalyzeCodebaseResult {
graph: CodeGraph,
warnings: Vec<AnalysisWarning>,
}
impl AnalyzeCodebaseResult {
pub fn graph(&self) -> &CodeGraph {
&self.graph
}
pub fn warnings(&self) -> &[AnalysisWarning] {
&self.warnings
}
}

View File

@@ -0,0 +1,5 @@
mod analyze_codebase;
mod render_diagrams;
pub use analyze_codebase::{AnalyzeCodebase, AnalyzeCodebaseResult};
pub use render_diagrams::RenderDiagrams;

View File

@@ -0,0 +1,45 @@
use archlens_domain::{
CodeGraph, DomainError, OutputConfig,
ports::{DiagramRenderer, OutputWriter},
};
pub struct RenderDiagrams<R, W>
where
R: DiagramRenderer,
W: OutputWriter,
{
renderer: R,
writer: W,
}
impl<R, W> RenderDiagrams<R, W>
where
R: DiagramRenderer,
W: OutputWriter,
{
pub fn new(renderer: R, writer: W) -> Self {
Self { renderer, writer }
}
pub fn execute(&self, graph: &CodeGraph, config: &OutputConfig) -> Result<(), DomainError> {
if config.split_by_module() {
let overview = self.renderer.render(graph)?;
self.writer.write(&overview)?;
for module in graph.modules() {
let subgraph = graph.subgraph_by_module(&module);
let output = self.renderer.render(&subgraph)?;
self.writer.write(&output)?;
}
} else {
let output = self.renderer.render(graph)?;
self.writer.write(&output)?;
}
Ok(())
}
pub fn writer(&self) -> &W {
&self.writer
}
}

View File

@@ -0,0 +1,344 @@
mod fakes;
use std::path::Path;
use archlens_application::queries::AnalyzeCodebase;
use archlens_domain::{
AnalysisConfig, AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError,
FilePath, Language, Relationship, RelationshipKind, SourceFile,
};
use fakes::{FakeFileDiscovery, FakeSourceAnalyzer};
#[test]
fn analyzes_discovered_files_and_builds_code_graph() {
let files = vec![
SourceFile::new(FilePath::new("src/order.rs").unwrap(), Language::Rust),
SourceFile::new(FilePath::new("src/service.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![],
),
)
.with_result(
"src/service.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/service.rs").unwrap(),
1,
)
.unwrap(),
],
vec![
Relationship::new("OrderService", "Order", RelationshipKind::Composition)
.unwrap(),
],
vec![],
),
);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default())
.unwrap();
assert_eq!(result.graph().elements().len(), 2);
assert_eq!(result.graph().relationships().len(), 1);
assert!(result.warnings().is_empty());
}
#[test]
fn empty_file_list_returns_empty_graph() {
let discovery = FakeFileDiscovery::empty();
let analyzer = FakeSourceAnalyzer::new();
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default())
.unwrap();
assert!(result.graph().elements().is_empty());
assert!(result.graph().relationships().is_empty());
assert!(result.warnings().is_empty());
}
#[test]
fn aggregates_warnings_from_multiple_files() {
let files = vec![
SourceFile::new(FilePath::new("src/a.rs").unwrap(), Language::Rust),
SourceFile::new(FilePath::new("src/b.rs").unwrap(), Language::Rust),
];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new()
.with_result(
"src/a.rs",
AnalysisResult::new(
vec![],
vec![],
vec![
AnalysisWarning::new(FilePath::new("src/a.rs").unwrap(), 10, "unknown macro")
.unwrap(),
],
),
)
.with_result(
"src/b.rs",
AnalysisResult::new(
vec![],
vec![],
vec![
AnalysisWarning::new(
FilePath::new("src/b.rs").unwrap(),
5,
"unparseable block",
)
.unwrap(),
],
),
);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default())
.unwrap();
assert_eq!(result.warnings().len(), 2);
}
#[test]
fn analysis_error_on_file_collects_warning_and_continues() {
let files = vec![
SourceFile::new(FilePath::new("src/good.rs").unwrap(), Language::Rust),
SourceFile::new(FilePath::new("src/broken.rs").unwrap(), Language::Rust),
];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new()
.with_result(
"src/good.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"Good",
CodeElementKind::Struct,
FilePath::new("src/good.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
)
.with_error(
"src/broken.rs",
DomainError::AnalysisError("parse failed".to_string()),
);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default())
.unwrap();
assert_eq!(result.graph().elements().len(), 1);
assert_eq!(result.graph().elements()[0].name(), "Good");
assert_eq!(result.warnings().len(), 1);
}
#[test]
fn infers_module_from_directory_structure() {
let files = vec![
SourceFile::new(
FilePath::new("/project/src/orders/service.rs").unwrap(),
Language::Rust,
),
SourceFile::new(
FilePath::new("/project/src/billing/invoice.rs").unwrap(),
Language::Rust,
),
SourceFile::new(
FilePath::new("/project/src/lib.rs").unwrap(),
Language::Rust,
),
];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new()
.with_result(
"/project/src/orders/service.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("/project/src/orders/service.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
)
.with_result(
"/project/src/billing/invoice.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"Invoice",
CodeElementKind::Struct,
FilePath::new("/project/src/billing/invoice.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
)
.with_result(
"/project/src/lib.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"App",
CodeElementKind::Struct,
FilePath::new("/project/src/lib.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("/project"), &AnalysisConfig::default())
.unwrap();
let order_svc = result
.graph()
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
assert_eq!(order_svc.module().unwrap().as_str(), "Orders");
let invoice = result
.graph()
.elements()
.iter()
.find(|e| e.name() == "Invoice")
.unwrap();
assert_eq!(invoice.module().unwrap().as_str(), "Billing");
let app = result
.graph()
.elements()
.iter()
.find(|e| e.name() == "App")
.unwrap();
assert!(app.module().is_none());
}
#[test]
fn infers_nested_module_from_deep_directories() {
let files = vec![SourceFile::new(
FilePath::new("/project/src/orders/models/order.rs").unwrap(),
Language::Rust,
)];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new().with_result(
"/project/src/orders/models/order.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("/project/src/orders/models/order.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("/project"), &AnalysisConfig::default())
.unwrap();
let order = result
.graph()
.elements()
.iter()
.find(|e| e.name() == "Order")
.unwrap();
assert_eq!(order.module().unwrap().as_str(), "Orders");
}
#[test]
fn respects_config_module_mappings() {
let files = vec![SourceFile::new(
FilePath::new("/project/src/infra/db.rs").unwrap(),
Language::Rust,
)];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new().with_result(
"/project/src/infra/db.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"DbPool",
CodeElementKind::Struct,
FilePath::new("/project/src/infra/db.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
);
let mut mappings = std::collections::HashMap::new();
mappings.insert("src/infra".to_string(), "Infrastructure".to_string());
let config = AnalysisConfig::default().with_module_mappings(mappings);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case.execute(Path::new("/project"), &config).unwrap();
let db = result
.graph()
.elements()
.iter()
.find(|e| e.name() == "DbPool")
.unwrap();
assert_eq!(db.module().unwrap().as_str(), "Infrastructure");
}

View File

@@ -0,0 +1,17 @@
use archlens_domain::{CodeGraph, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer};
pub struct FakeDiagramRenderer;
impl FakeDiagramRenderer {
pub fn new() -> Self {
Self
}
}
impl DiagramRenderer for FakeDiagramRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let content = format!("graph with {} elements", graph.elements().len());
let file = RenderedFile::new("output.mmd", &content)?;
Ok(RenderOutput::single(file))
}
}

View File

@@ -0,0 +1,27 @@
use std::path::Path;
use archlens_domain::{AnalysisConfig, DomainError, SourceFile, ports::FileDiscovery};
pub struct FakeFileDiscovery {
files: Vec<SourceFile>,
}
impl FakeFileDiscovery {
pub fn new(files: Vec<SourceFile>) -> Self {
Self { files }
}
pub fn empty() -> Self {
Self { files: Vec::new() }
}
}
impl FileDiscovery for FakeFileDiscovery {
fn discover(
&self,
_root: &Path,
_config: &AnalysisConfig,
) -> Result<Vec<SourceFile>, DomainError> {
Ok(self.files.clone())
}
}

View File

@@ -0,0 +1,9 @@
mod diagram_renderer;
mod file_discovery;
mod output_writer;
mod source_analyzer;
pub use diagram_renderer::FakeDiagramRenderer;
pub use file_discovery::FakeFileDiscovery;
pub use output_writer::FakeOutputWriter;
pub use source_analyzer::FakeSourceAnalyzer;

View File

@@ -0,0 +1,26 @@
use std::cell::RefCell;
use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter};
pub struct FakeOutputWriter {
written: RefCell<Vec<RenderOutput>>,
}
impl FakeOutputWriter {
pub fn new() -> Self {
Self {
written: RefCell::new(Vec::new()),
}
}
pub fn written_outputs(&self) -> Vec<RenderOutput> {
self.written.borrow().clone()
}
}
impl OutputWriter for FakeOutputWriter {
fn write(&self, output: &RenderOutput) -> Result<(), DomainError> {
self.written.borrow_mut().push(output.clone());
Ok(())
}
}

View File

@@ -0,0 +1,43 @@
use std::collections::HashMap;
use archlens_domain::{AnalysisResult, DomainError, SourceFile, ports::SourceAnalyzer};
enum FakeResponse {
Success(AnalysisResult),
Failure(DomainError),
}
pub struct FakeSourceAnalyzer {
results: HashMap<String, FakeResponse>,
}
impl FakeSourceAnalyzer {
pub fn new() -> Self {
Self {
results: HashMap::new(),
}
}
pub fn with_result(mut self, file_path: &str, result: AnalysisResult) -> Self {
self.results
.insert(file_path.to_string(), FakeResponse::Success(result));
self
}
pub fn with_error(mut self, file_path: &str, error: DomainError) -> Self {
self.results
.insert(file_path.to_string(), FakeResponse::Failure(error));
self
}
}
impl SourceAnalyzer for FakeSourceAnalyzer {
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError> {
let key = file.path().as_str().to_string();
match self.results.get(&key) {
Some(FakeResponse::Success(result)) => Ok(result.clone()),
Some(FakeResponse::Failure(_)) => Err(DomainError::AnalysisError(key)),
None => Ok(AnalysisResult::empty()),
}
}
}

View File

@@ -0,0 +1,78 @@
mod fakes;
use archlens_application::queries::RenderDiagrams;
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, OutputConfig,
};
use fakes::{FakeDiagramRenderer, FakeOutputWriter};
fn build_graph() -> CodeGraph {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"BillingService",
CodeElementKind::Class,
FilePath::new("src/billing.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Billing").unwrap()),
);
graph
}
#[test]
fn renders_single_diagram_and_writes_output() {
let renderer = FakeDiagramRenderer::new();
let writer = FakeOutputWriter::new();
let config = OutputConfig::default();
let use_case = RenderDiagrams::new(renderer, writer);
use_case.execute(&build_graph(), &config).unwrap();
let outputs = use_case.writer().written_outputs();
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0].files().len(), 1);
}
#[test]
fn split_mode_renders_overview_plus_per_module_diagrams() {
let renderer = FakeDiagramRenderer::new();
let writer = FakeOutputWriter::new();
let config = OutputConfig::default().with_split_by_module(true);
let graph = build_graph(); // has 2 modules: Orders, Billing
let use_case = RenderDiagrams::new(renderer, writer);
use_case.execute(&graph, &config).unwrap();
let outputs = use_case.writer().written_outputs();
// 1 overview + 2 module diagrams = 3 writes
assert_eq!(outputs.len(), 3);
}
#[test]
fn empty_graph_still_produces_output() {
let renderer = FakeDiagramRenderer::new();
let writer = FakeOutputWriter::new();
let config = OutputConfig::default();
let graph = CodeGraph::new();
let use_case = RenderDiagrams::new(renderer, writer);
use_case.execute(&graph, &config).unwrap();
let outputs = use_case.writer().written_outputs();
assert_eq!(outputs.len(), 1);
}

8
crates/domain/Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[package]
name = "archlens-domain"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
thiserror.workspace = true

View File

@@ -0,0 +1,78 @@
use std::collections::HashSet;
use crate::{CodeElement, ModuleName, Relationship};
#[derive(Debug, Clone)]
pub struct CodeGraph {
elements: Vec<CodeElement>,
relationships: Vec<Relationship>,
}
impl Default for CodeGraph {
fn default() -> Self {
Self::new()
}
}
impl CodeGraph {
pub fn new() -> Self {
Self {
elements: Vec::new(),
relationships: Vec::new(),
}
}
pub fn add_element(&mut self, element: CodeElement) {
self.elements.push(element);
}
pub fn add_relationship(&mut self, relationship: Relationship) {
self.relationships.push(relationship);
}
pub fn elements(&self) -> &[CodeElement] {
&self.elements
}
pub fn relationships(&self) -> &[Relationship] {
&self.relationships
}
pub fn modules(&self) -> Vec<ModuleName> {
let mut seen = HashSet::new();
let mut modules = Vec::new();
for element in &self.elements {
if let Some(module) = element.module()
&& seen.insert(module.as_str().to_string())
{
modules.push(module.clone());
}
}
modules
}
pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph {
let filtered_elements: Vec<CodeElement> = self
.elements
.iter()
.filter(|e| e.module().is_some_and(|m| m == module))
.cloned()
.collect();
let element_names: HashSet<&str> = filtered_elements.iter().map(|e| e.name()).collect();
let filtered_relationships: Vec<Relationship> = self
.relationships
.iter()
.filter(|r| element_names.contains(r.source()) && element_names.contains(r.target()))
.cloned()
.collect();
CodeGraph {
elements: filtered_elements,
relationships: filtered_relationships,
}
}
}

View File

@@ -0,0 +1,3 @@
mod code_graph;
pub use code_graph::CodeGraph;

View File

@@ -0,0 +1,111 @@
use crate::{CodeElementKind, DomainError, FilePath, ModuleName, Visibility};
#[derive(Debug, Clone)]
pub struct CodeElement {
name: String,
kind: CodeElementKind,
file_path: FilePath,
line: usize,
visibility: Visibility,
module: Option<ModuleName>,
generics: Vec<String>,
attributes: Vec<String>,
fields: Vec<String>,
methods: Vec<String>,
}
impl CodeElement {
pub fn new(
name: &str,
kind: CodeElementKind,
file_path: FilePath,
line: usize,
) -> Result<Self, DomainError> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(DomainError::EmptyValue("CodeElement name"));
}
Ok(Self {
name: trimmed.to_string(),
kind,
file_path,
line,
visibility: Visibility::Public,
module: None,
generics: Vec::new(),
attributes: Vec::new(),
fields: Vec::new(),
methods: Vec::new(),
})
}
pub fn with_visibility(mut self, visibility: Visibility) -> Self {
self.visibility = visibility;
self
}
pub fn with_module(mut self, module: ModuleName) -> Self {
self.module = Some(module);
self
}
pub fn with_generics(mut self, generics: Vec<String>) -> Self {
self.generics = generics;
self
}
pub fn with_attributes(mut self, attributes: Vec<String>) -> Self {
self.attributes = attributes;
self
}
pub fn name(&self) -> &str {
&self.name
}
pub fn kind(&self) -> CodeElementKind {
self.kind
}
pub fn file_path(&self) -> &FilePath {
&self.file_path
}
pub fn line(&self) -> usize {
self.line
}
pub fn visibility(&self) -> Visibility {
self.visibility
}
pub fn module(&self) -> Option<&ModuleName> {
self.module.as_ref()
}
pub fn generics(&self) -> &[String] {
&self.generics
}
pub fn attributes(&self) -> &[String] {
&self.attributes
}
pub fn with_fields(mut self, fields: Vec<String>) -> Self {
self.fields = fields;
self
}
pub fn with_methods(mut self, methods: Vec<String>) -> Self {
self.methods = methods;
self
}
pub fn fields(&self) -> &[String] {
&self.fields
}
pub fn methods(&self) -> &[String] {
&self.methods
}
}

View File

@@ -0,0 +1,5 @@
mod code_element;
mod relationship;
pub use code_element::CodeElement;
pub use relationship::Relationship;

View File

@@ -0,0 +1,49 @@
use crate::{DomainError, FilePath, RelationshipKind};
#[derive(Debug, Clone)]
pub struct Relationship {
source: String,
target: String,
kind: RelationshipKind,
source_file: Option<FilePath>,
}
impl Relationship {
pub fn new(source: &str, target: &str, kind: RelationshipKind) -> Result<Self, DomainError> {
let source = source.trim();
let target = target.trim();
if source.is_empty() {
return Err(DomainError::EmptyValue("Relationship source"));
}
if target.is_empty() {
return Err(DomainError::EmptyValue("Relationship target"));
}
Ok(Self {
source: source.to_string(),
target: target.to_string(),
kind,
source_file: None,
})
}
pub fn with_source_file(mut self, file: FilePath) -> Self {
self.source_file = Some(file);
self
}
pub fn source(&self) -> &str {
&self.source
}
pub fn target(&self) -> &str {
&self.target
}
pub fn kind(&self) -> RelationshipKind {
self.kind
}
pub fn source_file(&self) -> Option<&FilePath> {
self.source_file.as_ref()
}
}

View File

@@ -0,0 +1,14 @@
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("{0} cannot be empty")]
EmptyValue(&'static str),
#[error("failed to analyze: {0}")]
AnalysisError(String),
#[error("IO error: {0}")]
IoError(String),
#[error("config error: {0}")]
ConfigError(String),
}

14
crates/domain/src/lib.rs Normal file
View File

@@ -0,0 +1,14 @@
mod error;
pub mod aggregates;
pub mod entities;
pub mod ports;
pub mod value_objects;
pub use aggregates::CodeGraph;
pub use entities::{CodeElement, Relationship};
pub use error::DomainError;
pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning};
pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility};
pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile};
pub use value_objects::source::{FilePath, Language, ModuleName, SourceFile};

View File

@@ -0,0 +1,6 @@
use crate::{AnalysisConfig, DomainError, OutputConfig};
pub trait ConfigLoader {
fn load_analysis_config(&self) -> Result<AnalysisConfig, DomainError>;
fn load_output_config(&self) -> Result<OutputConfig, DomainError>;
}

View File

@@ -0,0 +1,5 @@
use crate::{CodeGraph, DomainError, RenderOutput};
pub trait DiagramRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError>;
}

View File

@@ -0,0 +1,9 @@
use crate::{AnalysisConfig, DomainError, SourceFile};
pub trait FileDiscovery {
fn discover(
&self,
root: &std::path::Path,
config: &AnalysisConfig,
) -> Result<Vec<SourceFile>, DomainError>;
}

View File

@@ -0,0 +1,13 @@
mod config_loader;
mod diagram_renderer;
mod file_discovery;
mod output_writer;
mod project_analyzer;
mod source_analyzer;
pub use config_loader::ConfigLoader;
pub use diagram_renderer::DiagramRenderer;
pub use file_discovery::FileDiscovery;
pub use output_writer::OutputWriter;
pub use project_analyzer::ProjectAnalyzer;
pub use source_analyzer::SourceAnalyzer;

View File

@@ -0,0 +1,5 @@
use crate::{DomainError, RenderOutput};
pub trait OutputWriter {
fn write(&self, output: &RenderOutput) -> Result<(), DomainError>;
}

View File

@@ -0,0 +1,7 @@
use std::path::Path;
use crate::{CodeGraph, DomainError};
pub trait ProjectAnalyzer {
fn analyze(&self, root: &Path) -> Result<CodeGraph, DomainError>;
}

View File

@@ -0,0 +1,5 @@
use crate::{AnalysisResult, DomainError, SourceFile};
pub trait SourceAnalyzer: Send + Sync {
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError>;
}

View File

@@ -0,0 +1,60 @@
use std::collections::HashMap;
use crate::DiagramLevel;
#[derive(Debug, Clone)]
pub struct AnalysisConfig {
excludes: Vec<String>,
level: DiagramLevel,
module_mappings: HashMap<String, String>,
scope: Option<String>,
}
impl AnalysisConfig {
pub fn with_excludes(mut self, excludes: Vec<String>) -> Self {
self.excludes = excludes;
self
}
pub fn with_level(mut self, level: DiagramLevel) -> Self {
self.level = level;
self
}
pub fn with_module_mappings(mut self, mappings: HashMap<String, String>) -> Self {
self.module_mappings = mappings;
self
}
pub fn excludes(&self) -> &[String] {
&self.excludes
}
pub fn level(&self) -> DiagramLevel {
self.level
}
pub fn with_scope(mut self, scope: String) -> Self {
self.scope = Some(scope);
self
}
pub fn module_mappings(&self) -> &HashMap<String, String> {
&self.module_mappings
}
pub fn scope(&self) -> Option<&str> {
self.scope.as_deref()
}
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
excludes: Vec::new(),
level: DiagramLevel::Module,
module_mappings: HashMap::new(),
scope: None,
}
}
}

View File

@@ -0,0 +1,42 @@
use crate::{AnalysisWarning, CodeElement, Relationship};
#[derive(Debug, Clone)]
pub struct AnalysisResult {
elements: Vec<CodeElement>,
relationships: Vec<Relationship>,
warnings: Vec<AnalysisWarning>,
}
impl AnalysisResult {
pub fn new(
elements: Vec<CodeElement>,
relationships: Vec<Relationship>,
warnings: Vec<AnalysisWarning>,
) -> Self {
Self {
elements,
relationships,
warnings,
}
}
pub fn empty() -> Self {
Self {
elements: Vec::new(),
relationships: Vec::new(),
warnings: Vec::new(),
}
}
pub fn elements(&self) -> &[CodeElement] {
&self.elements
}
pub fn relationships(&self) -> &[Relationship] {
&self.relationships
}
pub fn warnings(&self) -> &[AnalysisWarning] {
&self.warnings
}
}

View File

@@ -0,0 +1,34 @@
use crate::{DomainError, FilePath};
#[derive(Debug, Clone)]
pub struct AnalysisWarning {
file_path: FilePath,
line: usize,
message: String,
}
impl AnalysisWarning {
pub fn new(file_path: FilePath, line: usize, message: &str) -> Result<Self, DomainError> {
let message = message.trim();
if message.is_empty() {
return Err(DomainError::EmptyValue("AnalysisWarning message"));
}
Ok(Self {
file_path,
line,
message: message.to_string(),
})
}
pub fn file_path(&self) -> &FilePath {
&self.file_path
}
pub fn line(&self) -> usize {
self.line
}
pub fn message(&self) -> &str {
&self.message
}
}

View File

@@ -0,0 +1,7 @@
mod analysis_config;
mod analysis_result;
mod analysis_warning;
pub use analysis_config::AnalysisConfig;
pub use analysis_result::AnalysisResult;
pub use analysis_warning::AnalysisWarning;

View File

@@ -0,0 +1,9 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CodeElementKind {
Class,
Struct,
Trait,
Interface,
Enum,
Project,
}

View File

@@ -0,0 +1,7 @@
mod code_element_kind;
mod relationship_kind;
mod visibility;
pub use code_element_kind::CodeElementKind;
pub use relationship_kind::RelationshipKind;
pub use visibility::Visibility;

View File

@@ -0,0 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RelationshipKind {
Inheritance,
Composition,
Import,
}

View File

@@ -0,0 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Visibility {
Public,
Private,
Internal,
}

View File

@@ -0,0 +1,4 @@
pub mod analysis;
pub mod graph;
pub mod output;
pub mod source;

View File

@@ -0,0 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiagramLevel {
Project,
Module,
Type,
}

View File

@@ -0,0 +1,9 @@
mod diagram_level;
mod output_config;
mod render_output;
mod rendered_file;
pub use diagram_level::DiagramLevel;
pub use output_config::OutputConfig;
pub use render_output::RenderOutput;
pub use rendered_file::RenderedFile;

View File

@@ -0,0 +1,25 @@
#[derive(Debug, Clone, Default)]
pub struct OutputConfig {
split_by_module: bool,
output_path: Option<String>,
}
impl OutputConfig {
pub fn with_split_by_module(mut self, split: bool) -> Self {
self.split_by_module = split;
self
}
pub fn with_output_path(mut self, path: String) -> Self {
self.output_path = Some(path);
self
}
pub fn split_by_module(&self) -> bool {
self.split_by_module
}
pub fn output_path(&self) -> Option<&str> {
self.output_path.as_deref()
}
}

View File

@@ -0,0 +1,20 @@
use crate::RenderedFile;
#[derive(Debug, Clone)]
pub struct RenderOutput {
files: Vec<RenderedFile>,
}
impl RenderOutput {
pub fn new(files: Vec<RenderedFile>) -> Self {
Self { files }
}
pub fn single(file: RenderedFile) -> Self {
Self { files: vec![file] }
}
pub fn files(&self) -> &[RenderedFile] {
&self.files
}
}

View File

@@ -0,0 +1,32 @@
use crate::DomainError;
#[derive(Debug, Clone)]
pub struct RenderedFile {
name: String,
content: String,
}
impl RenderedFile {
pub fn new(name: &str, content: &str) -> Result<Self, DomainError> {
let name = name.trim();
let content = content.trim();
if name.is_empty() {
return Err(DomainError::EmptyValue("RenderedFile name"));
}
if content.is_empty() {
return Err(DomainError::EmptyValue("RenderedFile content"));
}
Ok(Self {
name: name.to_string(),
content: content.to_string(),
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn content(&self) -> &str {
&self.content
}
}

View File

@@ -0,0 +1,18 @@
use crate::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FilePath(String);
impl FilePath {
pub fn new(value: &str) -> Result<Self, DomainError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(DomainError::EmptyValue("FilePath"));
}
Ok(Self(trimmed.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}

View File

@@ -0,0 +1,16 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Language {
Rust,
CSharp,
Python,
}
impl Language {
pub fn name(&self) -> &'static str {
match self {
Self::Rust => "Rust",
Self::CSharp => "CSharp",
Self::Python => "Python",
}
}
}

View File

@@ -0,0 +1,9 @@
mod file_path;
mod language;
mod module_name;
mod source_file;
pub use file_path::FilePath;
pub use language::Language;
pub use module_name::ModuleName;
pub use source_file::SourceFile;

View File

@@ -0,0 +1,18 @@
use crate::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModuleName(String);
impl ModuleName {
pub fn new(value: &str) -> Result<Self, DomainError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(DomainError::EmptyValue("ModuleName"));
}
Ok(Self(trimmed.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}

View File

@@ -0,0 +1,21 @@
use crate::{FilePath, Language};
#[derive(Debug, Clone)]
pub struct SourceFile {
path: FilePath,
language: Language,
}
impl SourceFile {
pub fn new(path: FilePath, language: Language) -> Self {
Self { path, language }
}
pub fn path(&self) -> &FilePath {
&self.path
}
pub fn language(&self) -> Language {
self.language
}
}

View File

@@ -0,0 +1,39 @@
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, FilePath, Relationship,
RelationshipKind,
};
#[test]
fn analysis_result_collects_elements_relationships_and_warnings() {
let element = CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap();
let relationship =
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap();
let warning = AnalysisWarning::new(
FilePath::new("src/broken.rs").unwrap(),
10,
"unparseable macro",
)
.unwrap();
let result = AnalysisResult::new(vec![element], vec![relationship], vec![warning]);
assert_eq!(result.elements().len(), 1);
assert_eq!(result.relationships().len(), 1);
assert_eq!(result.warnings().len(), 1);
}
#[test]
fn empty_analysis_result() {
let result = AnalysisResult::empty();
assert!(result.elements().is_empty());
assert!(result.relationships().is_empty());
assert!(result.warnings().is_empty());
}

View File

@@ -0,0 +1,21 @@
use archlens_domain::{AnalysisWarning, FilePath};
#[test]
fn warning_carries_location_and_message() {
let warning = AnalysisWarning::new(
FilePath::new("src/broken.rs").unwrap(),
42,
"could not parse struct definition",
)
.unwrap();
assert_eq!(warning.file_path().as_str(), "src/broken.rs");
assert_eq!(warning.line(), 42);
assert_eq!(warning.message(), "could not parse struct definition");
}
#[test]
fn warning_rejects_empty_message() {
let result = AnalysisWarning::new(FilePath::new("src/broken.rs").unwrap(), 1, "");
assert!(result.is_err());
}

View File

@@ -0,0 +1,107 @@
use archlens_domain::{CodeElement, CodeElementKind, FilePath, ModuleName, Visibility};
#[test]
fn code_element_is_created_with_required_fields() {
let element = CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
42,
)
.unwrap();
assert_eq!(element.name(), "OrderService");
assert_eq!(element.kind(), CodeElementKind::Class);
assert_eq!(element.file_path().as_str(), "src/orders/service.rs");
assert_eq!(element.line(), 42);
}
#[test]
fn code_element_with_empty_name_is_rejected() {
let result = CodeElement::new(
"",
CodeElementKind::Class,
FilePath::new("src/main.rs").unwrap(),
1,
);
assert!(result.is_err());
}
#[test]
fn code_element_defaults_to_public_visibility() {
let element = CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap();
assert_eq!(element.visibility(), Visibility::Public);
}
#[test]
fn code_element_with_visibility() {
let element = CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap()
.with_visibility(Visibility::Private);
assert_eq!(element.visibility(), Visibility::Private);
}
#[test]
fn code_element_with_module_path() {
let module = ModuleName::new("Orders").unwrap();
let element = CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/orders/order.rs").unwrap(),
1,
)
.unwrap()
.with_module(module.clone());
assert_eq!(element.module(), Some(&module));
}
#[test]
fn code_element_with_generics() {
let element = CodeElement::new(
"Repository",
CodeElementKind::Trait,
FilePath::new("src/repo.rs").unwrap(),
1,
)
.unwrap()
.with_generics(vec!["T".to_string()]);
assert_eq!(element.generics(), &["T"]);
}
#[test]
fn code_element_with_attributes() {
let element = CodeElement::new(
"OrderController",
CodeElementKind::Class,
FilePath::new("src/controller.cs").unwrap(),
1,
)
.unwrap()
.with_attributes(vec!["ApiController".to_string()]);
assert_eq!(element.attributes(), &["ApiController"]);
}
#[test]
fn all_element_kinds_exist() {
let _class = CodeElementKind::Class;
let _struct = CodeElementKind::Struct;
let _trait = CodeElementKind::Trait;
let _interface = CodeElementKind::Interface;
let _enum = CodeElementKind::Enum;
}

View File

@@ -0,0 +1,130 @@
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, Relationship, RelationshipKind,
};
fn make_element(name: &str, module: Option<&str>) -> CodeElement {
let mut element = CodeElement::new(
name,
CodeElementKind::Class,
FilePath::new(&format!("src/{name}.rs")).unwrap(),
1,
)
.unwrap();
if let Some(m) = module {
element = element.with_module(ModuleName::new(m).unwrap());
}
element
}
#[test]
fn empty_graph_has_no_elements() {
let graph = CodeGraph::new();
assert!(graph.elements().is_empty());
assert!(graph.relationships().is_empty());
}
#[test]
fn graph_stores_added_elements() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", None));
graph.add_element(make_element("Order", None));
assert_eq!(graph.elements().len(), 2);
}
#[test]
fn graph_stores_relationships() {
let mut graph = CodeGraph::new();
let service = make_element("OrderService", None);
let repo = make_element("OrderRepository", None);
graph.add_element(service);
graph.add_element(repo);
graph.add_relationship(
Relationship::new(
"OrderService",
"OrderRepository",
RelationshipKind::Composition,
)
.unwrap(),
);
assert_eq!(graph.relationships().len(), 1);
let rel = &graph.relationships()[0];
assert_eq!(rel.source(), "OrderService");
assert_eq!(rel.target(), "OrderRepository");
assert_eq!(rel.kind(), RelationshipKind::Composition);
}
#[test]
fn subgraph_by_module_filters_elements() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("Orders")));
graph.add_element(make_element("Order", Some("Orders")));
graph.add_element(make_element("BillingService", Some("Billing")));
let module = ModuleName::new("Orders").unwrap();
let subgraph = graph.subgraph_by_module(&module);
assert_eq!(subgraph.elements().len(), 2);
assert!(
subgraph
.elements()
.iter()
.all(|e| e.module().unwrap().as_str() == "Orders")
);
}
#[test]
fn subgraph_includes_relationships_within_module() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("Orders")));
graph.add_element(make_element("Order", Some("Orders")));
graph.add_element(make_element("BillingService", Some("Billing")));
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
graph.add_relationship(
Relationship::new(
"OrderService",
"BillingService",
RelationshipKind::Composition,
)
.unwrap(),
);
let module = ModuleName::new("Orders").unwrap();
let subgraph = graph.subgraph_by_module(&module);
assert_eq!(subgraph.relationships().len(), 1);
assert_eq!(subgraph.relationships()[0].target(), "Order");
}
#[test]
fn subgraph_of_nonexistent_module_is_empty() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("Orders")));
let module = ModuleName::new("Unknown").unwrap();
let subgraph = graph.subgraph_by_module(&module);
assert!(subgraph.elements().is_empty());
assert!(subgraph.relationships().is_empty());
}
#[test]
fn graph_lists_unique_modules() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("Orders")));
graph.add_element(make_element("Order", Some("Orders")));
graph.add_element(make_element("BillingService", Some("Billing")));
graph.add_element(make_element("Orphan", None));
let modules = graph.modules();
assert_eq!(modules.len(), 2);
assert!(modules.iter().any(|m| m.as_str() == "Orders"));
assert!(modules.iter().any(|m| m.as_str() == "Billing"));
}

View File

@@ -0,0 +1,37 @@
use archlens_domain::{AnalysisConfig, DiagramLevel, OutputConfig};
#[test]
fn analysis_config_has_sensible_defaults() {
let config = AnalysisConfig::default();
assert!(config.excludes().is_empty());
assert_eq!(config.level(), DiagramLevel::Module);
assert!(config.module_mappings().is_empty());
}
#[test]
fn analysis_config_with_excludes() {
let config =
AnalysisConfig::default().with_excludes(vec!["tests/".to_string(), "vendor/".to_string()]);
assert_eq!(config.excludes().len(), 2);
}
#[test]
fn output_config_has_sensible_defaults() {
let config = OutputConfig::default();
assert!(!config.split_by_module());
assert!(config.output_path().is_none());
}
#[test]
fn output_config_with_split() {
let config = OutputConfig::default().with_split_by_module(true);
assert!(config.split_by_module());
}
#[test]
fn all_diagram_levels_exist() {
let _project = DiagramLevel::Project;
let _module = DiagramLevel::Module;
let _type_level = DiagramLevel::Type;
}

View File

@@ -0,0 +1,29 @@
use archlens_domain::FilePath;
#[test]
fn valid_file_path_is_created() {
let path = FilePath::new("src/main.rs").unwrap();
assert_eq!(path.as_str(), "src/main.rs");
}
#[test]
fn empty_file_path_is_rejected() {
let result = FilePath::new("");
assert!(result.is_err());
}
#[test]
fn whitespace_only_file_path_is_rejected() {
let result = FilePath::new(" ");
assert!(result.is_err());
}
#[test]
fn file_paths_are_comparable() {
let a = FilePath::new("src/main.rs").unwrap();
let b = FilePath::new("src/main.rs").unwrap();
let c = FilePath::new("src/lib.rs").unwrap();
assert_eq!(a, b);
assert_ne!(a, c);
}

View File

@@ -0,0 +1,18 @@
use archlens_domain::Language;
#[test]
fn known_languages_are_available() {
let rust = Language::Rust;
let csharp = Language::CSharp;
let python = Language::Python;
assert_eq!(rust.name(), "Rust");
assert_eq!(csharp.name(), "CSharp");
assert_eq!(python.name(), "Python");
}
#[test]
fn languages_are_comparable() {
assert_eq!(Language::Rust, Language::Rust);
assert_ne!(Language::Rust, Language::Python);
}

View File

@@ -0,0 +1,23 @@
use archlens_domain::ModuleName;
#[test]
fn valid_module_name_is_created() {
let name = ModuleName::new("Orders").unwrap();
assert_eq!(name.as_str(), "Orders");
}
#[test]
fn empty_module_name_is_rejected() {
let result = ModuleName::new("");
assert!(result.is_err());
}
#[test]
fn module_names_are_comparable() {
let a = ModuleName::new("Orders").unwrap();
let b = ModuleName::new("Orders").unwrap();
let c = ModuleName::new("Billing").unwrap();
assert_eq!(a, b);
assert_ne!(a, c);
}

View File

@@ -0,0 +1,37 @@
use archlens_domain::{RenderOutput, RenderedFile};
#[test]
fn rendered_file_carries_name_and_content() {
let file = RenderedFile::new("overview.mmd", "graph TD;").unwrap();
assert_eq!(file.name(), "overview.mmd");
assert_eq!(file.content(), "graph TD;");
}
#[test]
fn rendered_file_rejects_empty_name() {
let result = RenderedFile::new("", "content");
assert!(result.is_err());
}
#[test]
fn rendered_file_rejects_empty_content() {
let result = RenderedFile::new("file.mmd", "");
assert!(result.is_err());
}
#[test]
fn render_output_holds_multiple_files() {
let files = vec![
RenderedFile::new("overview.mmd", "graph TD;").unwrap(),
RenderedFile::new("orders.mmd", "classDiagram").unwrap(),
];
let output = RenderOutput::new(files);
assert_eq!(output.files().len(), 2);
}
#[test]
fn render_output_can_be_single_file() {
let file = RenderedFile::new("arch.mmd", "graph TD;").unwrap();
let output = RenderOutput::single(file);
assert_eq!(output.files().len(), 1);
}

View File

@@ -0,0 +1,10 @@
use archlens_domain::{FilePath, Language, SourceFile};
#[test]
fn source_file_carries_path_and_language() {
let path = FilePath::new("src/main.rs").unwrap();
let file = SourceFile::new(path.clone(), Language::Rust);
assert_eq!(file.path(), &path);
assert_eq!(file.language(), Language::Rust);
}

View File

@@ -0,0 +1,28 @@
[package]
name = "archlens"
version = "0.1.0"
edition = "2024"
publish = false
[[bin]]
name = "archlens"
path = "src/main.rs"
[dependencies]
archlens-domain.workspace = true
archlens-application.workspace = true
archlens-tree-sitter.workspace = true
archlens-walkdir.workspace = true
archlens-mermaid.workspace = true
archlens-ascii.workspace = true
archlens-file-writer.workspace = true
archlens-stdout-writer.workspace = true
archlens-toml-config.workspace = true
archlens-cargo-workspace.workspace = true
anyhow.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
tempfile.workspace = true

Some files were not shown because too many files have changed in this diff Show More