feat: implement all P1/P2/P3/P4 improvements from issue backlog
Some checks failed
CI / Check / Test (push) Failing after 1m33s
Architecture Docs / Generate diagrams (push) Successful in 3m21s

P1 correctness:
- filter test files by default (--include-tests to opt in)
- per-module diagrams show cross-module dependency arrows
- qualified type names (Module::TypeName) fix false edges from duplicate names

P2 output richness:
- method parameter types and return types in class diagrams (Rust + Python)
- Python pyproject.toml project analyzer (--level project for monorepos)

P3 unique value:
- boundary rules in archlens.toml ([rules] allow/deny, --strict enforcement)

P4 nice to have:
- dependency weight labels on module arrows (--no-weights to disable)
- --watch mode with 500ms debounce
- D2 renderer adapter (--format d2)
- interactive self-contained HTML viewer (--format html)
- git-aware incremental analysis (--since <ref>)
This commit is contained in:
2026-06-17 09:50:50 +02:00
parent 27197062eb
commit fdd85011a4
42 changed files with 2767 additions and 92 deletions

246
Cargo.lock generated
View File

@@ -47,7 +47,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -75,14 +75,18 @@ dependencies = [
"archlens-application", "archlens-application",
"archlens-ascii", "archlens-ascii",
"archlens-cargo-workspace", "archlens-cargo-workspace",
"archlens-d2",
"archlens-domain", "archlens-domain",
"archlens-file-writer", "archlens-file-writer",
"archlens-html",
"archlens-mermaid", "archlens-mermaid",
"archlens-python-project",
"archlens-stdout-writer", "archlens-stdout-writer",
"archlens-toml-config", "archlens-toml-config",
"archlens-tree-sitter", "archlens-tree-sitter",
"archlens-walkdir", "archlens-walkdir",
"clap", "clap",
"notify",
"tempfile", "tempfile",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -119,6 +123,14 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "archlens-d2"
version = "0.1.0"
dependencies = [
"archlens-domain",
"tempfile",
]
[[package]] [[package]]
name = "archlens-domain" name = "archlens-domain"
version = "0.1.0" version = "0.1.0"
@@ -136,6 +148,16 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "archlens-html"
version = "0.1.0"
dependencies = [
"archlens-domain",
"serde",
"serde_json",
"tempfile",
]
[[package]] [[package]]
name = "archlens-mermaid" name = "archlens-mermaid"
version = "0.1.0" version = "0.1.0"
@@ -145,6 +167,16 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "archlens-python-project"
version = "0.1.0"
dependencies = [
"archlens-domain",
"serde",
"tempfile",
"toml",
]
[[package]] [[package]]
name = "archlens-stdout-writer" name = "archlens-stdout-writer"
version = "0.1.0" version = "0.1.0"
@@ -192,6 +224,12 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.0"
@@ -314,7 +352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -323,6 +361,16 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -335,6 +383,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.2" version = "0.4.2"
@@ -416,6 +473,35 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "inotify"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@@ -428,6 +514,26 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kqueue"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.13.0",
"libc",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -473,13 +579,54 @@ version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "notify"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
dependencies = [
"bitflags 2.13.0",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.52.0",
]
[[package]]
name = "notify-types"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
dependencies = [
"instant",
"serde",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -589,11 +736,11 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.0",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -717,7 +864,7 @@ dependencies = [
"getrandom", "getrandom",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -934,6 +1081,12 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.4+wasi-0.2.12" version = "1.0.4+wasi-0.2.12"
@@ -980,7 +1133,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.13.0",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap", "indexmap",
"semver", "semver",
@@ -992,7 +1145,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1001,6 +1154,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -1010,6 +1172,70 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.15" version = "0.7.15"
@@ -1083,7 +1309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags", "bitflags 2.13.0",
"indexmap", "indexmap",
"log", "log",
"serde", "serde",

View File

@@ -12,6 +12,9 @@ members = [
"crates/adapters/stdout-writer", "crates/adapters/stdout-writer",
"crates/adapters/toml-config", "crates/adapters/toml-config",
"crates/adapters/cargo-workspace", "crates/adapters/cargo-workspace",
"crates/adapters/python-project",
"crates/adapters/d2",
"crates/adapters/html-viewer",
] ]
[workspace.dependencies] [workspace.dependencies]
@@ -26,6 +29,10 @@ archlens-file-writer = { path = "crates/adapters/file-writer" }
archlens-stdout-writer = { path = "crates/adapters/stdout-writer" } archlens-stdout-writer = { path = "crates/adapters/stdout-writer" }
archlens-toml-config = { path = "crates/adapters/toml-config" } archlens-toml-config = { path = "crates/adapters/toml-config" }
archlens-cargo-workspace = { path = "crates/adapters/cargo-workspace" } archlens-cargo-workspace = { path = "crates/adapters/cargo-workspace" }
archlens-python-project = { path = "crates/adapters/python-project" }
archlens-d2 = { path = "crates/adapters/d2" }
archlens-html = { path = "crates/adapters/html-viewer" }
serde_json = "1"
# Error handling # Error handling
thiserror = "2" thiserror = "2"
@@ -55,5 +62,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Parallelism # Parallelism
rayon = "1" rayon = "1"
# File watching
notify = { version = "7", features = ["serde"] }
# Testing # Testing
tempfile = "3" tempfile = "3"

View File

@@ -2,7 +2,7 @@
Generate architecture diagrams from source code. Runs on CI to keep docs fresh. Generate architecture diagrams from source code. Runs on CI to keep docs fresh.
Supports Rust and Python. Produces Mermaid or ASCII output. Supports Rust, Python and C# (planned). Produces Mermaid, D2, ASCII, or interactive HTML output.
## Install ## Install
@@ -28,6 +28,12 @@ archlens . --output docs/architecture.mmd
# Split by module (one file per module + overview) # Split by module (one file per module + overview)
archlens . --level type --split-by-module --output docs/arch/ archlens . --level type --split-by-module --output docs/arch/
# D2 format
archlens . --format d2 --output docs/architecture.d2
# Interactive HTML viewer
archlens . --format html --output docs/architecture.html
# ASCII output to terminal # ASCII output to terminal
archlens . --format ascii archlens . --format ascii
@@ -37,6 +43,18 @@ archlens . --scope src/domain
# Exclude directories # Exclude directories
archlens . --exclude tests/ --exclude generated/ archlens . --exclude tests/ --exclude generated/
# Exclude test files from diagrams (default: excluded)
archlens . --include-tests
# Show/hide dependency weights on module arrows (default: shown)
archlens . --level module --no-weights
# Watch for changes and regenerate automatically
archlens . --watch
# Analyse only files changed since a git ref (useful in CI)
archlens . --since HEAD~1
# Verbose logging # Verbose logging
archlens . -v # info archlens . -v # info
archlens . -vv # debug archlens . -vv # debug
@@ -50,7 +68,13 @@ Check if committed diagrams are up to date:
archlens . --level project --check --output docs/architecture.mmd archlens . --level project --check --output docs/architecture.mmd
``` ```
Exit code 1 if the diagram has changed. Use `--strict` to also fail on parse warnings. Exit code 1 if the diagram has changed. Use `--strict` to also fail on parse warnings or boundary rule violations.
Only re-analyse files changed since the last release tag:
```bash
archlens . --since v1.2.0 --output docs/architecture.mmd
```
Compare current state against an existing file: Compare current state against an existing file:
@@ -80,23 +104,41 @@ level = "module"
format = "mermaid" format = "mermaid"
# path = "docs/architecture.mmd" # path = "docs/architecture.mmd"
split_by_module = false split_by_module = false
[rules]
# Allowed dependency directions — unlisted directions are violations
# allow = ["Application --> Domain", "Adapters --> Domain"]
# Explicitly forbidden directions — always checked
# deny = ["Domain --> Adapters", "Domain --> Application"]
``` ```
Violations are printed to stderr. Pass `--strict` to exit with code 1 on any violation.
## Diagram Levels ## Diagram Levels
| Level | What it shows | Source | | Level | What it shows | Source |
|-------|--------------|--------| |-------|--------------|--------|
| `project` | Crate/package dependencies | `Cargo.toml` | | `project` | Crate/package dependencies | `Cargo.toml` or `pyproject.toml` |
| `module` | Module-level dependency graph | Imports + manifest deps | | `module` | Module-level dependency graph with coupling weights | Imports + manifest deps |
| `type` | Class diagram with fields, methods, relationships | Source code (tree-sitter) | | `type` | Class diagram with fields, methods, signatures, relationships | Source code (tree-sitter) |
## Output Formats
| Format | Flag | Extension | Notes |
|--------|------|-----------|-------|
| Mermaid | `--format mermaid` | `.mmd` | Default. Renders in GitHub, GitLab, Obsidian |
| D2 | `--format d2` | `.d2` | Better layout control, renders to SVG |
| ASCII | `--format ascii` | `.txt` | Terminal-friendly |
| HTML | `--format html` | `.html` | Self-contained interactive viewer, clickable nodes |
## Supported Languages ## Supported Languages
| Language | Types | Inheritance | Composition | Imports | | Language | Types | Inheritance | Composition | Imports | Method signatures |
|----------|-------|-------------|-------------|---------| |----------|-------|-------------|-------------|---------|-------------------|
| Rust | struct, enum, trait | `impl Trait for Type` | struct fields | `use`, `mod` | | Rust | struct, enum, trait | `impl Trait for Type` | struct fields | `use`, `mod` | params + return type |
| Python | class | `class Foo(Bar)` | `__init__` params, type annotations | `import`, `from ... import` | | Python | class | `class Foo(Bar)` | `__init__` params, type annotations | `import`, `from ... import` | typed params + return annotation |
| C# | planned | - | - | - | | C# | planned | - | - | - | - |
## Architecture ## Architecture
@@ -109,9 +151,12 @@ crates/
adapters/ adapters/
tree-sitter/ # Source code parsing (Rust, Python) tree-sitter/ # Source code parsing (Rust, Python)
cargo-workspace/ # Cargo.toml dependency extraction cargo-workspace/ # Cargo.toml dependency extraction
python-project/ # pyproject.toml dependency extraction
walkdir/ # File discovery walkdir/ # File discovery
mermaid/ # Mermaid diagram output mermaid/ # Mermaid diagram output
d2/ # D2 diagram output
ascii/ # Terminal output ascii/ # Terminal output
html-viewer/ # Interactive HTML output
file-writer/ # Write to disk file-writer/ # Write to disk
stdout-writer/ # Write to stdout stdout-writer/ # Write to stdout
toml-config/ # Config file parsing toml-config/ # Config file parsing

View File

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

View File

@@ -0,0 +1,173 @@
use archlens_domain::{
CodeGraph, DiagramLevel, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer,
};
pub struct D2Renderer {
level: DiagramLevel,
}
impl Default for D2Renderer {
fn default() -> Self {
Self::new()
}
}
impl D2Renderer {
pub fn new() -> Self {
Self {
level: DiagramLevel::Type,
}
}
pub fn with_level(level: DiagramLevel) -> Self {
Self { level }
}
}
impl DiagramRenderer for D2Renderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let content = match self.level {
DiagramLevel::Type => render_type(graph),
DiagramLevel::Module => render_module(graph),
DiagramLevel::Project => render_project(graph),
};
let file = RenderedFile::new("diagram.d2", &content)?;
Ok(RenderOutput::single(file))
}
}
fn sanitize(name: &str) -> String {
name.replace("::", "_").replace(['-', ' '], "_")
}
fn render_type(graph: &CodeGraph) -> String {
let mut lines = Vec::new();
let (by_module, ungrouped) = graph.elements_by_module();
// Grouped by module
for (module, elements) in &by_module {
let mod_id = sanitize(module);
lines.push(format!("{mod_id}: {{"));
for el in elements {
let el_id = sanitize(el.name());
lines.push(format!(" {el_id}: {{"));
lines.push(" shape: class".to_string());
for field in el.fields() {
lines.push(format!(" {field}"));
}
for method in el.methods() {
let method_display = method.trim_start_matches(['+', '-']);
lines.push(format!(
" {}()",
method_display.split('(').next().unwrap_or(method_display)
));
}
lines.push(" }".to_string());
}
lines.push("}".to_string());
}
// Ungrouped elements
for el in &ungrouped {
let el_id = sanitize(el.name());
lines.push(format!("{el_id}: {{"));
lines.push(" shape: class".to_string());
lines.push("}".to_string());
}
// Relationships
for rel in graph.relationships() {
use archlens_domain::RelationshipKind;
let src = sanitize(rel.source());
let tgt = sanitize(rel.target());
let arrow = match rel.kind() {
RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"),
RelationshipKind::Composition => format!("{src} -> {tgt}"),
RelationshipKind::Import => continue,
};
lines.push(arrow);
}
lines.join("\n")
}
fn render_module(graph: &CodeGraph) -> String {
use archlens_domain::RelationshipKind;
use std::collections::{HashMap, HashSet};
let mut lines = Vec::new();
let mut modules: HashSet<String> = HashSet::new();
let mut name_to_module: HashMap<&str, &str> = HashMap::new();
for el in graph.elements() {
if let Some(m) = el.module() {
modules.insert(m.as_str().to_string());
name_to_module.insert(el.qualified_name(), m.as_str());
name_to_module.insert(el.name(), m.as_str());
}
}
for module in &modules {
let id = sanitize(module);
lines.push(format!("{id}: {module}"));
}
let mut edges: HashSet<(String, String)> = HashSet::new();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
let src_mod = name_to_module.get(rel.source());
let tgt_mod = name_to_module.get(rel.target());
if let (Some(s), Some(t)) = (src_mod, tgt_mod)
&& s != t
{
edges.insert((s.to_string(), t.to_string()));
}
}
for (src, tgt) in &edges {
lines.push(format!("{} -> {}", sanitize(src), sanitize(tgt)));
}
lines.join("\n")
}
fn render_project(graph: &CodeGraph) -> String {
use archlens_domain::RelationshipKind;
use std::collections::HashMap;
let mut lines = Vec::new();
let (by_module, ungrouped) = graph.elements_by_module();
for (module, elements) in &by_module {
let mod_id = sanitize(module);
lines.push(format!("{mod_id}: {{"));
for el in elements {
lines.push(format!(" {}: {}", sanitize(el.name()), el.name()));
}
lines.push("}".to_string());
}
for el in &ungrouped {
lines.push(format!("{}: {}", sanitize(el.name()), el.name()));
}
let name_to_id: HashMap<&str, String> = graph
.elements()
.iter()
.map(|e| (e.name(), sanitize(e.name())))
.collect();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
if let (Some(src), Some(tgt)) = (name_to_id.get(rel.source()), name_to_id.get(rel.target()))
{
lines.push(format!("{src} -> {tgt}"));
}
}
lines.join("\n")
}

