feat: implement all P1/P2/P3/P4 improvements from issue backlog
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:
246
Cargo.lock
generated
246
Cargo.lock
generated
@@ -47,7 +47,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -75,14 +75,18 @@ dependencies = [
|
||||
"archlens-application",
|
||||
"archlens-ascii",
|
||||
"archlens-cargo-workspace",
|
||||
"archlens-d2",
|
||||
"archlens-domain",
|
||||
"archlens-file-writer",
|
||||
"archlens-html",
|
||||
"archlens-mermaid",
|
||||
"archlens-python-project",
|
||||
"archlens-stdout-writer",
|
||||
"archlens-toml-config",
|
||||
"archlens-tree-sitter",
|
||||
"archlens-walkdir",
|
||||
"clap",
|
||||
"notify",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -119,6 +123,14 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archlens-d2"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"archlens-domain",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archlens-domain"
|
||||
version = "0.1.0"
|
||||
@@ -136,6 +148,16 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archlens-html"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"archlens-domain",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archlens-mermaid"
|
||||
version = "0.1.0"
|
||||
@@ -145,6 +167,16 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archlens-python-project"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"archlens-domain",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archlens-stdout-writer"
|
||||
version = "0.1.0"
|
||||
@@ -192,6 +224,12 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
@@ -314,7 +352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -323,6 +361,16 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -335,6 +383,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
@@ -416,6 +473,35 @@ dependencies = [
|
||||
"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]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -428,6 +514,26 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -473,13 +579,54 @@ version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -589,11 +736,11 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -717,7 +864,7 @@ dependencies = [
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -934,6 +1081,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "wasip2"
|
||||
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"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
@@ -992,7 +1145,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1001,6 +1154,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -1010,6 +1172,70 @@ dependencies = [
|
||||
"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]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
@@ -1083,7 +1309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"bitflags 2.13.0",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
|
||||
10
Cargo.toml
10
Cargo.toml
@@ -12,6 +12,9 @@ members = [
|
||||
"crates/adapters/stdout-writer",
|
||||
"crates/adapters/toml-config",
|
||||
"crates/adapters/cargo-workspace",
|
||||
"crates/adapters/python-project",
|
||||
"crates/adapters/d2",
|
||||
"crates/adapters/html-viewer",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -26,6 +29,10 @@ archlens-file-writer = { path = "crates/adapters/file-writer" }
|
||||
archlens-stdout-writer = { path = "crates/adapters/stdout-writer" }
|
||||
archlens-toml-config = { path = "crates/adapters/toml-config" }
|
||||
archlens-cargo-workspace = { path = "crates/adapters/cargo-workspace" }
|
||||
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
|
||||
thiserror = "2"
|
||||
@@ -55,5 +62,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# Parallelism
|
||||
rayon = "1"
|
||||
|
||||
# File watching
|
||||
notify = { version = "7", features = ["serde"] }
|
||||
|
||||
# Testing
|
||||
tempfile = "3"
|
||||
|
||||
87
README.md
87
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -28,6 +28,12 @@ archlens . --output docs/architecture.mmd
|
||||
# Split by module (one file per module + overview)
|
||||
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
|
||||
archlens . --format ascii
|
||||
|
||||
@@ -37,6 +43,18 @@ archlens . --scope src/domain
|
||||
# Exclude directories
|
||||
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
|
||||
archlens . -v # info
|
||||
archlens . -vv # debug
|
||||
@@ -50,7 +68,13 @@ Check if committed diagrams are up to date:
|
||||
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:
|
||||
|
||||
@@ -80,23 +104,41 @@ level = "module"
|
||||
format = "mermaid"
|
||||
# path = "docs/architecture.mmd"
|
||||
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
|
||||
|
||||
| Level | What it shows | Source |
|
||||
|-------|--------------|--------|
|
||||
| `project` | Crate/package dependencies | `Cargo.toml` |
|
||||
| `module` | Module-level dependency graph | Imports + manifest deps |
|
||||
| `type` | Class diagram with fields, methods, relationships | Source code (tree-sitter) |
|
||||
| `project` | Crate/package dependencies | `Cargo.toml` or `pyproject.toml` |
|
||||
| `module` | Module-level dependency graph with coupling weights | Imports + manifest deps |
|
||||
| `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
|
||||
|
||||
| Language | Types | Inheritance | Composition | Imports |
|
||||
|----------|-------|-------------|-------------|---------|
|
||||
| Rust | struct, enum, trait | `impl Trait for Type` | struct fields | `use`, `mod` |
|
||||
| Python | class | `class Foo(Bar)` | `__init__` params, type annotations | `import`, `from ... import` |
|
||||
| C# | planned | - | - | - |
|
||||
| Language | Types | Inheritance | Composition | Imports | Method signatures |
|
||||
|----------|-------|-------------|-------------|---------|-------------------|
|
||||
| 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` | typed params + return annotation |
|
||||
| C# | planned | - | - | - | - |
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -104,18 +146,21 @@ Built with hexagonal architecture (ports and adapters) + DDD.
|
||||
|
||||
```
|
||||
crates/
|
||||
domain/ # Core model, zero external deps
|
||||
application/ # Use cases, orchestration
|
||||
domain/ # Core model, zero external deps
|
||||
application/ # Use cases, orchestration
|
||||
adapters/
|
||||
tree-sitter/ # Source code parsing (Rust, Python)
|
||||
cargo-workspace/ # Cargo.toml dependency extraction
|
||||
walkdir/ # File discovery
|
||||
mermaid/ # Mermaid diagram output
|
||||
ascii/ # Terminal output
|
||||
file-writer/ # Write to disk
|
||||
stdout-writer/ # Write to stdout
|
||||
toml-config/ # Config file parsing
|
||||
presentation/ # CLI (clap), composition root
|
||||
tree-sitter/ # Source code parsing (Rust, Python)
|
||||
cargo-workspace/ # Cargo.toml dependency extraction
|
||||
python-project/ # pyproject.toml dependency extraction
|
||||
walkdir/ # File discovery
|
||||
mermaid/ # Mermaid diagram output
|
||||
d2/ # D2 diagram output
|
||||
ascii/ # Terminal output
|
||||
html-viewer/ # Interactive HTML output
|
||||
file-writer/ # Write to disk
|
||||
stdout-writer/ # Write to stdout
|
||||
toml-config/ # Config file parsing
|
||||
presentation/ # CLI (clap), composition root
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
11
crates/adapters/d2/Cargo.toml
Normal file
11
crates/adapters/d2/Cargo.toml
Normal 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
|
||||
173
crates/adapters/d2/src/d2_renderer.rs
Normal file
173
crates/adapters/d2/src/d2_renderer.rs
Normal 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")
|
||||
}
|
||||
2
crates/adapters/d2/src/lib.rs
Normal file
2
crates/adapters/d2/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod d2_renderer;
|
||||
pub use d2_renderer::D2Renderer;
|
||||
110
crates/adapters/d2/tests/d2_renderer_tests.rs
Normal file
110
crates/adapters/d2/tests/d2_renderer_tests.rs
Normal 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}");
|
||||
}
|
||||
13
crates/adapters/html-viewer/Cargo.toml
Normal file
13
crates/adapters/html-viewer/Cargo.toml
Normal 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
|
||||
240
crates/adapters/html-viewer/src/html_renderer.rs
Normal file
240
crates/adapters/html-viewer/src/html_renderer.rs
Normal 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
|
||||
)
|
||||
}
|
||||
2
crates/adapters/html-viewer/src/lib.rs
Normal file
2
crates/adapters/html-viewer/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod html_renderer;
|
||||
pub use html_renderer::HtmlRenderer;
|
||||
78
crates/adapters/html-viewer/tests/html_renderer_tests.rs
Normal file
78
crates/adapters/html-viewer/tests/html_renderer_tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use archlens_domain::{
|
||||
|
||||
pub struct MermaidRenderer {
|
||||
level: DiagramLevel,
|
||||
show_weights: bool,
|
||||
}
|
||||
|
||||
impl Default for MermaidRenderer {
|
||||
@@ -19,11 +20,24 @@ impl MermaidRenderer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
level: DiagramLevel::Type,
|
||||
show_weights: true,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -89,7 +103,9 @@ impl MermaidRenderer {
|
||||
RelationshipKind::Composition => "-->",
|
||||
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()) {
|
||||
lines.push(format!(" {key}"));
|
||||
}
|
||||
@@ -136,10 +152,15 @@ impl MermaidRenderer {
|
||||
|
||||
for element in graph.elements() {
|
||||
if let Some(module) = element.module() {
|
||||
// Index both bare name and qualified name for lookup
|
||||
name_to_modules
|
||||
.entry(element.name())
|
||||
.or_default()
|
||||
.insert(module.as_str());
|
||||
name_to_modules
|
||||
.entry(element.qualified_name())
|
||||
.or_default()
|
||||
.insert(module.as_str());
|
||||
modules.insert(module.as_str().to_string());
|
||||
|
||||
let file_stem = std::path::Path::new(element.file_path().as_str())
|
||||
@@ -156,7 +177,7 @@ impl MermaidRenderer {
|
||||
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() {
|
||||
match rel.kind() {
|
||||
RelationshipKind::Import => {
|
||||
@@ -168,7 +189,7 @@ impl MermaidRenderer {
|
||||
&& modules.contains(&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())
|
||||
&& 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;
|
||||
}
|
||||
|
||||
@@ -190,7 +213,9 @@ impl MermaidRenderer {
|
||||
}
|
||||
for tgt_mod in tgt_set {
|
||||
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 {
|
||||
lines.push(format!(" {source} --> {target}"));
|
||||
for ((source, target), count) in &module_edges {
|
||||
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")
|
||||
@@ -249,4 +284,43 @@ impl DiagramRenderer for MermaidRenderer {
|
||||
let file = RenderedFile::new("diagram.mmd", &content)?;
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ fn renders_module_level_flowchart() {
|
||||
assert!(content.contains("graph TD"));
|
||||
assert!(content.contains("Orders"));
|
||||
assert!(content.contains("Billing"));
|
||||
assert!(content.contains("Orders --> Billing"));
|
||||
assert!(content.contains("Orders --") && content.contains("Billing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -320,9 +320,99 @@ fn module_level_aggregates_cross_module_deps_into_single_arrow() {
|
||||
let output = renderer.render(&graph).unwrap();
|
||||
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!(
|
||||
arrow_count, 1,
|
||||
"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}"
|
||||
);
|
||||
}
|
||||
|
||||
13
crates/adapters/python-project/Cargo.toml
Normal file
13
crates/adapters/python-project/Cargo.toml
Normal 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
|
||||
3
crates/adapters/python-project/src/lib.rs
Normal file
3
crates/adapters/python-project/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod python_project_analyzer;
|
||||
|
||||
pub use python_project_analyzer::PythonProjectAnalyzer;
|
||||
151
crates/adapters/python-project/src/python_project_analyzer.rs
Normal file
151
crates/adapters/python-project/src/python_project_analyzer.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
165
crates/adapters/python-project/tests/python_project_tests.rs
Normal file
165
crates/adapters/python-project/tests/python_project_tests.rs
Normal 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");
|
||||
}
|
||||
@@ -7,6 +7,14 @@ use archlens_domain::{
|
||||
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)]
|
||||
struct RawConfig {
|
||||
#[serde(default)]
|
||||
@@ -15,6 +23,8 @@ struct RawConfig {
|
||||
output: RawOutput,
|
||||
#[serde(default)]
|
||||
modules: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
rules: RawRules,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
@@ -68,6 +78,10 @@ impl ConfigLoader for TomlConfigLoader {
|
||||
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> {
|
||||
let mut config =
|
||||
OutputConfig::default().with_split_by_module(self.raw.output.split_by_module);
|
||||
|
||||
@@ -66,3 +66,27 @@ fn missing_file_returns_defaults() {
|
||||
assert!(!output.split_by_module());
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -74,10 +74,15 @@ fn collect_classes(
|
||||
let name = &source[name_node.byte_range()];
|
||||
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) {
|
||||
Ok(element) => {
|
||||
type_names.insert(name.to_string());
|
||||
elements.push(element);
|
||||
elements.push(element.with_methods(methods));
|
||||
}
|
||||
Err(e) => {
|
||||
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(
|
||||
body: &Node,
|
||||
source: &str,
|
||||
|
||||
@@ -270,7 +270,14 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
|
||||
} 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
|
||||
}
|
||||
|
||||
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(
|
||||
node: &Node,
|
||||
source: &str,
|
||||
|
||||
@@ -136,3 +136,74 @@ fn extracts_composition_from_class_level_annotations() {
|
||||
assert_eq!(composition[0].source(), "Definition");
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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::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}");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
let relative = path.strip_prefix(root).unwrap_or(path);
|
||||
let relative_str = relative.to_string_lossy();
|
||||
@@ -88,6 +109,18 @@ impl FileDiscovery for WalkdirDiscovery {
|
||||
}
|
||||
|
||||
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 file_path = FilePath::new(&absolute.to_string_lossy())
|
||||
.map_err(|e| DomainError::IoError(e.to_string()))?;
|
||||
|
||||
@@ -58,6 +58,100 @@ fn respects_exclude_patterns() {
|
||||
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]
|
||||
fn empty_directory_returns_no_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -95,6 +95,7 @@ where
|
||||
.collect();
|
||||
|
||||
let graph = graph
|
||||
.qualify()
|
||||
.resolve_relationships()
|
||||
.filter_external_imports(&known_dirs);
|
||||
|
||||
|
||||
@@ -72,20 +72,11 @@ impl CodeGraph {
|
||||
}
|
||||
|
||||
pub fn resolve_relationships(self) -> CodeGraph {
|
||||
let mut file_types: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
let mut name_modules: HashMap<&str, HashSet<Option<&str>>> = HashMap::new();
|
||||
let all_type_names: HashSet<&str> = self.elements.iter().map(|e| e.name()).collect();
|
||||
let qualified_names: HashSet<&str> =
|
||||
self.elements.iter().map(|e| e.qualified_name()).collect();
|
||||
|
||||
for element in &self.elements {
|
||||
file_types
|
||||
.entry(element.file_path().as_str().to_string())
|
||||
.or_default()
|
||||
.insert(element.name().to_string());
|
||||
name_modules
|
||||
.entry(element.name())
|
||||
.or_default()
|
||||
.insert(element.module().map(|m| m.as_str()));
|
||||
}
|
||||
// Also keep bare name lookup for import relationships and unqualified fallback
|
||||
let all_bare_names: HashSet<&str> = self.elements.iter().map(|e| e.name()).collect();
|
||||
|
||||
let mut resolved = CodeGraph::new();
|
||||
for element in &self.elements {
|
||||
@@ -97,24 +88,11 @@ impl CodeGraph {
|
||||
resolved.add_relationship(rel.clone());
|
||||
}
|
||||
_ => {
|
||||
if !all_type_names.contains(rel.source())
|
||||
|| !all_type_names.contains(rel.target())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(src_file) = rel.source_file() {
|
||||
let file_key = src_file.as_str().to_string();
|
||||
if let Some(types_in_file) = file_types.get(&file_key)
|
||||
&& types_in_file.contains(rel.target())
|
||||
{
|
||||
resolved.add_relationship(rel.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let tgt_modules = &name_modules[rel.target()];
|
||||
if tgt_modules.len() == 1 {
|
||||
let src_ok = qualified_names.contains(rel.source())
|
||||
|| all_bare_names.contains(rel.source());
|
||||
let tgt_ok = qualified_names.contains(rel.target())
|
||||
|| all_bare_names.contains(rel.target());
|
||||
if src_ok && tgt_ok {
|
||||
resolved.add_relationship(rel.clone());
|
||||
}
|
||||
}
|
||||
@@ -152,6 +130,129 @@ impl CodeGraph {
|
||||
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 {
|
||||
let filtered_elements: Vec<CodeElement> = self
|
||||
.elements
|
||||
@@ -160,12 +261,15 @@ impl CodeGraph {
|
||||
.cloned()
|
||||
.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
|
||||
.relationships
|
||||
.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()
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::{CodeElementKind, DomainError, FilePath, ModuleName, Visibility};
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodeElement {
|
||||
name: String,
|
||||
qualified_name: Option<String>,
|
||||
kind: CodeElementKind,
|
||||
file_path: FilePath,
|
||||
line: usize,
|
||||
@@ -27,6 +28,7 @@ impl CodeElement {
|
||||
}
|
||||
Ok(Self {
|
||||
name: trimmed.to_string(),
|
||||
qualified_name: None,
|
||||
kind,
|
||||
file_path,
|
||||
line,
|
||||
@@ -59,10 +61,19 @@ impl CodeElement {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_qualified_name(mut self, qn: String) -> Self {
|
||||
self.qualified_name = Some(qn);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn qualified_name(&self) -> &str {
|
||||
self.qualified_name.as_deref().unwrap_or(&self.name)
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> CodeElementKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
@@ -11,4 +11,5 @@ pub use error::DomainError;
|
||||
pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning};
|
||||
pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility};
|
||||
pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile};
|
||||
pub use value_objects::rules::{BoundaryRule, RuleKind, RuleViolation, check_boundary_rules};
|
||||
pub use value_objects::source::{FilePath, Language, ModuleName, SourceFile};
|
||||
|
||||
@@ -3,4 +3,8 @@ use crate::{AnalysisConfig, DomainError, OutputConfig};
|
||||
pub trait ConfigLoader {
|
||||
fn load_analysis_config(&self) -> Result<AnalysisConfig, DomainError>;
|
||||
fn load_output_config(&self) -> Result<OutputConfig, DomainError>;
|
||||
|
||||
fn load_rules(&self) -> (Vec<String>, Vec<String>) {
|
||||
(Vec::new(), Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
use crate::{CodeGraph, DomainError, RenderOutput};
|
||||
use crate::{CodeGraph, DomainError, ModuleName, RenderOutput};
|
||||
|
||||
pub trait DiagramRenderer {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::DiagramLevel;
|
||||
|
||||
@@ -8,6 +8,8 @@ pub struct AnalysisConfig {
|
||||
level: DiagramLevel,
|
||||
module_mappings: HashMap<String, String>,
|
||||
scope: Option<String>,
|
||||
include_tests: bool,
|
||||
changed_files: Option<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl AnalysisConfig {
|
||||
@@ -46,6 +48,24 @@ impl AnalysisConfig {
|
||||
pub fn scope(&self) -> Option<&str> {
|
||||
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 {
|
||||
@@ -55,6 +75,8 @@ impl Default for AnalysisConfig {
|
||||
level: DiagramLevel::Module,
|
||||
module_mappings: HashMap::new(),
|
||||
scope: None,
|
||||
include_tests: false,
|
||||
changed_files: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod analysis;
|
||||
pub mod graph;
|
||||
pub mod output;
|
||||
pub mod rules;
|
||||
pub mod source;
|
||||
|
||||
28
crates/domain/src/value_objects/rules/boundary_rule.rs
Normal file
28
crates/domain/src/value_objects/rules/boundary_rule.rs
Normal 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
|
||||
}
|
||||
}
|
||||
52
crates/domain/src/value_objects/rules/mod.rs
Normal file
52
crates/domain/src/value_objects/rules/mod.rs
Normal 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
|
||||
}
|
||||
45
crates/domain/src/value_objects/rules/rule_violation.rs
Normal file
45
crates/domain/src/value_objects/rules/rule_violation.rs
Normal 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
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
80
crates/domain/tests/boundary_rule_tests.rs
Normal file
80
crates/domain/tests/boundary_rule_tests.rs
Normal 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");
|
||||
}
|
||||
@@ -115,6 +115,180 @@ fn subgraph_of_nonexistent_module_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]
|
||||
fn graph_lists_unique_modules() {
|
||||
let mut graph = CodeGraph::new();
|
||||
|
||||
@@ -19,7 +19,11 @@ archlens-file-writer.workspace = true
|
||||
archlens-stdout-writer.workspace = true
|
||||
archlens-toml-config.workspace = true
|
||||
archlens-cargo-workspace.workspace = true
|
||||
archlens-python-project.workspace = true
|
||||
archlens-d2.workspace = true
|
||||
archlens-html.workspace = true
|
||||
anyhow.workspace = true
|
||||
notify.workspace = true
|
||||
clap.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
@@ -32,6 +32,18 @@ pub struct Cli {
|
||||
#[arg(long)]
|
||||
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)]
|
||||
pub split_by_module: bool,
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@ use anyhow::{Result, bail};
|
||||
use archlens_application::queries::AnalyzeCodebase;
|
||||
use archlens_ascii::AsciiRenderer;
|
||||
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
|
||||
use archlens_d2::D2Renderer;
|
||||
use archlens_domain::{
|
||||
CodeGraph, DiagramLevel, ModuleName,
|
||||
BoundaryRule, CodeGraph, DiagramLevel, ModuleName, check_boundary_rules,
|
||||
ports::{ConfigLoader, OutputWriter, ProjectAnalyzer},
|
||||
};
|
||||
use archlens_file_writer::FileOutputWriter;
|
||||
use archlens_html::HtmlRenderer;
|
||||
use archlens_mermaid::MermaidRenderer;
|
||||
use archlens_python_project::PythonProjectAnalyzer;
|
||||
use archlens_stdout_writer::StdoutOutputWriter;
|
||||
use archlens_toml_config::TomlConfigLoader;
|
||||
use archlens_tree_sitter::TreeSitterAnalyzer;
|
||||
@@ -30,15 +33,43 @@ pub fn run(args: Cli) -> Result<()> {
|
||||
}
|
||||
init_tracing(args.verbose);
|
||||
|
||||
if args.watch {
|
||||
return run_watch(args);
|
||||
}
|
||||
|
||||
let level = parse_level(&args.level);
|
||||
let config_loader = load_config(&args)?;
|
||||
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);
|
||||
|
||||
if args.check {
|
||||
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 {
|
||||
write_split(&graph, &*renderer, &args.output, ext)?;
|
||||
} else {
|
||||
@@ -75,9 +106,20 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
|
||||
excludes.extend(args.exclude.iter().cloned());
|
||||
analysis_config = analysis_config.with_excludes(excludes);
|
||||
}
|
||||
if args.include_tests {
|
||||
analysis_config = analysis_config.with_include_tests(true);
|
||||
}
|
||||
if let Some(ref git_ref) = args.since {
|
||||
let changed = get_changed_files(&args.path, git_ref)?;
|
||||
analysis_config = analysis_config.with_changed_files(changed);
|
||||
}
|
||||
|
||||
if level == DiagramLevel::Project {
|
||||
return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?);
|
||||
let cargo_toml = args.path.join("Cargo.toml");
|
||||
if cargo_toml.exists() {
|
||||
return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?);
|
||||
}
|
||||
return Ok(PythonProjectAnalyzer::new().analyze(&args.path)?);
|
||||
}
|
||||
|
||||
let discovery = WalkdirDiscovery::new();
|
||||
@@ -106,10 +148,13 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
|
||||
|
||||
if level == DiagramLevel::Module {
|
||||
let workspace_toml = args.path.join("Cargo.toml");
|
||||
if workspace_toml.exists()
|
||||
&& let Ok(project_graph) = CargoWorkspaceAnalyzer::new().analyze(&args.path)
|
||||
{
|
||||
merge_project_deps_as_module_edges(&mut graph, &project_graph);
|
||||
let project_graph = if workspace_toml.exists() {
|
||||
CargoWorkspaceAnalyzer::new().analyze(&args.path).ok()
|
||||
} else {
|
||||
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(
|
||||
format: &str,
|
||||
level: DiagramLevel,
|
||||
show_weights: bool,
|
||||
) -> Result<Box<dyn archlens_domain::ports::DiagramRenderer>> {
|
||||
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())),
|
||||
"d2" => Ok(Box::new(D2Renderer::with_level(level))),
|
||||
"html" => Ok(Box::new(HtmlRenderer::new())),
|
||||
fmt => bail!("unknown format: {fmt}"),
|
||||
}
|
||||
}
|
||||
@@ -130,6 +180,8 @@ fn create_renderer(
|
||||
fn format_extension(format: &str) -> &str {
|
||||
match format {
|
||||
"mermaid" => "mmd",
|
||||
"d2" => "d2",
|
||||
"html" => "html",
|
||||
_ => "txt",
|
||||
}
|
||||
}
|
||||
@@ -174,14 +226,17 @@ fn write_split(
|
||||
|
||||
for module in graph.modules() {
|
||||
let subgraph = graph.subgraph_by_module(&module);
|
||||
let cross_deps = graph.cross_module_deps_for(&module);
|
||||
let module_output = renderer.render(&subgraph)?;
|
||||
let raw = module_output
|
||||
.files()
|
||||
.first()
|
||||
.map(|f| f.content())
|
||||
.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()),
|
||||
module_output
|
||||
.files()
|
||||
.first()
|
||||
.map(|f| f.content())
|
||||
.unwrap_or(""),
|
||||
&content,
|
||||
)?;
|
||||
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 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 current = output.files().first().map(|f| f.content()).unwrap_or("");
|
||||
@@ -331,6 +386,13 @@ format = "mermaid"
|
||||
|
||||
# Generate separate files per module
|
||||
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)?;
|
||||
@@ -362,3 +424,104 @@ fn init_tracing(verbosity: u8) {
|
||||
.try_init()
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ fn analyzes_rust_project_and_writes_mermaid_to_file() {
|
||||
config: None,
|
||||
scope: None,
|
||||
exclude: vec![],
|
||||
include_tests: false,
|
||||
no_weights: false,
|
||||
watch: false,
|
||||
since: None,
|
||||
split_by_module: false,
|
||||
strict: false,
|
||||
check: false,
|
||||
@@ -83,6 +87,10 @@ fn works_without_config_file() {
|
||||
config: None,
|
||||
scope: None,
|
||||
exclude: vec![],
|
||||
include_tests: false,
|
||||
no_weights: false,
|
||||
watch: false,
|
||||
since: None,
|
||||
split_by_module: false,
|
||||
strict: false,
|
||||
check: false,
|
||||
@@ -108,6 +116,10 @@ fn split_by_module_writes_overview_and_per_module_files() {
|
||||
config: None,
|
||||
scope: None,
|
||||
exclude: vec![],
|
||||
include_tests: false,
|
||||
no_weights: false,
|
||||
watch: false,
|
||||
since: None,
|
||||
split_by_module: true,
|
||||
strict: 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"
|
||||
);
|
||||
}
|
||||
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user