init: archlens — architecture diagram generator
Some checks failed
CI / Check / Test (push) Failing after 1m24s
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:
42
.gitea/workflows/ci.yml
Normal file
42
.gitea/workflows/ci.yml
Normal 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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
55
CONTEXT.md
Normal file
55
CONTEXT.md
Normal 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
1120
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
Cargo.toml
Normal file
59
Cargo.toml
Normal 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
21
LICENSE
Normal 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
28
Makefile
Normal 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
123
README.md
Normal 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
|
||||
10
crates/adapters/ascii/Cargo.toml
Normal file
10
crates/adapters/ascii/Cargo.toml
Normal 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
|
||||
153
crates/adapters/ascii/src/ascii_renderer.rs
Normal file
153
crates/adapters/ascii/src/ascii_renderer.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
3
crates/adapters/ascii/src/lib.rs
Normal file
3
crates/adapters/ascii/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod ascii_renderer;
|
||||
|
||||
pub use ascii_renderer::AsciiRenderer;
|
||||
48
crates/adapters/ascii/tests/ascii_renderer_tests.rs
Normal file
48
crates/adapters/ascii/tests/ascii_renderer_tests.rs
Normal 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"));
|
||||
}
|
||||
15
crates/adapters/cargo-workspace/Cargo.toml
Normal file
15
crates/adapters/cargo-workspace/Cargo.toml
Normal 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
|
||||
123
crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs
Normal file
123
crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs
Normal 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()
|
||||
}
|
||||
3
crates/adapters/cargo-workspace/src/lib.rs
Normal file
3
crates/adapters/cargo-workspace/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod cargo_workspace_analyzer;
|
||||
|
||||
pub use cargo_workspace_analyzer::CargoWorkspaceAnalyzer;
|
||||
132
crates/adapters/cargo-workspace/tests/cargo_workspace_tests.rs
Normal file
132
crates/adapters/cargo-workspace/tests/cargo_workspace_tests.rs
Normal 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");
|
||||
}
|
||||
13
crates/adapters/file-writer/Cargo.toml
Normal file
13
crates/adapters/file-writer/Cargo.toml
Normal 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
|
||||
52
crates/adapters/file-writer/src/file_output_writer.rs
Normal file
52
crates/adapters/file-writer/src/file_output_writer.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
3
crates/adapters/file-writer/src/lib.rs
Normal file
3
crates/adapters/file-writer/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod file_output_writer;
|
||||
|
||||
pub use file_output_writer::FileOutputWriter;
|
||||
35
crates/adapters/file-writer/tests/file_writer_tests.rs
Normal file
35
crates/adapters/file-writer/tests/file_writer_tests.rs
Normal 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());
|
||||
}
|
||||
10
crates/adapters/mermaid/Cargo.toml
Normal file
10
crates/adapters/mermaid/Cargo.toml
Normal 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
|
||||
3
crates/adapters/mermaid/src/lib.rs
Normal file
3
crates/adapters/mermaid/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod mermaid_renderer;
|
||||
|
||||
pub use mermaid_renderer::MermaidRenderer;
|
||||
266
crates/adapters/mermaid/src/mermaid_renderer.rs
Normal file
266
crates/adapters/mermaid/src/mermaid_renderer.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
328
crates/adapters/mermaid/tests/mermaid_renderer_tests.rs
Normal file
328
crates/adapters/mermaid/tests/mermaid_renderer_tests.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
10
crates/adapters/stdout-writer/Cargo.toml
Normal file
10
crates/adapters/stdout-writer/Cargo.toml
Normal 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
|
||||
3
crates/adapters/stdout-writer/src/lib.rs
Normal file
3
crates/adapters/stdout-writer/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod stdout_output_writer;
|
||||
|
||||
pub use stdout_output_writer::StdoutOutputWriter;
|
||||
24
crates/adapters/stdout-writer/src/stdout_output_writer.rs
Normal file
24
crates/adapters/stdout-writer/src/stdout_output_writer.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
12
crates/adapters/stdout-writer/tests/stdout_writer_tests.rs
Normal file
12
crates/adapters/stdout-writer/tests/stdout_writer_tests.rs
Normal 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());
|
||||
}
|
||||
15
crates/adapters/toml-config/Cargo.toml
Normal file
15
crates/adapters/toml-config/Cargo.toml
Normal 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
|
||||
3
crates/adapters/toml-config/src/lib.rs
Normal file
3
crates/adapters/toml-config/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod toml_config_loader;
|
||||
|
||||
pub use toml_config_loader::TomlConfigLoader;
|
||||
81
crates/adapters/toml-config/src/toml_config_loader.rs
Normal file
81
crates/adapters/toml-config/src/toml_config_loader.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
68
crates/adapters/toml-config/tests/toml_config_tests.rs
Normal file
68
crates/adapters/toml-config/tests/toml_config_tests.rs
Normal 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());
|
||||
}
|
||||
17
crates/adapters/tree-sitter/Cargo.toml
Normal file
17
crates/adapters/tree-sitter/Cargo.toml
Normal 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
|
||||
5
crates/adapters/tree-sitter/src/lib.rs
Normal file
5
crates/adapters/tree-sitter/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod python;
|
||||
mod rust;
|
||||
mod tree_sitter_analyzer;
|
||||
|
||||
pub use tree_sitter_analyzer::TreeSitterAnalyzer;
|
||||
337
crates/adapters/tree-sitter/src/python/mod.rs
Normal file
337
crates/adapters/tree-sitter/src/python/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
349
crates/adapters/tree-sitter/src/rust/mod.rs
Normal file
349
crates/adapters/tree-sitter/src/rust/mod.rs
Normal 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
|
||||
}
|
||||
30
crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs
Normal file
30
crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
138
crates/adapters/tree-sitter/tests/python_analyzer_tests.rs
Normal file
138
crates/adapters/tree-sitter/tests/python_analyzer_tests.rs
Normal 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");
|
||||
}
|
||||
130
crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs
Normal file
130
crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs
Normal 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"));
|
||||
}
|
||||
15
crates/adapters/walkdir/Cargo.toml
Normal file
15
crates/adapters/walkdir/Cargo.toml
Normal 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
|
||||
3
crates/adapters/walkdir/src/lib.rs
Normal file
3
crates/adapters/walkdir/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod walkdir_discovery;
|
||||
|
||||
pub use walkdir_discovery::WalkdirDiscovery;
|
||||
100
crates/adapters/walkdir/src/walkdir_discovery.rs
Normal file
100
crates/adapters/walkdir/src/walkdir_discovery.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
71
crates/adapters/walkdir/tests/walkdir_discovery_tests.rs
Normal file
71
crates/adapters/walkdir/tests/walkdir_discovery_tests.rs
Normal 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());
|
||||
}
|
||||
11
crates/application/Cargo.toml
Normal file
11
crates/application/Cargo.toml
Normal 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
|
||||
1
crates/application/src/lib.rs
Normal file
1
crates/application/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod queries;
|
||||
244
crates/application/src/queries/analyze_codebase.rs
Normal file
244
crates/application/src/queries/analyze_codebase.rs
Normal 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
|
||||
}
|
||||
}
|
||||
5
crates/application/src/queries/mod.rs
Normal file
5
crates/application/src/queries/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod analyze_codebase;
|
||||
mod render_diagrams;
|
||||
|
||||
pub use analyze_codebase::{AnalyzeCodebase, AnalyzeCodebaseResult};
|
||||
pub use render_diagrams::RenderDiagrams;
|
||||
45
crates/application/src/queries/render_diagrams.rs
Normal file
45
crates/application/src/queries/render_diagrams.rs
Normal 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
|
||||
}
|
||||
}
|
||||
344
crates/application/tests/analyze_codebase_tests.rs
Normal file
344
crates/application/tests/analyze_codebase_tests.rs
Normal 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");
|
||||
}
|
||||
17
crates/application/tests/fakes/diagram_renderer.rs
Normal file
17
crates/application/tests/fakes/diagram_renderer.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
27
crates/application/tests/fakes/file_discovery.rs
Normal file
27
crates/application/tests/fakes/file_discovery.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
9
crates/application/tests/fakes/mod.rs
Normal file
9
crates/application/tests/fakes/mod.rs
Normal 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;
|
||||
26
crates/application/tests/fakes/output_writer.rs
Normal file
26
crates/application/tests/fakes/output_writer.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
43
crates/application/tests/fakes/source_analyzer.rs
Normal file
43
crates/application/tests/fakes/source_analyzer.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
78
crates/application/tests/render_diagrams_tests.rs
Normal file
78
crates/application/tests/render_diagrams_tests.rs
Normal 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
8
crates/domain/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "archlens-domain"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
78
crates/domain/src/aggregates/code_graph.rs
Normal file
78
crates/domain/src/aggregates/code_graph.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
3
crates/domain/src/aggregates/mod.rs
Normal file
3
crates/domain/src/aggregates/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod code_graph;
|
||||
|
||||
pub use code_graph::CodeGraph;
|
||||
111
crates/domain/src/entities/code_element.rs
Normal file
111
crates/domain/src/entities/code_element.rs
Normal 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
|
||||
}
|
||||
}
|
||||
5
crates/domain/src/entities/mod.rs
Normal file
5
crates/domain/src/entities/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod code_element;
|
||||
mod relationship;
|
||||
|
||||
pub use code_element::CodeElement;
|
||||
pub use relationship::Relationship;
|
||||
49
crates/domain/src/entities/relationship.rs
Normal file
49
crates/domain/src/entities/relationship.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
14
crates/domain/src/error.rs
Normal file
14
crates/domain/src/error.rs
Normal 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
14
crates/domain/src/lib.rs
Normal 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};
|
||||
6
crates/domain/src/ports/config_loader.rs
Normal file
6
crates/domain/src/ports/config_loader.rs
Normal 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>;
|
||||
}
|
||||
5
crates/domain/src/ports/diagram_renderer.rs
Normal file
5
crates/domain/src/ports/diagram_renderer.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use crate::{CodeGraph, DomainError, RenderOutput};
|
||||
|
||||
pub trait DiagramRenderer {
|
||||
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError>;
|
||||
}
|
||||
9
crates/domain/src/ports/file_discovery.rs
Normal file
9
crates/domain/src/ports/file_discovery.rs
Normal 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>;
|
||||
}
|
||||
13
crates/domain/src/ports/mod.rs
Normal file
13
crates/domain/src/ports/mod.rs
Normal 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;
|
||||
5
crates/domain/src/ports/output_writer.rs
Normal file
5
crates/domain/src/ports/output_writer.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use crate::{DomainError, RenderOutput};
|
||||
|
||||
pub trait OutputWriter {
|
||||
fn write(&self, output: &RenderOutput) -> Result<(), DomainError>;
|
||||
}
|
||||
7
crates/domain/src/ports/project_analyzer.rs
Normal file
7
crates/domain/src/ports/project_analyzer.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{CodeGraph, DomainError};
|
||||
|
||||
pub trait ProjectAnalyzer {
|
||||
fn analyze(&self, root: &Path) -> Result<CodeGraph, DomainError>;
|
||||
}
|
||||
5
crates/domain/src/ports/source_analyzer.rs
Normal file
5
crates/domain/src/ports/source_analyzer.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use crate::{AnalysisResult, DomainError, SourceFile};
|
||||
|
||||
pub trait SourceAnalyzer: Send + Sync {
|
||||
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError>;
|
||||
}
|
||||
60
crates/domain/src/value_objects/analysis/analysis_config.rs
Normal file
60
crates/domain/src/value_objects/analysis/analysis_config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
42
crates/domain/src/value_objects/analysis/analysis_result.rs
Normal file
42
crates/domain/src/value_objects/analysis/analysis_result.rs
Normal 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
|
||||
}
|
||||
}
|
||||
34
crates/domain/src/value_objects/analysis/analysis_warning.rs
Normal file
34
crates/domain/src/value_objects/analysis/analysis_warning.rs
Normal 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
|
||||
}
|
||||
}
|
||||
7
crates/domain/src/value_objects/analysis/mod.rs
Normal file
7
crates/domain/src/value_objects/analysis/mod.rs
Normal 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;
|
||||
@@ -0,0 +1,9 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CodeElementKind {
|
||||
Class,
|
||||
Struct,
|
||||
Trait,
|
||||
Interface,
|
||||
Enum,
|
||||
Project,
|
||||
}
|
||||
7
crates/domain/src/value_objects/graph/mod.rs
Normal file
7
crates/domain/src/value_objects/graph/mod.rs
Normal 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;
|
||||
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum RelationshipKind {
|
||||
Inheritance,
|
||||
Composition,
|
||||
Import,
|
||||
}
|
||||
6
crates/domain/src/value_objects/graph/visibility.rs
Normal file
6
crates/domain/src/value_objects/graph/visibility.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Visibility {
|
||||
Public,
|
||||
Private,
|
||||
Internal,
|
||||
}
|
||||
4
crates/domain/src/value_objects/mod.rs
Normal file
4
crates/domain/src/value_objects/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod analysis;
|
||||
pub mod graph;
|
||||
pub mod output;
|
||||
pub mod source;
|
||||
6
crates/domain/src/value_objects/output/diagram_level.rs
Normal file
6
crates/domain/src/value_objects/output/diagram_level.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DiagramLevel {
|
||||
Project,
|
||||
Module,
|
||||
Type,
|
||||
}
|
||||
9
crates/domain/src/value_objects/output/mod.rs
Normal file
9
crates/domain/src/value_objects/output/mod.rs
Normal 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;
|
||||
25
crates/domain/src/value_objects/output/output_config.rs
Normal file
25
crates/domain/src/value_objects/output/output_config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
20
crates/domain/src/value_objects/output/render_output.rs
Normal file
20
crates/domain/src/value_objects/output/render_output.rs
Normal 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
|
||||
}
|
||||
}
|
||||
32
crates/domain/src/value_objects/output/rendered_file.rs
Normal file
32
crates/domain/src/value_objects/output/rendered_file.rs
Normal 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
|
||||
}
|
||||
}
|
||||
18
crates/domain/src/value_objects/source/file_path.rs
Normal file
18
crates/domain/src/value_objects/source/file_path.rs
Normal 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
|
||||
}
|
||||
}
|
||||
16
crates/domain/src/value_objects/source/language.rs
Normal file
16
crates/domain/src/value_objects/source/language.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/domain/src/value_objects/source/mod.rs
Normal file
9
crates/domain/src/value_objects/source/mod.rs
Normal 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;
|
||||
18
crates/domain/src/value_objects/source/module_name.rs
Normal file
18
crates/domain/src/value_objects/source/module_name.rs
Normal 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
|
||||
}
|
||||
}
|
||||
21
crates/domain/src/value_objects/source/source_file.rs
Normal file
21
crates/domain/src/value_objects/source/source_file.rs
Normal 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
|
||||
}
|
||||
}
|
||||
39
crates/domain/tests/analysis_result_tests.rs
Normal file
39
crates/domain/tests/analysis_result_tests.rs
Normal 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());
|
||||
}
|
||||
21
crates/domain/tests/analysis_warning_tests.rs
Normal file
21
crates/domain/tests/analysis_warning_tests.rs
Normal 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());
|
||||
}
|
||||
107
crates/domain/tests/code_element_tests.rs
Normal file
107
crates/domain/tests/code_element_tests.rs
Normal 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;
|
||||
}
|
||||
130
crates/domain/tests/code_graph_tests.rs
Normal file
130
crates/domain/tests/code_graph_tests.rs
Normal 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"));
|
||||
}
|
||||
37
crates/domain/tests/config_tests.rs
Normal file
37
crates/domain/tests/config_tests.rs
Normal 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;
|
||||
}
|
||||
29
crates/domain/tests/file_path_tests.rs
Normal file
29
crates/domain/tests/file_path_tests.rs
Normal 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);
|
||||
}
|
||||
18
crates/domain/tests/language_tests.rs
Normal file
18
crates/domain/tests/language_tests.rs
Normal 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);
|
||||
}
|
||||
23
crates/domain/tests/module_name_tests.rs
Normal file
23
crates/domain/tests/module_name_tests.rs
Normal 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);
|
||||
}
|
||||
37
crates/domain/tests/render_output_tests.rs
Normal file
37
crates/domain/tests/render_output_tests.rs
Normal 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);
|
||||
}
|
||||
10
crates/domain/tests/source_file_tests.rs
Normal file
10
crates/domain/tests/source_file_tests.rs
Normal 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);
|
||||
}
|
||||
28
crates/presentation/Cargo.toml
Normal file
28
crates/presentation/Cargo.toml
Normal 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
Reference in New Issue
Block a user