View File

@@ -0,0 +1,2 @@
mod d2_renderer;
pub use d2_renderer::D2Renderer;

View File

@@ -0,0 +1,110 @@
use archlens_d2::D2Renderer;
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, DiagramLevel, FilePath, ModuleName, Relationship,
RelationshipKind, ports::DiagramRenderer,
};
fn make_el(name: &str, module: Option<&str>) -> CodeElement {
let mut el = CodeElement::new(
name,
CodeElementKind::Class,
FilePath::new(&format!("src/{name}.rs")).unwrap(),
1,
)
.unwrap();
if let Some(m) = module {
el = el.with_module(ModuleName::new(m).unwrap());
}
el
}
#[test]
fn type_level_emits_class_shapes() {
let mut graph = CodeGraph::new();
graph.add_element(make_el("OrderService", Some("App")));
graph.add_element(make_el("Order", Some("Domain")));
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
let graph = graph.qualify();
let renderer = D2Renderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(
content.contains("shape: class"),
"expected class shape: {content}"
);
assert!(
content.contains("OrderService"),
"expected OrderService: {content}"
);
assert!(content.contains("Order"), "expected Order: {content}");
}
#[test]
fn module_level_emits_module_nodes_and_edges() {
let mut graph = CodeGraph::new();
graph.add_element(make_el("Service", Some("App")));
graph.add_element(make_el("Order", Some("Domain")));
graph.add_relationship(
Relationship::new("Service", "Order", RelationshipKind::Composition).unwrap(),
);
let graph = graph.qualify();
let renderer = D2Renderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("App"), "expected App module: {content}");
assert!(
content.contains("Domain"),
"expected Domain module: {content}"
);
assert!(
content.contains("->"),
"expected dependency arrow: {content}"
);
}
#[test]
fn project_level_groups_by_module() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"my-api",
CodeElementKind::Project,
FilePath::new("api/pyproject.toml").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Backend").unwrap()),
);
graph.add_element(
CodeElement::new(
"my-commons",
CodeElementKind::Project,
FilePath::new("commons/pyproject.toml").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("my-api", "my-commons", RelationshipKind::Composition).unwrap(),
);
let renderer = D2Renderer::with_level(DiagramLevel::Project);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(
content.contains("Backend"),
"expected Backend group: {content}"
);
assert!(
content.contains("my-api") || content.contains("my_api"),
"expected my-api: {content}"
);
assert!(content.contains("->"), "expected dep arrow: {content}");
}

View File

@@ -0,0 +1,13 @@
[package]
name = "archlens-html"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
serde.workspace = true
serde_json = "1"
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,240 @@
use std::collections::HashMap;
use serde::Serialize;
use archlens_domain::{
CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile, ports::DiagramRenderer,
};
pub struct HtmlRenderer;
impl Default for HtmlRenderer {
fn default() -> Self {
Self::new()
}
}
impl HtmlRenderer {
pub fn new() -> Self {
Self
}
}
#[derive(Serialize)]
struct GraphData {
nodes: Vec<NodeData>,
edges: Vec<EdgeData>,
}
#[derive(Serialize)]
struct NodeData {
id: String,
label: String,
module: String,
kind: String,
fields: Vec<String>,
methods: Vec<String>,
}
#[derive(Serialize)]
struct EdgeData {
source: String,
target: String,
kind: String,
}
impl DiagramRenderer for HtmlRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
// Build graph data
let mut id_map: HashMap<String, String> = HashMap::new();
let mut nodes = Vec::new();
for (i, el) in graph.elements().iter().enumerate() {
let id = format!("n{i}");
id_map.insert(el.qualified_name().to_string(), id.clone());
id_map.insert(el.name().to_string(), id.clone());
nodes.push(NodeData {
id,
label: el.name().to_string(),
module: el
.module()
.map(|m| m.as_str().to_string())
.unwrap_or_default(),
kind: format!("{:?}", el.kind()),
fields: el.fields().to_vec(),
methods: el.methods().to_vec(),
});
}
let edges = graph
.relationships()
.iter()
.filter(|r| r.kind() != RelationshipKind::Import)
.filter_map(|r| {
let src = id_map.get(r.source())?;
let tgt = id_map.get(r.target())?;
Some(EdgeData {
source: src.clone(),
target: tgt.clone(),
kind: format!("{:?}", r.kind()),
})
})
.collect();
let data = GraphData { nodes, edges };
let json =
serde_json::to_string(&data).map_err(|e| DomainError::ConfigError(e.to_string()))?;
let html = build_html(&json);
let file = RenderedFile::new("diagram.html", &html)?;
Ok(RenderOutput::single(file))
}
}
fn build_html(graph_json: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Architecture Diagram</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: sans-serif; background: #1a1a2e; color: #eee; display: flex; height: 100vh; }}
#sidebar {{ width: 280px; background: #16213e; padding: 1rem; overflow-y: auto; border-right: 1px solid #0f3460; }}
#cy {{ flex: 1; }}
h2 {{ color: #e94560; margin-bottom: 0.5rem; font-size: 1rem; }}
#detail {{ padding: 0.5rem 0; font-size: 0.85rem; }}
.member {{ padding: 2px 0; color: #aaa; }}
</style>
</head>
<body>
<div id="sidebar">
<h2>Architecture Diagram</h2>
<div id="detail"><p>Click a node to see details.</p></div>
</div>
<div id="cy"></div>
<script>
const GRAPH = {graph_json};
// Inline minimal Cytoscape-compatible renderer using Canvas API
(function() {{
const canvas = document.createElement('canvas');
const container = document.getElementById('cy');
canvas.style.width = '100%';
canvas.style.height = '100%';
container.appendChild(canvas);
const detail = document.getElementById('detail');
function resize() {{
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
draw();
}}
// Group nodes by module
const modules = {{}};
GRAPH.nodes.forEach(n => {{
const m = n.module || '(ungrouped)';
if (!modules[m]) modules[m] = [];
modules[m].push(n);
}});
// Layout: arrange modules in a grid, nodes within each module in a column
const positions = {{}};
const modNames = Object.keys(modules);
const cols = Math.ceil(Math.sqrt(modNames.length));
const cellW = 220, cellH = 200, pad = 60;
modNames.forEach((mod, mi) => {{
const col = mi % cols, row = Math.floor(mi / cols);
const bx = pad + col * (cellW + pad);
const by = pad + row * (cellH + pad);
modules[mod].forEach((n, ni) => {{
positions[n.id] = {{
x: bx + 20 + (ni % 2) * 90,
y: by + 30 + Math.floor(ni / 2) * 50
}};
}});
}});
let selected = null;
const nodeRadius = 18;
function draw() {{
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw module backgrounds
modNames.forEach((mod, mi) => {{
const col = mi % cols, row = Math.floor(mi / cols);
const bx = pad / 2 + col * (cellW + pad);
const by = pad / 2 + row * (cellH + pad);
ctx.fillStyle = 'rgba(15,52,96,0.4)';
ctx.fillRect(bx, by, cellW + pad / 2, cellH + pad / 2);
ctx.fillStyle = '#4fc3f7';
ctx.font = '11px sans-serif';
ctx.fillText(mod, bx + 6, by + 14);
}});
// Draw edges
GRAPH.edges.forEach(e => {{
const sp = positions[e.source], tp = positions[e.target];
if (!sp || !tp) return;
ctx.strokeStyle = e.kind === 'Inheritance' ? '#e94560' : '#aaa';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(sp.x, sp.y);
ctx.lineTo(tp.x, tp.y);
ctx.stroke();
}});
// Draw nodes
GRAPH.nodes.forEach(n => {{
const p = positions[n.id];
if (!p) return;
ctx.fillStyle = selected && selected.id === n.id ? '#e94560' : '#0f3460';
ctx.beginPath();
ctx.arc(p.x, p.y, nodeRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#eee';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(n.label.substring(0, 12), p.x, p.y + 4);
}});
}}
canvas.addEventListener('click', e => {{
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
selected = null;
for (const n of GRAPH.nodes) {{
const p = positions[n.id];
if (!p) continue;
const dx = mx - p.x, dy = my - p.y;
if (dx * dx + dy * dy < nodeRadius * nodeRadius) {{
selected = n;
break;
}}
}}
if (selected) {{
detail.innerHTML = `<strong>${{selected.label}}</strong><br><em>${{selected.module}}</em>` +
(selected.fields.length ? '<br><b>Fields:</b><br>' + selected.fields.map(f => `<div class="member">${{f}}</div>`).join('') : '') +
(selected.methods.length ? '<br><b>Methods:</b><br>' + selected.methods.map(m => `<div class="member">${{m}}</div>`).join('') : '');
}} else {{
detail.innerHTML = '<p>Click a node to see details.</p>';
}}
draw();
}});
window.addEventListener('resize', resize);
resize();
}})();
</script>
</body>
</html>"#,
graph_json = graph_json
)
}

View File

@@ -0,0 +1,2 @@
mod html_renderer;
pub use html_renderer::HtmlRenderer;

View File

@@ -0,0 +1,78 @@
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, Relationship, RelationshipKind,
ports::DiagramRenderer,
};
use archlens_html::HtmlRenderer;
fn make_graph() -> CodeGraph {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/app/s.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("App").unwrap()),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Class,
FilePath::new("src/domain/o.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Domain").unwrap()),
);
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
graph.qualify()
}
#[test]
fn html_output_is_self_contained() {
let renderer = HtmlRenderer::new();
let output = renderer.render(&make_graph()).unwrap();
let content = output.files()[0].content();
assert!(
content.starts_with("<!DOCTYPE html>"),
"should be full HTML doc"
);
assert!(content.contains("</html>"), "should be complete HTML");
assert!(
!content.contains("src="),
"should not have external src= links"
);
}
#[test]
fn html_output_embeds_graph_data_as_json() {
let renderer = HtmlRenderer::new();
let output = renderer.render(&make_graph()).unwrap();
let content = output.files()[0].content();
assert!(
content.contains("OrderService"),
"graph data should contain node names"
);
assert!(
content.contains("Domain"),
"graph data should contain module names"
);
}
#[test]
fn html_output_includes_interactive_js() {
let renderer = HtmlRenderer::new();
let output = renderer.render(&make_graph()).unwrap();
let content = output.files()[0].content();
assert!(
content.contains("cytoscape") || content.contains("graph") || content.contains("nodes"),
"should include graph visualization JS"
);
}

View File

@@ -7,6 +7,7 @@ use archlens_domain::{
pub struct MermaidRenderer { pub struct MermaidRenderer {
level: DiagramLevel, level: DiagramLevel,
show_weights: bool,
} }
impl Default for MermaidRenderer { impl Default for MermaidRenderer {
@@ -19,11 +20,24 @@ impl MermaidRenderer {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
level: DiagramLevel::Type, level: DiagramLevel::Type,
show_weights: true,
} }
} }
pub fn with_level(level: DiagramLevel) -> Self { pub fn with_level(level: DiagramLevel) -> Self {
Self { level } Self {
level,
show_weights: true,
}
}
pub fn with_weights(mut self, show: bool) -> Self {
self.show_weights = show;
self
}
fn display_name(qualified: &str) -> &str {
qualified.split("::").last().unwrap_or(qualified)
} }
fn format_element_name(element: &CodeElement) -> String { fn format_element_name(element: &CodeElement) -> String {
@@ -89,7 +103,9 @@ impl MermaidRenderer {
RelationshipKind::Composition => "-->", RelationshipKind::Composition => "-->",
RelationshipKind::Import => "..>", RelationshipKind::Import => "..>",
}; };
let key = format!("{} {} {}", rel.source(), arrow, rel.target()); let src = Self::display_name(rel.source());
let tgt = Self::display_name(rel.target());
let key = format!("{} {} {}", src, arrow, tgt);
if rel_seen.insert(key.clone()) { if rel_seen.insert(key.clone()) {
lines.push(format!(" {key}")); lines.push(format!(" {key}"));
} }
@@ -136,10 +152,15 @@ impl MermaidRenderer {
for element in graph.elements() { for element in graph.elements() {
if let Some(module) = element.module() { if let Some(module) = element.module() {
// Index both bare name and qualified name for lookup
name_to_modules name_to_modules
.entry(element.name()) .entry(element.name())
.or_default() .or_default()
.insert(module.as_str()); .insert(module.as_str());
name_to_modules
.entry(element.qualified_name())
.or_default()
.insert(module.as_str());
modules.insert(module.as_str().to_string()); modules.insert(module.as_str().to_string());
let file_stem = std::path::Path::new(element.file_path().as_str()) let file_stem = std::path::Path::new(element.file_path().as_str())
@@ -156,7 +177,7 @@ impl MermaidRenderer {
lines.push(format!(" {module}[{module}]")); lines.push(format!(" {module}[{module}]"));
} }
let mut module_edges: HashSet<(String, String)> = HashSet::new(); let mut module_edges: HashMap<(String, String), usize> = HashMap::new();
for rel in graph.relationships() { for rel in graph.relationships() {
match rel.kind() { match rel.kind() {
RelationshipKind::Import => { RelationshipKind::Import => {
@@ -168,7 +189,7 @@ impl MermaidRenderer {
&& modules.contains(&target_mod) && modules.contains(&target_mod)
&& *src != target_mod && *src != target_mod
{ {
module_edges.insert((src.clone(), target_mod)); *module_edges.entry((src.clone(), target_mod)).or_insert(0) += 1;
} }
} }
_ => { _ => {
@@ -176,7 +197,9 @@ impl MermaidRenderer {
&& modules.contains(rel.target()) && modules.contains(rel.target())
&& rel.source() != rel.target() && rel.source() != rel.target()
{ {
module_edges.insert((rel.source().to_string(), rel.target().to_string())); *module_edges
.entry((rel.source().to_string(), rel.target().to_string()))
.or_insert(0) += 1;
continue; continue;
} }
@@ -190,7 +213,9 @@ impl MermaidRenderer {
} }
for tgt_mod in tgt_set { for tgt_mod in tgt_set {
if src_mod != tgt_mod { if src_mod != tgt_mod {
module_edges.insert((src_mod.to_string(), tgt_mod.to_string())); *module_edges
.entry((src_mod.to_string(), tgt_mod.to_string()))
.or_insert(0) += 1;
} }
} }
} }
@@ -199,8 +224,18 @@ impl MermaidRenderer {
} }
} }
for (source, target) in &module_edges { for ((source, target), count) in &module_edges {
lines.push(format!(" {source} --> {target}")); let arrow = if self.show_weights {
let label = if *count == 1 {
r#"|"1 dep"|"#.to_string()
} else {
format!(r#"|"{count} deps"|"#)
};
format!("--{label}")
} else {
"-->".to_string()
};
lines.push(format!(" {source} {arrow} {target}"));
} }
lines.join("\n") lines.join("\n")
@@ -249,4 +284,43 @@ impl DiagramRenderer for MermaidRenderer {
let file = RenderedFile::new("diagram.mmd", &content)?; let file = RenderedFile::new("diagram.mmd", &content)?;
Ok(RenderOutput::single(file)) Ok(RenderOutput::single(file))
} }
fn append_cross_module_deps(
&self,
content: &str,
module: &ModuleName,
deps: &[(ModuleName, usize)],
) -> String {
if deps.is_empty() {
return content.to_string();
}
let src_id = format!(
"{}_module",
module.as_str().to_lowercase().replace('-', "_")
);
let mut extra = format!(
" class {src_id}[\"{}\"] {{\n <<module>>\n }}\n",
module.as_str()
);
for (dep_mod, count) in deps {
let dep_id = format!(
"{}_module",
dep_mod.as_str().to_lowercase().replace('-', "_")
);
extra.push_str(&format!(
" class {dep_id}[\"{}\"] {{\n <<module>>\n }}\n",
dep_mod.as_str()
));
let label = if *count == 1 {
"1 dep".to_string()
} else {
format!("{count} deps")
};
extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n"));
}
format!("{content}\n{extra}")
}
} }

View File

@@ -156,7 +156,7 @@ fn renders_module_level_flowchart() {
assert!(content.contains("graph TD")); assert!(content.contains("graph TD"));
assert!(content.contains("Orders")); assert!(content.contains("Orders"));
assert!(content.contains("Billing")); assert!(content.contains("Billing"));
assert!(content.contains("Orders --> Billing")); assert!(content.contains("Orders --") && content.contains("Billing"));
} }
#[test] #[test]
@@ -320,9 +320,99 @@ fn module_level_aggregates_cross_module_deps_into_single_arrow() {
let output = renderer.render(&graph).unwrap(); let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content(); let content = output.files()[0].content();
let arrow_count = content.matches("Orders --> Infra").count(); let arrow_count =
content.matches("Orders --> Infra").count() + content.matches("Orders --|").count();
assert_eq!( assert_eq!(
arrow_count, 1, arrow_count, 1,
"should have exactly one aggregated arrow, got:\n{content}" "should have exactly one aggregated arrow, got:\n{content}"
); );
} }
#[test]
fn module_level_shows_dep_count_as_edge_label() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"ServiceA",
CodeElementKind::Class,
FilePath::new("src/app/a.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("App").unwrap()),
);
graph.add_element(
CodeElement::new(
"ServiceB",
CodeElementKind::Class,
FilePath::new("src/app/b.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("App").unwrap()),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Class,
FilePath::new("src/domain/order.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Domain").unwrap()),
);
graph.add_relationship(
Relationship::new("ServiceA", "Order", RelationshipKind::Composition).unwrap(),
);
graph.add_relationship(
Relationship::new("ServiceB", "Order", RelationshipKind::Composition).unwrap(),
);
let graph = graph.qualify();
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(
content.contains(r#"|"2 deps"|"#),
"expected dep count label in: {content}"
);
}
#[test]
fn module_level_single_dep_uses_singular_label() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"Service",
CodeElementKind::Class,
FilePath::new("src/app/s.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("App").unwrap()),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Class,
FilePath::new("src/domain/o.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Domain").unwrap()),
);
graph.add_relationship(
Relationship::new("Service", "Order", RelationshipKind::Composition).unwrap(),
);
let graph = graph.qualify();
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(
content.contains(r#"|"1 dep"|"#),
"expected singular dep label in: {content}"
);
}

View File

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

View File

@@ -0,0 +1,3 @@
mod python_project_analyzer;
pub use python_project_analyzer::PythonProjectAnalyzer;

View File

@@ -0,0 +1,151 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use serde::Deserialize;
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, DomainError, FilePath, Relationship, RelationshipKind,
ports::ProjectAnalyzer,
};
pub struct PythonProjectAnalyzer;
impl Default for PythonProjectAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl PythonProjectAnalyzer {
pub fn new() -> Self {
Self
}
}
// PEP 621 format
#[derive(Deserialize, Default)]
struct ProjectSection {
name: Option<String>,
#[serde(default)]
dependencies: Vec<String>,
}
// Poetry format
#[derive(Deserialize, Default)]
struct PoetrySection {
name: Option<String>,
#[serde(default)]
dependencies: HashMap<String, toml::Value>,
}
#[derive(Deserialize, Default)]
struct ToolSection {
#[serde(default)]
poetry: PoetrySection,
}
#[derive(Deserialize)]
struct PyprojectToml {
project: Option<ProjectSection>,
#[serde(default)]
tool: ToolSection,
}
fn extract_dep_name(dep: &str) -> &str {
dep.split(&['>', '<', '=', '!', '[', ';', ' '][..])
.next()
.unwrap_or(dep)
.trim()
}
fn normalize(name: &str) -> String {
name.to_lowercase().replace(['-', '.'], "_")
}
impl ProjectAnalyzer for PythonProjectAnalyzer {
fn analyze(&self, root: &Path) -> Result<CodeGraph, DomainError> {
// 1. Scan immediate subdirectories for pyproject.toml
let entries = std::fs::read_dir(root).map_err(|e| DomainError::IoError(e.to_string()))?;
let mut packages: Vec<(String, String, Vec<String>)> = Vec::new(); // (dir, name, deps)
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let pyproject = path.join("pyproject.toml");
if !pyproject.exists() {
continue;
}
let content = std::fs::read_to_string(&pyproject)
.map_err(|e| DomainError::IoError(e.to_string()))?;
let parsed: PyprojectToml =
toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?;
let dir_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
// Try PEP 621 [project] first, then Poetry [tool.poetry]
let (name, deps) = if let Some(proj) = parsed.project {
let name = proj.name.unwrap_or_else(|| dir_name.clone());
let deps: Vec<String> = proj
.dependencies
.iter()
.map(|d| extract_dep_name(d).to_string())
.collect();
(name, deps)
} else if let Some(pname) = parsed.tool.poetry.name {
let deps: Vec<String> = parsed
.tool
.poetry
.dependencies
.keys()
.filter(|k| k.as_str() != "python")
.cloned()
.collect();
(pname, deps)
} else {
continue;
};
packages.push((dir_name, name, deps));
}
let known: HashSet<String> = packages
.iter()
.map(|(_, name, _)| normalize(name))
.collect();
let mut graph = CodeGraph::new();
for (dir, name, _) in &packages {
let file_path = FilePath::new(&format!("{}/pyproject.toml", dir))
.map_err(|e| DomainError::IoError(e.to_string()))?;
let element = CodeElement::new(name, CodeElementKind::Project, file_path, 1)?;
graph.add_element(element);
}
for (_, pkg_name, deps) in &packages {
for dep in deps {
let dep_norm = normalize(dep);
if known.contains(&dep_norm) && dep_norm != normalize(pkg_name) {
// find the canonical name (original casing) of the dep
if let Some((_, canonical, _)) =
packages.iter().find(|(_, n, _)| normalize(n) == dep_norm)
&& let Ok(rel) =
Relationship::new(pkg_name, canonical, RelationshipKind::Composition)
{
graph.add_relationship(rel);
}
}
}
}
Ok(graph)
}
}

View File

@@ -0,0 +1,165 @@
use std::fs;
use archlens_domain::{CodeElementKind, RelationshipKind, ports::ProjectAnalyzer};
use archlens_python_project::PythonProjectAnalyzer;
fn create_monorepo(dir: &std::path::Path) {
fs::create_dir_all(dir.join("api")).unwrap();
fs::create_dir_all(dir.join("commons")).unwrap();
fs::create_dir_all(dir.join("worker")).unwrap();
fs::write(
dir.join("api/pyproject.toml"),
r#"
[project]
name = "my-api"
dependencies = [
"my-commons>=1.0",
"fastapi",
]
"#,
)
.unwrap();
fs::write(
dir.join("commons/pyproject.toml"),
r#"
[project]
name = "my-commons"
dependencies = []
"#,
)
.unwrap();
fs::write(
dir.join("worker/pyproject.toml"),
r#"
[project]
name = "my-worker"
dependencies = [
"my-commons>=1.0",
"celery",
]
"#,
)
.unwrap();
}
#[test]
fn discovers_python_packages_as_project_elements() {
let dir = tempfile::tempdir().unwrap();
create_monorepo(dir.path());
let analyzer = PythonProjectAnalyzer::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(&"my-api"));
assert!(names.contains(&"my-commons"));
assert!(names.contains(&"my-worker"));
}
#[test]
fn extracts_intra_project_dependencies_from_pep621() {
let dir = tempfile::tempdir().unwrap();
create_monorepo(dir.path());
let analyzer = PythonProjectAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
let deps: Vec<(&str, &str)> = graph
.relationships()
.iter()
.map(|r| (r.source(), r.target()))
.collect();
assert!(
deps.contains(&("my-api", "my-commons")),
"missing api->commons: {deps:?}"
);
assert!(
deps.contains(&("my-worker", "my-commons")),
"missing worker->commons: {deps:?}"
);
assert!(
graph
.relationships()
.iter()
.all(|r| r.kind() == RelationshipKind::Composition)
);
}
#[test]
fn excludes_external_dependencies() {
let dir = tempfile::tempdir().unwrap();
create_monorepo(dir.path());
let analyzer = PythonProjectAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
let targets: Vec<&str> = graph.relationships().iter().map(|r| r.target()).collect();
assert!(!targets.contains(&"fastapi"), "fastapi should be excluded");
assert!(!targets.contains(&"celery"), "celery should be excluded");
}
fn create_poetry_monorepo(dir: &std::path::Path) {
fs::create_dir_all(dir.join("api")).unwrap();
fs::create_dir_all(dir.join("commons")).unwrap();
fs::write(
dir.join("api/pyproject.toml"),
r#"
[tool.poetry]
name = "my-api"
[tool.poetry.dependencies]
python = "^3.11"
my-commons = {path = "../commons"}
httpx = "^0.27"
"#,
)
.unwrap();
fs::write(
dir.join("commons/pyproject.toml"),
r#"
[tool.poetry]
name = "my-commons"
[tool.poetry.dependencies]
python = "^3.11"
"#,
)
.unwrap();
}
#[test]
fn extracts_intra_project_dependencies_from_poetry() {
let dir = tempfile::tempdir().unwrap();
create_poetry_monorepo(dir.path());
let analyzer = PythonProjectAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
assert_eq!(graph.elements().len(), 2);
let deps: Vec<(&str, &str)> = graph
.relationships()
.iter()
.map(|r| (r.source(), r.target()))
.collect();
assert!(
deps.contains(&("my-api", "my-commons")),
"missing api->commons: {deps:?}"
);
let targets: Vec<&str> = graph.relationships().iter().map(|r| r.target()).collect();
assert!(!targets.contains(&"httpx"), "httpx should be excluded");
}

View File

@@ -7,6 +7,14 @@ use archlens_domain::{
AnalysisConfig, DiagramLevel, DomainError, OutputConfig, ports::ConfigLoader, AnalysisConfig, DiagramLevel, DomainError, OutputConfig, ports::ConfigLoader,
}; };
#[derive(Debug, Deserialize, Default)]
struct RawRules {
#[serde(default)]
allow: Vec<String>,
#[serde(default)]
deny: Vec<String>,
}
#[derive(Debug, Deserialize, Default)] #[derive(Debug, Deserialize, Default)]
struct RawConfig { struct RawConfig {
#[serde(default)] #[serde(default)]
@@ -15,6 +23,8 @@ struct RawConfig {
output: RawOutput, output: RawOutput,
#[serde(default)] #[serde(default)]
modules: HashMap<String, String>, modules: HashMap<String, String>,
#[serde(default)]
rules: RawRules,
} }
#[derive(Debug, Deserialize, Default)] #[derive(Debug, Deserialize, Default)]
@@ -68,6 +78,10 @@ impl ConfigLoader for TomlConfigLoader {
Ok(config) Ok(config)
} }
fn load_rules(&self) -> (Vec<String>, Vec<String>) {
(self.raw.rules.allow.clone(), self.raw.rules.deny.clone())
}
fn load_output_config(&self) -> Result<OutputConfig, DomainError> { fn load_output_config(&self) -> Result<OutputConfig, DomainError> {
let mut config = let mut config =
OutputConfig::default().with_split_by_module(self.raw.output.split_by_module); OutputConfig::default().with_split_by_module(self.raw.output.split_by_module);

View File

@@ -66,3 +66,27 @@ fn missing_file_returns_defaults() {
assert!(!output.split_by_module()); assert!(!output.split_by_module());
assert!(output.output_path().is_none()); assert!(output.output_path().is_none());
} }
#[test]
fn loads_boundary_rules_from_toml_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("archlens.toml");
fs::write(
&config_path,
r#"
[rules]
allow = ["Application --> Domain", "Adapters --> Domain"]
deny = ["Domain --> Adapters"]
"#,
)
.unwrap();
let loader = TomlConfigLoader::from_path(&config_path).unwrap();
let (allow, deny) = loader.load_rules();
assert_eq!(allow.len(), 2);
assert_eq!(deny.len(), 1);
assert!(allow.iter().any(|r| r == "Application --> Domain"));
assert!(allow.iter().any(|r| r == "Adapters --> Domain"));
assert!(deny.iter().any(|r| r == "Domain --> Adapters"));
}

View File

@@ -74,10 +74,15 @@ fn collect_classes(
let name = &source[name_node.byte_range()]; let name = &source[name_node.byte_range()];
let line = child.start_position().row + 1; let line = child.start_position().row + 1;
let methods = child
.child_by_field_name("body")
.map(|body| collect_methods(&body, source))
.unwrap_or_default();
match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) { match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) {
Ok(element) => { Ok(element) => {
type_names.insert(name.to_string()); type_names.insert(name.to_string());
elements.push(element); elements.push(element.with_methods(methods));
} }
Err(e) => { Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) { if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
@@ -267,6 +272,73 @@ fn collect_imports(
} }
} }
fn collect_methods(body: &Node, source: &str) -> Vec<String> {
let mut methods = Vec::new();
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() != "function_definition" {
continue;
}
let Some(name_node) = child.child_by_field_name("name") else {
continue;
};
let fn_name = &source[name_node.byte_range()];
if fn_name.starts_with('_') {
continue;
}
let params = child
.child_by_field_name("parameters")
.map(|p| extract_python_params(&p, source))
.unwrap_or_default();
let ret = child
.child_by_field_name("return_type")
.map(|n| source[n.byte_range()].trim().to_string())
.unwrap_or_default();
let sig = if ret.is_empty() {
format!("+{fn_name}({params})")
} else {
format!("+{fn_name}({params}) -> {ret}")
};
methods.push(sig);
}
methods
}
fn extract_python_params(params_node: &Node, source: &str) -> String {
let mut parts = Vec::new();
let mut cursor = params_node.walk();
for param in params_node.children(&mut cursor) {
match param.kind() {
"typed_parameter" => {
if let Some(type_node) = param.child_by_field_name("type") {
// name is the first identifier child (not a named field)
let mut inner = param.walk();
let name = param
.children(&mut inner)
.find(|c| c.kind() == "identifier")
.map(|c| &source[c.byte_range()])
.unwrap_or_default();
if name != "self" && name != "cls" && !name.is_empty() {
let ty = source[type_node.byte_range()].trim();
parts.push(format!("{name}: {ty}"));
}
}
}
"identifier" => {
let name = &source[param.byte_range()];
if name != "self" && name != "cls" {
parts.push(name.to_string());
}
}
_ => {}
}
}
parts.join(", ")
}
fn collect_constructor_params( fn collect_constructor_params(
body: &Node, body: &Node,
source: &str, source: &str,

View File

@@ -270,7 +270,14 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
} else { } else {
"-" "-"
}; };
methods.push(format!("{vis}{fn_name}()")); let params = extract_fn_params(&item, source);
let ret = extract_fn_return(&item, source);
let sig = if ret.is_empty() {
format!("{vis}{fn_name}({params})")
} else {
format!("{vis}{fn_name}({params}) -> {ret}")
};
methods.push(sig);
} }
} }
} }
@@ -278,6 +285,38 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
methods methods
} }
fn extract_fn_params(fn_item: &Node, source: &str) -> String {
let Some(params_node) = fn_item.child_by_field_name("parameters") else {
return String::new();
};
let mut parts = Vec::new();
let mut cursor = params_node.walk();
for param in params_node.children(&mut cursor) {
match param.kind() {
"parameter" => {
if let (Some(pat), Some(ty)) = (
param.child_by_field_name("pattern"),
param.child_by_field_name("type"),
) {
let name = &source[pat.byte_range()];
let ty_text = source[ty.byte_range()].trim();
parts.push(format!("{name}: {ty_text}"));
}
}
"self_parameter" | "&self" | "self" => {}
_ => {}
}
}
parts.join(", ")
}
fn extract_fn_return(fn_item: &Node, source: &str) -> String {
fn_item
.child_by_field_name("return_type")
.map(|n| source[n.byte_range()].trim().to_string())
.unwrap_or_default()
}
fn collect_mod_declarations( fn collect_mod_declarations(
node: &Node, node: &Node,
source: &str, source: &str,

View File

@@ -136,3 +136,74 @@ fn extracts_composition_from_class_level_annotations() {
assert_eq!(composition[0].source(), "Definition"); assert_eq!(composition[0].source(), "Definition");
assert_eq!(composition[0].target(), "Gad"); assert_eq!(composition[0].target(), "Gad");
} }
#[test]
fn extracts_python_class_methods() {
let source = "class OrderService:\n def process(self):\n pass\n def cancel(self):\n pass\n";
let result = analyze_python(source, "service.py");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
assert!(
element.methods().iter().any(|m| m.contains("process")),
"expected 'process' method, got: {:?}",
element.methods()
);
assert!(
element.methods().iter().any(|m| m.contains("cancel")),
"expected 'cancel' method, got: {:?}",
element.methods()
);
}
#[test]
fn extracts_python_method_typed_params() {
let source = "class OrderService:\n def process(self, order: Order, count: int) -> None:\n pass\n";
let result = analyze_python(source, "service.py");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("process"))
.unwrap();
assert!(
method.contains("order: Order"),
"missing typed param: {method}"
);
assert!(
method.contains("count: int"),
"missing typed param: {method}"
);
}
#[test]
fn extracts_python_method_return_annotation() {
let source = "class OrderService:\n def get(self) -> Order:\n pass\n";
let result = analyze_python(source, "service.py");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("get"))
.unwrap();
assert!(
method.contains("-> Order"),
"expected return type, got: {method}"
);
}

View File

@@ -128,3 +128,145 @@ fn extracts_mod_declarations() {
assert!(imports.iter().any(|r| r.target() == "crate::models")); assert!(imports.iter().any(|r| r.target() == "crate::models"));
assert!(imports.iter().any(|r| r.target() == "crate::services")); assert!(imports.iter().any(|r| r.target() == "crate::services"));
} }
#[test]
fn extracts_rust_method_with_typed_params() {
let source = r#"
pub struct OrderService;
impl OrderService {
pub fn process(&self, order: Order, count: u64) {}
}
"#;
let result = analyze_rust(source, "service.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
assert!(
element
.methods()
.iter()
.any(|m| m.contains("order: Order") && m.contains("count: u64")),
"expected typed params in method, got: {:?}",
element.methods()
);
}
#[test]
fn extracts_rust_method_return_type() {
let source = r#"
pub struct OrderService;
impl OrderService {
pub fn get(&self) -> Order {}
}
"#;
let result = analyze_rust(source, "service.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
assert!(
element.methods().iter().any(|m| m.contains("-> Order")),
"expected return type in method, got: {:?}",
element.methods()
);
}
#[test]
fn extracts_rust_method_params_and_return() {
let source = r#"
pub struct OrderService;
impl OrderService {
pub fn process(&self, order: Order) -> Result<(), Error> {}
}
"#;
let result = analyze_rust(source, "service.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("process"))
.unwrap();
assert!(method.contains("order: Order"), "missing param: {method}");
assert!(method.contains("->"), "missing return arrow: {method}");
}
#[test]
fn extracts_rust_static_method_params() {
let source = r#"
pub struct Finder;
impl Finder {
pub fn detect(path: &str, count: usize) -> bool { false }
}
"#;
let result = analyze_rust(source, "finder.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "Finder")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("detect"))
.unwrap();
assert!(method.contains("path"), "missing path param: {method}");
assert!(method.contains("count"), "missing count param: {method}");
}
#[test]
fn extracts_rust_private_method_params() {
let source = r#"
pub struct WalkdirDiscovery;
impl WalkdirDiscovery {
fn detect_language(path: &std::path::Path) -> Option<String> { None }
}
"#;
let result = analyze_rust(source, "discovery.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "WalkdirDiscovery")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("detect_language"))
.unwrap();
assert!(method.contains("path"), "missing path param: {method}");
}
#[test]
fn extracts_rust_method_reference_param() {
let source = r#"
use std::path::Path;
pub struct WalkdirDiscovery;
impl WalkdirDiscovery {
fn detect_language(path: &Path) -> Option<String> { None }
}
"#;
let result = analyze_rust(source, "discovery.rs");
let element = result
.elements()
.iter()
.find(|e| e.name() == "WalkdirDiscovery")
.unwrap();
let method = element
.methods()
.iter()
.find(|m| m.contains("detect_language"))
.unwrap();
assert!(method.contains("path"), "missing path param: {method}");
}

View File

@@ -42,6 +42,27 @@ impl WalkdirDiscovery {
} }
} }
fn is_test_file(path: &Path, language: Language) -> bool {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default();
let in_tests_dir = path
.parent()
.map(|p| p.components().any(|c| c.as_os_str() == "tests"))
.unwrap_or(false);
if in_tests_dir {
return true;
}
match language {
Language::Rust => stem.ends_with("_test") || stem.ends_with("_tests"),
Language::Python => stem.starts_with("test_") || stem.ends_with("_test"),
Language::CSharp => stem.ends_with("Tests") || stem.ends_with("Test"),
}
}
fn is_excluded(path: &Path, root: &Path, excludes: &[String]) -> bool { fn is_excluded(path: &Path, root: &Path, excludes: &[String]) -> bool {
let relative = path.strip_prefix(root).unwrap_or(path); let relative = path.strip_prefix(root).unwrap_or(path);
let relative_str = relative.to_string_lossy(); let relative_str = relative.to_string_lossy();
@@ -88,6 +109,18 @@ impl FileDiscovery for WalkdirDiscovery {
} }
if let Some(language) = Self::detect_language(path) { if let Some(language) = Self::detect_language(path) {
if !config.include_tests() && Self::is_test_file(path, language) {
continue;
}
if let Some(changed) = config.changed_files() {
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
if !changed
.iter()
.any(|c| relative.ends_with(c.as_str()) || c.ends_with(relative.as_ref()))
{
continue;
}
}
let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let file_path = FilePath::new(&absolute.to_string_lossy()) let file_path = FilePath::new(&absolute.to_string_lossy())
.map_err(|e| DomainError::IoError(e.to_string()))?; .map_err(|e| DomainError::IoError(e.to_string()))?;

View File

@@ -58,6 +58,100 @@ fn respects_exclude_patterns() {
assert!(!files.iter().any(|f| f.path().as_str().contains("billing"))); assert!(!files.iter().any(|f| f.path().as_str().contains("billing")));
} }
#[test]
fn excludes_python_test_prefix_files_by_default() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap();
fs::write(dir.path().join("test_orders.py"), "class TestOrder: pass").unwrap();
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].path().as_str().ends_with("orders.py"));
}
#[test]
fn excludes_python_test_suffix_files_by_default() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap();
fs::write(dir.path().join("orders_test.py"), "class OrderTest: pass").unwrap();
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].path().as_str().ends_with("orders.py"));
}
#[test]
fn excludes_files_in_tests_directory_by_default() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("tests")).unwrap();
fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap();
fs::write(dir.path().join("tests/helpers.py"), "class Helper: pass").unwrap();
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].path().as_str().ends_with("orders.py"));
}
#[test]
fn excludes_rust_test_files_by_default() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("orders.rs"), "struct Order;").unwrap();
fs::write(dir.path().join("orders_tests.rs"), "struct OrdersTests;").unwrap();
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].path().as_str().ends_with("orders.rs"));
}
#[test]
fn excludes_rust_files_in_tests_directory_by_default() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("tests")).unwrap();
fs::write(dir.path().join("lib.rs"), "struct Lib;").unwrap();
fs::write(
dir.path().join("tests/integration.rs"),
"struct IntegrationTest;",
)
.unwrap();
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].path().as_str().ends_with("lib.rs"));
}
#[test]
fn include_tests_flag_re_enables_test_files() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap();
fs::write(dir.path().join("test_orders.py"), "class TestOrder: pass").unwrap();
let config = AnalysisConfig::default().with_include_tests(true);
let discovery = WalkdirDiscovery::new();
let files = discovery.discover(dir.path(), &config).unwrap();
assert_eq!(files.len(), 2);
}
#[test] #[test]
fn empty_directory_returns_no_files() { fn empty_directory_returns_no_files() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View File

@@ -95,6 +95,7 @@ where
.collect(); .collect();
let graph = graph let graph = graph
.qualify()
.resolve_relationships() .resolve_relationships()
.filter_external_imports(&known_dirs); .filter_external_imports(&known_dirs);

View File

@@ -72,20 +72,11 @@ impl CodeGraph {
} }
pub fn resolve_relationships(self) -> CodeGraph { pub fn resolve_relationships(self) -> CodeGraph {
let mut file_types: HashMap<String, HashSet<String>> = HashMap::new(); let qualified_names: HashSet<&str> =
let mut name_modules: HashMap<&str, HashSet<Option<&str>>> = HashMap::new(); self.elements.iter().map(|e| e.qualified_name()).collect();
let all_type_names: HashSet<&str> = self.elements.iter().map(|e| e.name()).collect();
for element in &self.elements { // Also keep bare name lookup for import relationships and unqualified fallback
file_types let all_bare_names: HashSet<&str> = self.elements.iter().map(|e| e.name()).collect();
.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(); let mut resolved = CodeGraph::new();
for element in &self.elements { for element in &self.elements {
@@ -97,24 +88,11 @@ impl CodeGraph {
resolved.add_relationship(rel.clone()); resolved.add_relationship(rel.clone());
} }
_ => { _ => {
if !all_type_names.contains(rel.source()) let src_ok = qualified_names.contains(rel.source())
|| !all_type_names.contains(rel.target()) || all_bare_names.contains(rel.source());
{ let tgt_ok = qualified_names.contains(rel.target())
continue; || all_bare_names.contains(rel.target());
} if src_ok && tgt_ok {
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.add_relationship(rel.clone());
} }
} }
@@ -152,6 +130,129 @@ impl CodeGraph {
filtered filtered
} }
pub fn qualify(self) -> CodeGraph {
// Build lookup: bare name -> Vec<qualified_name> (for disambiguation)
let mut name_to_qualified: HashMap<&str, Vec<String>> = HashMap::new();
for element in &self.elements {
let qn = match element.module() {
Some(m) => format!("{}::{}", m.as_str(), element.name()),
None => element.name().to_string(),
};
name_to_qualified
.entry(element.name())
.or_default()
.push(qn);
}
// Build lookup: file_path -> qualified source names in that file
let mut file_to_qualified: HashMap<&str, Vec<String>> = HashMap::new();
for element in &self.elements {
let qn = match element.module() {
Some(m) => format!("{}::{}", m.as_str(), element.name()),
None => element.name().to_string(),
};
file_to_qualified
.entry(element.file_path().as_str())
.or_default()
.push(qn);
}
let mut qualified = CodeGraph::new();
// 1. Qualify element names
for element in &self.elements {
let qn = match element.module() {
Some(m) => format!("{}::{}", m.as_str(), element.name()),
None => element.name().to_string(),
};
qualified.add_element(element.clone().with_qualified_name(qn));
}
// 2. Rewrite relationship source/target
for rel in &self.relationships {
// Qualify source: find the qualified name of the source in its file
let src_qualified = rel
.source_file()
.and_then(|f| file_to_qualified.get(f.as_str()))
.and_then(|qns| {
qns.iter().find(|qn| {
qn.ends_with(&format!("::{}", rel.source())) || *qn == rel.source()
})
})
.cloned()
.unwrap_or_else(|| {
// Fall back: unambiguous lookup
name_to_qualified
.get(rel.source())
.filter(|v| v.len() == 1)
.and_then(|v| v.first())
.cloned()
.unwrap_or_else(|| rel.source().to_string())
});
// Qualify target: prefer same module as source when ambiguous
let src_module = src_qualified.split("::").next().unwrap_or("");
let tgt_qualified = match name_to_qualified.get(rel.target()) {
Some(candidates) if candidates.len() == 1 => candidates[0].clone(),
Some(candidates) => {
// Prefer same module as source
candidates
.iter()
.find(|qn| qn.starts_with(&format!("{}::", src_module)))
.cloned()
.unwrap_or_else(|| rel.target().to_string())
}
None => rel.target().to_string(),
};
let new_rel = Relationship::new(&src_qualified, &tgt_qualified, rel.kind())
.unwrap_or_else(|_| rel.clone());
let new_rel = if let Some(f) = rel.source_file() {
new_rel.with_source_file(f.clone())
} else {
new_rel
};
qualified.add_relationship(new_rel);
}
qualified
}
pub fn cross_module_deps_for(&self, module: &ModuleName) -> Vec<(ModuleName, usize)> {
let module_element_qnames: HashSet<&str> = self
.elements
.iter()
.filter(|e| e.module().is_some_and(|m| m == module))
.map(|e| e.qualified_name())
.collect();
let target_module_of: HashMap<&str, Option<&ModuleName>> = self
.elements
.iter()
.map(|e| (e.qualified_name(), e.module()))
.collect();
let mut counts: HashMap<&str, usize> = HashMap::new();
for rel in &self.relationships {
if !module_element_qnames.contains(rel.source()) {
continue;
}
if module_element_qnames.contains(rel.target()) {
continue;
}
if let Some(Some(target_mod)) = target_module_of.get(rel.target()) {
*counts.entry(target_mod.as_str()).or_insert(0) += 1;
}
}
let mut result: Vec<(ModuleName, usize)> = counts
.into_iter()
.filter_map(|(name, count)| ModuleName::new(name).ok().map(|m| (m, count)))
.collect();
result.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
result
}
pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph { pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph {
let filtered_elements: Vec<CodeElement> = self let filtered_elements: Vec<CodeElement> = self
.elements .elements
@@ -160,12 +261,15 @@ impl CodeGraph {
.cloned() .cloned()
.collect(); .collect();
let element_names: HashSet<&str> = filtered_elements.iter().map(|e| e.name()).collect(); let element_qnames: HashSet<&str> = filtered_elements
.iter()
.map(|e| e.qualified_name())
.collect();
let filtered_relationships: Vec<Relationship> = self let filtered_relationships: Vec<Relationship> = self
.relationships .relationships
.iter() .iter()
.filter(|r| element_names.contains(r.source()) && element_names.contains(r.target())) .filter(|r| element_qnames.contains(r.source()) && element_qnames.contains(r.target()))
.cloned() .cloned()
.collect(); .collect();

View File

@@ -3,6 +3,7 @@ use crate::{CodeElementKind, DomainError, FilePath, ModuleName, Visibility};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CodeElement { pub struct CodeElement {
name: String, name: String,
qualified_name: Option<String>,
kind: CodeElementKind, kind: CodeElementKind,
file_path: FilePath, file_path: FilePath,
line: usize, line: usize,
@@ -27,6 +28,7 @@ impl CodeElement {
} }
Ok(Self { Ok(Self {
name: trimmed.to_string(), name: trimmed.to_string(),
qualified_name: None,
kind, kind,
file_path, file_path,
line, line,
@@ -59,10 +61,19 @@ impl CodeElement {
self self
} }
pub fn with_qualified_name(mut self, qn: String) -> Self {
self.qualified_name = Some(qn);
self
}
pub fn name(&self) -> &str { pub fn name(&self) -> &str {
&self.name &self.name
} }
pub fn qualified_name(&self) -> &str {
self.qualified_name.as_deref().unwrap_or(&self.name)
}
pub fn kind(&self) -> CodeElementKind { pub fn kind(&self) -> CodeElementKind {
self.kind self.kind
} }

View File

@@ -11,4 +11,5 @@ pub use error::DomainError;
pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning}; pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning};
pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility}; pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility};
pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile}; pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile};
pub use value_objects::rules::{BoundaryRule, RuleKind, RuleViolation, check_boundary_rules};
pub use value_objects::source::{FilePath, Language, ModuleName, SourceFile}; pub use value_objects::source::{FilePath, Language, ModuleName, SourceFile};

View File

@@ -3,4 +3,8 @@ use crate::{AnalysisConfig, DomainError, OutputConfig};
pub trait ConfigLoader { pub trait ConfigLoader {
fn load_analysis_config(&self) -> Result<AnalysisConfig, DomainError>; fn load_analysis_config(&self) -> Result<AnalysisConfig, DomainError>;
fn load_output_config(&self) -> Result<OutputConfig, DomainError>; fn load_output_config(&self) -> Result<OutputConfig, DomainError>;
fn load_rules(&self) -> (Vec<String>, Vec<String>) {
(Vec::new(), Vec::new())
}
} }

View File

@@ -1,5 +1,15 @@
use crate::{CodeGraph, DomainError, RenderOutput}; use crate::{CodeGraph, DomainError, ModuleName, RenderOutput};
pub trait DiagramRenderer { pub trait DiagramRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError>; fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError>;
fn append_cross_module_deps(
&self,
content: &str,
module: &ModuleName,
deps: &[(ModuleName, usize)],
) -> String {
let _ = (module, deps);
content.to_string()
}
} }

View File

@@ -1,4 +1,4 @@
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use crate::DiagramLevel; use crate::DiagramLevel;
@@ -8,6 +8,8 @@ pub struct AnalysisConfig {
level: DiagramLevel, level: DiagramLevel,
module_mappings: HashMap<String, String>, module_mappings: HashMap<String, String>,
scope: Option<String>, scope: Option<String>,
include_tests: bool,
changed_files: Option<HashSet<String>>,
} }
impl AnalysisConfig { impl AnalysisConfig {
@@ -46,6 +48,24 @@ impl AnalysisConfig {
pub fn scope(&self) -> Option<&str> { pub fn scope(&self) -> Option<&str> {
self.scope.as_deref() self.scope.as_deref()
} }
pub fn with_include_tests(mut self, include: bool) -> Self {
self.include_tests = include;
self
}
pub fn include_tests(&self) -> bool {
self.include_tests
}
pub fn with_changed_files(mut self, files: HashSet<String>) -> Self {
self.changed_files = Some(files);
self
}
pub fn changed_files(&self) -> Option<&HashSet<String>> {
self.changed_files.as_ref()
}
} }
impl Default for AnalysisConfig { impl Default for AnalysisConfig {
@@ -55,6 +75,8 @@ impl Default for AnalysisConfig {
level: DiagramLevel::Module, level: DiagramLevel::Module,
module_mappings: HashMap::new(), module_mappings: HashMap::new(),
scope: None, scope: None,
include_tests: false,
changed_files: None,
} }
} }
} }

View File

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

View File

@@ -0,0 +1,28 @@
pub struct BoundaryRule {
source: String,
target: String,
}
impl BoundaryRule {
pub fn parse(s: &str) -> Option<Self> {
let (src, tgt) = s.split_once("-->")?;
let source = src.trim().to_string();
let target = tgt.trim().to_string();
if source.is_empty() || target.is_empty() {
return None;
}
Some(Self { source, target })
}
pub fn source(&self) -> &str {
&self.source
}
pub fn target(&self) -> &str {
&self.target
}
pub fn matches(&self, src_module: &str, tgt_module: &str) -> bool {
self.source == src_module && self.target == tgt_module
}
}

View File

@@ -0,0 +1,52 @@
mod boundary_rule;
mod rule_violation;
pub use boundary_rule::BoundaryRule;
pub use rule_violation::{RuleKind, RuleViolation};
use std::collections::HashSet;
use crate::{CodeGraph, RelationshipKind};
pub fn check_boundary_rules(
graph: &CodeGraph,
allow: &[BoundaryRule],
deny: &[BoundaryRule],
) -> Vec<RuleViolation> {
if allow.is_empty() && deny.is_empty() {
return Vec::new();
}
// Build qualified-name → module lookup
let qname_to_module: std::collections::HashMap<&str, &str> = graph
.elements()
.iter()
.filter_map(|e| e.module().map(|m| (e.qualified_name(), m.as_str())))
.collect();
// Collect unique cross-module edges
let mut edges: HashSet<(String, String)> = HashSet::new();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
let src_mod = qname_to_module.get(rel.source()).copied();
let tgt_mod = qname_to_module.get(rel.target()).copied();
if let (Some(s), Some(t)) = (src_mod, tgt_mod)
&& s != t
{
edges.insert((s.to_string(), t.to_string()));
}
}
let mut violations = Vec::new();
for (src, tgt) in &edges {
if deny.iter().any(|r| r.matches(src, tgt)) {
violations.push(RuleViolation::new(src, tgt, RuleKind::Denied));
} else if !allow.is_empty() && !allow.iter().any(|r| r.matches(src, tgt)) {
violations.push(RuleViolation::new(src, tgt, RuleKind::NotAllowed));
}
}
violations
}

View File

@@ -0,0 +1,45 @@
pub enum RuleKind {
Denied,
NotAllowed,
}
pub struct RuleViolation {
source_module: String,
target_module: String,
kind: RuleKind,
}
impl RuleViolation {
pub fn new(source_module: &str, target_module: &str, kind: RuleKind) -> Self {
Self {
source_module: source_module.to_string(),
target_module: target_module.to_string(),
kind,
}
}
pub fn source_module(&self) -> &str {
&self.source_module
}
pub fn target_module(&self) -> &str {
&self.target_module
}
pub fn kind(&self) -> &RuleKind {
&self.kind
}
pub fn message(&self) -> String {
match self.kind {
RuleKind::Denied => format!(
"Denied dependency: {} --> {}",
self.source_module, self.target_module
),
RuleKind::NotAllowed => format!(
"Dependency not in allow list: {} --> {}",
self.source_module, self.target_module
),
}
}
}

View File

@@ -0,0 +1,80 @@
use archlens_domain::{
BoundaryRule, CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, Relationship,
RelationshipKind, RuleViolation, check_boundary_rules,
};
fn make_element(name: &str, module: &str) -> CodeElement {
CodeElement::new(
name,
CodeElementKind::Class,
FilePath::new(&format!("src/{name}.rs")).unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new(module).unwrap())
}
fn graph_with_edge(
src_name: &str,
src_module: &str,
tgt_name: &str,
tgt_module: &str,
) -> CodeGraph {
let mut graph = CodeGraph::new();
graph.add_element(make_element(src_name, src_module));
graph.add_element(make_element(tgt_name, tgt_module));
graph.add_relationship(
Relationship::new(src_name, tgt_name, RelationshipKind::Composition).unwrap(),
);
graph.qualify()
}
#[test]
fn boundary_rule_parses_source_and_target() {
let rule = BoundaryRule::parse("Application --> Domain").unwrap();
assert_eq!(rule.source(), "Application");
assert_eq!(rule.target(), "Domain");
}
#[test]
fn check_returns_denied_violation_when_deny_rule_matches_edge() {
let graph = graph_with_edge("Service", "Domain", "Adapter", "Adapters");
let deny = vec![BoundaryRule::parse("Domain --> Adapters").unwrap()];
let violations = check_boundary_rules(&graph, &[], &deny);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].source_module(), "Domain");
assert_eq!(violations[0].target_module(), "Adapters");
assert!(matches!(
violations[0].kind(),
archlens_domain::RuleKind::Denied
));
}
#[test]
fn check_returns_no_violation_when_edge_matches_allow_rule() {
let graph = graph_with_edge("Service", "Application", "Order", "Domain");
let allow = vec![BoundaryRule::parse("Application --> Domain").unwrap()];
let violations = check_boundary_rules(&graph, &allow, &[]);
assert!(
violations.is_empty(),
"expected no violations, got: {}",
violations.len()
);
}
#[test]
fn check_returns_not_allowed_when_edge_absent_from_allow_list() {
let graph = graph_with_edge("Repo", "Adapters", "Order", "Domain");
// Only Application --> Domain is allowed; Adapters --> Domain is not in the list
let allow = vec![BoundaryRule::parse("Application --> Domain").unwrap()];
let violations = check_boundary_rules(&graph, &allow, &[]);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].source_module(), "Adapters");
assert_eq!(violations[0].target_module(), "Domain");
}

View File

@@ -115,6 +115,180 @@ fn subgraph_of_nonexistent_module_is_empty() {
assert!(subgraph.relationships().is_empty()); assert!(subgraph.relationships().is_empty());
} }
#[test]
fn qualify_sets_qualified_name_on_elements_with_modules() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("DtoBaseModel", Some("Commons")));
graph.add_element(make_element("DtoBaseModel", Some("Api")));
graph.add_element(make_element("Orphan", None));
let graph = graph.qualify();
let commons_dto = graph
.elements()
.iter()
.find(|e| e.module().map(|m| m.as_str()) == Some("Commons"))
.unwrap();
assert_eq!(commons_dto.qualified_name(), "Commons::DtoBaseModel");
let api_dto = graph
.elements()
.iter()
.find(|e| e.module().map(|m| m.as_str()) == Some("Api"))
.unwrap();
assert_eq!(api_dto.qualified_name(), "Api::DtoBaseModel");
let orphan = graph
.elements()
.iter()
.find(|e| e.name() == "Orphan")
.unwrap();
assert_eq!(orphan.qualified_name(), "Orphan");
}
#[test]
fn qualify_rewrites_unambiguous_relationship_target() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("App")));
graph.add_element(make_element("Order", Some("Domain")));
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
let graph = graph.qualify();
let rel = &graph.relationships()[0];
assert_eq!(rel.source(), "App::OrderService");
assert_eq!(rel.target(), "Domain::Order");
}
#[test]
fn qualify_disambiguates_target_by_source_module() {
let mut graph = CodeGraph::new();
// DtoBaseModel exists in both Commons and Api
graph.add_element(make_element("DtoBaseModel", Some("Commons")));
graph.add_element(make_element("DtoBaseModel", Some("Api")));
// GlobalAudienceDefinition inherits DtoBaseModel, and is in Commons
graph.add_element(make_element("GlobalAudienceDefinition", Some("Commons")));
let mut rel = Relationship::new(
"GlobalAudienceDefinition",
"DtoBaseModel",
RelationshipKind::Inheritance,
)
.unwrap();
// source_file is in the Commons module path
rel = rel.with_source_file(
archlens_domain::FilePath::new("src/commons/global_audience.rs").unwrap(),
);
// Make GlobalAudienceDefinition's element file match
let mut gad = make_element("GlobalAudienceDefinition", Some("Commons"));
// rebuild with matching file_path
gad = CodeElement::new(
"GlobalAudienceDefinition",
archlens_domain::CodeElementKind::Class,
archlens_domain::FilePath::new("src/commons/global_audience.rs").unwrap(),
1,
)
.unwrap()
.with_module(archlens_domain::ModuleName::new("Commons").unwrap());
let mut graph = CodeGraph::new();
graph.add_element(make_element("DtoBaseModel", Some("Commons")));
graph.add_element(make_element("DtoBaseModel", Some("Api")));
graph.add_element(gad);
graph.add_relationship(rel);
let graph = graph.qualify();
let rel = &graph.relationships()[0];
assert_eq!(rel.source(), "Commons::GlobalAudienceDefinition");
assert_eq!(rel.target(), "Commons::DtoBaseModel");
}
#[test]
fn resolve_preserves_relationship_when_both_qualified_names_exist() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("GlobalAudienceDefinition", Some("Commons")));
graph.add_element(make_element("DtoBaseModel", Some("Commons")));
graph.add_element(make_element("DtoBaseModel", Some("Api")));
graph.add_relationship(
Relationship::new(
"GlobalAudienceDefinition",
"DtoBaseModel",
RelationshipKind::Inheritance,
)
.unwrap(),
);
let graph = graph.qualify().resolve_relationships();
// The relationship should survive — Commons::GlobalAudienceDefinition --> Commons::DtoBaseModel
assert_eq!(graph.relationships().len(), 1);
assert_eq!(
graph.relationships()[0].source(),
"Commons::GlobalAudienceDefinition"
);
assert_eq!(graph.relationships()[0].target(), "Commons::DtoBaseModel");
}
#[test]
fn cross_module_deps_for_returns_target_module_with_count() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("WidgetJobData", Some("aiss_worker")));
graph.add_element(make_element("WidgetType", Some("commons")));
graph.add_relationship(
Relationship::new("WidgetJobData", "WidgetType", RelationshipKind::Composition).unwrap(),
);
let module = ModuleName::new("aiss_worker").unwrap();
let deps = graph.cross_module_deps_for(&module);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].0.as_str(), "commons");
assert_eq!(deps[0].1, 1);
}
#[test]
fn cross_module_deps_for_returns_empty_for_intra_module_only() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("Orders")));
graph.add_element(make_element("Order", Some("Orders")));
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
let module = ModuleName::new("Orders").unwrap();
let deps = graph.cross_module_deps_for(&module);
assert!(deps.is_empty());
}
#[test]
fn cross_module_deps_for_aggregates_multiple_relationships_to_same_module() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("ServiceA", Some("app")));
graph.add_element(make_element("ServiceB", Some("app")));
graph.add_element(make_element("DomainType1", Some("domain")));
graph.add_element(make_element("DomainType2", Some("domain")));
graph.add_relationship(
Relationship::new("ServiceA", "DomainType1", RelationshipKind::Composition).unwrap(),
);
graph.add_relationship(
Relationship::new("ServiceB", "DomainType2", RelationshipKind::Composition).unwrap(),
);
let module = ModuleName::new("app").unwrap();
let deps = graph.cross_module_deps_for(&module);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].0.as_str(), "domain");
assert_eq!(deps[0].1, 2);
}
#[test] #[test]
fn graph_lists_unique_modules() { fn graph_lists_unique_modules() {
let mut graph = CodeGraph::new(); let mut graph = CodeGraph::new();

View File

@@ -19,7 +19,11 @@ archlens-file-writer.workspace = true
archlens-stdout-writer.workspace = true archlens-stdout-writer.workspace = true
archlens-toml-config.workspace = true archlens-toml-config.workspace = true
archlens-cargo-workspace.workspace = true archlens-cargo-workspace.workspace = true
archlens-python-project.workspace = true
archlens-d2.workspace = true
archlens-html.workspace = true
anyhow.workspace = true anyhow.workspace = true
notify.workspace = true
clap.workspace = true clap.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true

View File

@@ -32,6 +32,18 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub exclude: Vec<String>, pub exclude: Vec<String>,
#[arg(long)]
pub include_tests: bool,
#[arg(long)]
pub no_weights: bool,
#[arg(long)]
pub watch: bool,
#[arg(long, value_name = "REF")]
pub since: Option<String>,
#[arg(long)] #[arg(long)]
pub split_by_module: bool, pub split_by_module: bool,

View File

@@ -7,12 +7,15 @@ use anyhow::{Result, bail};
use archlens_application::queries::AnalyzeCodebase; use archlens_application::queries::AnalyzeCodebase;
use archlens_ascii::AsciiRenderer; use archlens_ascii::AsciiRenderer;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer; use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_d2::D2Renderer;
use archlens_domain::{ use archlens_domain::{
CodeGraph, DiagramLevel, ModuleName, BoundaryRule, CodeGraph, DiagramLevel, ModuleName, check_boundary_rules,
ports::{ConfigLoader, OutputWriter, ProjectAnalyzer}, ports::{ConfigLoader, OutputWriter, ProjectAnalyzer},
}; };
use archlens_file_writer::FileOutputWriter; use archlens_file_writer::FileOutputWriter;
use archlens_html::HtmlRenderer;
use archlens_mermaid::MermaidRenderer; use archlens_mermaid::MermaidRenderer;
use archlens_python_project::PythonProjectAnalyzer;
use archlens_stdout_writer::StdoutOutputWriter; use archlens_stdout_writer::StdoutOutputWriter;
use archlens_toml_config::TomlConfigLoader; use archlens_toml_config::TomlConfigLoader;
use archlens_tree_sitter::TreeSitterAnalyzer; use archlens_tree_sitter::TreeSitterAnalyzer;
@@ -30,15 +33,43 @@ pub fn run(args: Cli) -> Result<()> {
} }
init_tracing(args.verbose); init_tracing(args.verbose);
if args.watch {
return run_watch(args);
}
let level = parse_level(&args.level); let level = parse_level(&args.level);
let config_loader = load_config(&args)?;
let graph = build_graph(&args, level)?; let graph = build_graph(&args, level)?;
let renderer = create_renderer(&args.format, level)?; let renderer = create_renderer(&args.format, level, !args.no_weights)?;
let ext = format_extension(&args.format); let ext = format_extension(&args.format);
if args.check { if args.check {
return check_freshness(&args.output, &graph, &*renderer); return check_freshness(&args.output, &graph, &*renderer);
} }
// Boundary rule checking
let (raw_allow, raw_deny) = config_loader.load_rules();
let allow: Vec<BoundaryRule> = raw_allow
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
let deny: Vec<BoundaryRule> = raw_deny
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
if !allow.is_empty() || !deny.is_empty() {
let violations = check_boundary_rules(&graph, &allow, &deny);
for v in &violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
if args.strict && !violations.is_empty() {
bail!(
"{} boundary rule violation(s) in strict mode",
violations.len()
);
}
}
if args.split_by_module { if args.split_by_module {
write_split(&graph, &*renderer, &args.output, ext)?; write_split(&graph, &*renderer, &args.output, ext)?;
} else { } else {
@@ -75,10 +106,21 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
excludes.extend(args.exclude.iter().cloned()); excludes.extend(args.exclude.iter().cloned());
analysis_config = analysis_config.with_excludes(excludes); analysis_config = analysis_config.with_excludes(excludes);
} }
if args.include_tests {
analysis_config = analysis_config.with_include_tests(true);
}
if let Some(ref git_ref) = args.since {
let changed = get_changed_files(&args.path, git_ref)?;
analysis_config = analysis_config.with_changed_files(changed);
}
if level == DiagramLevel::Project { if level == DiagramLevel::Project {
let cargo_toml = args.path.join("Cargo.toml");
if cargo_toml.exists() {
return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?); return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?);
} }
return Ok(PythonProjectAnalyzer::new().analyze(&args.path)?);
}
let discovery = WalkdirDiscovery::new(); let discovery = WalkdirDiscovery::new();
let analyzer = TreeSitterAnalyzer::new(); let analyzer = TreeSitterAnalyzer::new();
@@ -106,10 +148,13 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
if level == DiagramLevel::Module { if level == DiagramLevel::Module {
let workspace_toml = args.path.join("Cargo.toml"); let workspace_toml = args.path.join("Cargo.toml");
if workspace_toml.exists() let project_graph = if workspace_toml.exists() {
&& let Ok(project_graph) = CargoWorkspaceAnalyzer::new().analyze(&args.path) CargoWorkspaceAnalyzer::new().analyze(&args.path).ok()
{ } else {
merge_project_deps_as_module_edges(&mut graph, &project_graph); PythonProjectAnalyzer::new().analyze(&args.path).ok()
};
if let Some(pg) = project_graph {
merge_project_deps_as_module_edges(&mut graph, &pg);
} }
} }
@@ -119,10 +164,15 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
fn create_renderer( fn create_renderer(
format: &str, format: &str,
level: DiagramLevel, level: DiagramLevel,
show_weights: bool,
) -> Result<Box<dyn archlens_domain::ports::DiagramRenderer>> { ) -> Result<Box<dyn archlens_domain::ports::DiagramRenderer>> {
match format { match format {
"mermaid" => Ok(Box::new(MermaidRenderer::with_level(level))), "mermaid" => Ok(Box::new(
MermaidRenderer::with_level(level).with_weights(show_weights),
)),
"ascii" => Ok(Box::new(AsciiRenderer::new())), "ascii" => Ok(Box::new(AsciiRenderer::new())),
"d2" => Ok(Box::new(D2Renderer::with_level(level))),
"html" => Ok(Box::new(HtmlRenderer::new())),
fmt => bail!("unknown format: {fmt}"), fmt => bail!("unknown format: {fmt}"),
} }
} }
@@ -130,6 +180,8 @@ fn create_renderer(
fn format_extension(format: &str) -> &str { fn format_extension(format: &str) -> &str {
match format { match format {
"mermaid" => "mmd", "mermaid" => "mmd",
"d2" => "d2",
"html" => "html",
_ => "txt", _ => "txt",
} }
} }
@@ -174,14 +226,17 @@ fn write_split(
for module in graph.modules() { for module in graph.modules() {
let subgraph = graph.subgraph_by_module(&module); let subgraph = graph.subgraph_by_module(&module);
let cross_deps = graph.cross_module_deps_for(&module);
let module_output = renderer.render(&subgraph)?; let module_output = renderer.render(&subgraph)?;
let module_file = archlens_domain::RenderedFile::new( let raw = module_output
&format!("{}.{ext}", module.as_str().to_lowercase()),
module_output
.files() .files()
.first() .first()
.map(|f| f.content()) .map(|f| f.content())
.unwrap_or(""), .unwrap_or("");
let content = renderer.append_cross_module_deps(raw, &module, &cross_deps);
let module_file = archlens_domain::RenderedFile::new(
&format!("{}.{ext}", module.as_str().to_lowercase()),
&content,
)?; )?;
writer.write(&archlens_domain::RenderOutput::single(module_file))?; writer.write(&archlens_domain::RenderOutput::single(module_file))?;
} }
@@ -258,7 +313,7 @@ fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
let level = parse_level(&args.level); let level = parse_level(&args.level);
let graph = build_graph(args, level)?; let graph = build_graph(args, level)?;
let renderer = create_renderer(&args.format, level)?; let renderer = create_renderer(&args.format, level, !args.no_weights)?;
let output = renderer.render(&graph)?; let output = renderer.render(&graph)?;
let current = output.files().first().map(|f| f.content()).unwrap_or(""); let current = output.files().first().map(|f| f.content()).unwrap_or("");
@@ -331,6 +386,13 @@ format = "mermaid"
# Generate separate files per module # Generate separate files per module
split_by_module = false split_by_module = false
[rules]
# Allowed dependency directions between modules (if set, unlisted directions are violations)
# allow = ["Application --> Domain", "Adapters --> Domain"]
# Explicitly forbidden dependency directions (always checked)
# deny = ["Domain --> Adapters", "Domain --> Application"]
"#; "#;
std::fs::write(&config_path, content)?; std::fs::write(&config_path, content)?;
@@ -362,3 +424,104 @@ fn init_tracing(verbosity: u8) {
.try_init() .try_init()
.ok(); .ok();
} }
fn get_changed_files(
root: &std::path::Path,
git_ref: &str,
) -> Result<std::collections::HashSet<String>> {
let output = std::process::Command::new("git")
.args(["diff", "--name-only", git_ref])
.current_dir(root)
.output()
.map_err(|e| anyhow::anyhow!("git not found: {e}"))?;
if !output.status.success() {
bail!(
"git diff failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let files = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
Ok(files)
}
fn run_watch(args: Cli) -> Result<()> {
use notify::{RecursiveMode, Watcher, recommended_watcher};
use std::sync::mpsc;
use std::time::{Duration, Instant};
let level = parse_level(&args.level);
let ext = format_extension(&args.format);
let debounce = Duration::from_millis(500);
let run_once = |args: &Cli| -> Result<()> {
let config_loader = load_config(args)?;
let graph = build_graph(args, level)?;
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
if args.split_by_module {
write_split(&graph, &*renderer, &args.output, ext)?;
} else {
write_single(&graph, &*renderer, &args.output)?;
}
let (raw_allow, raw_deny) = config_loader.load_rules();
let allow: Vec<BoundaryRule> = raw_allow
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
let deny: Vec<BoundaryRule> = raw_deny
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
if !allow.is_empty() || !deny.is_empty() {
let violations = check_boundary_rules(&graph, &allow, &deny);
for v in &violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
}
Ok(())
};
eprintln!(
"Watching {} for changes (Ctrl+C to stop)...",
args.path.display()
);
if let Err(e) = run_once(&args) {
eprintln!("Error: {e}");
} else {
eprintln!("Diagram generated.");
}
let (tx, rx) = mpsc::channel();
let mut watcher = recommended_watcher(move |res| {
let _ = tx.send(res);
})?;
watcher.watch(&args.path, RecursiveMode::Recursive)?;
let mut last_run = Instant::now();
loop {
match rx.recv() {
Ok(_) => {
if last_run.elapsed() < debounce {
continue;
}
last_run = Instant::now();
eprintln!("Change detected, regenerating...");
if let Err(e) = run_once(&args) {
eprintln!("Error: {e}");
} else {
eprintln!("Diagram updated.");
}
}
Err(e) => {
eprintln!("Watch error: {e}");
break;
}
}
}
Ok(())
}

View File

@@ -53,6 +53,10 @@ fn analyzes_rust_project_and_writes_mermaid_to_file() {
config: None, config: None,
scope: None, scope: None,
exclude: vec![], exclude: vec![],
include_tests: false,
no_weights: false,
watch: false,
since: None,
split_by_module: false, split_by_module: false,
strict: false, strict: false,
check: false, check: false,
@@ -83,6 +87,10 @@ fn works_without_config_file() {
config: None, config: None,
scope: None, scope: None,
exclude: vec![], exclude: vec![],
include_tests: false,
no_weights: false,
watch: false,
since: None,
split_by_module: false, split_by_module: false,
strict: false, strict: false,
check: false, check: false,
@@ -108,6 +116,10 @@ fn split_by_module_writes_overview_and_per_module_files() {
config: None, config: None,
scope: None, scope: None,
exclude: vec![], exclude: vec![],
include_tests: false,
no_weights: false,
watch: false,
since: None,
split_by_module: true, split_by_module: true,
strict: false, strict: false,
check: false, check: false,
@@ -131,3 +143,59 @@ fn split_by_module_writes_overview_and_per_module_files() {
"should have overview + at least one module file" "should have overview + at least one module file"
); );
} }
fn create_cross_module_project(dir: &std::path::Path) {
fs::create_dir_all(dir.join("src/app")).unwrap();
fs::create_dir_all(dir.join("src/domain")).unwrap();
fs::write(
dir.join("src/domain/order.rs"),
"pub struct Order { pub id: u64 }\n",
)
.unwrap();
fs::write(
dir.join("src/app/service.rs"),
"use crate::domain::Order;\npub struct OrderService { order: Order }\n",
)
.unwrap();
}
#[test]
fn per_module_file_shows_cross_module_dependency_arrows() {
let project = tempfile::tempdir().unwrap();
create_cross_module_project(project.path());
let output_dir = tempfile::tempdir().unwrap();
run(archlens::CliArgs {
command: None,
path: project.path().to_path_buf(),
level: "type".to_string(),
format: "mermaid".to_string(),
output: Some(output_dir.path().to_str().unwrap().to_string()),
config: None,
scope: None,
exclude: vec![],
include_tests: false,
no_weights: false,
watch: false,
since: None,
split_by_module: true,
strict: false,
check: false,
verbose: 0,
})
.unwrap();
let app_file = output_dir.path().join("app.mmd");
assert!(app_file.exists(), "app.mmd should exist");
let content = fs::read_to_string(&app_file).unwrap();
assert!(
content.contains("<<module>>"),
"per-module file should contain external module placeholder: {content}"
);
assert!(
content.contains("domain"),
"per-module file should reference the domain module: {content}"
);
}