commit 35f27d00b021f7ec8ab187164dc4c6df3b88af8d Author: Gabriel Kaszewski Date: Tue Jun 16 16:13:04 2026 +0200 init: archlens — architecture diagram generator Hex arch + DDD, tree-sitter parsing, Mermaid/ASCII output. Supports Rust + Python. 92 tests. CI, diff, --check for staleness detection. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4c8329f --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + check: + name: Check / Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: fmt + run: cargo fmt --all -- --check + + - name: clippy + run: cargo clippy --all-targets -- -D warnings + + - name: test + run: cargo test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..58d46e5 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,55 @@ +# Archlens — Domain Glossary + +> This file defines the canonical language of the Archlens domain. No implementation details — just terms and their meanings. + +## Core Concepts + +- **CodeGraph**: The aggregate root. A complete representation of a codebase's architecture — all elements, relationships, and module groupings. Can produce filtered subgraphs of itself by module. + +- **CodeElement**: A type-level construct in source code — a class, struct, trait, interface, or enum. Carries a name, kind, location (file + line), visibility, generic parameters, and attributes/decorators. + +- **Relationship**: A directed edge between two CodeElements. Has a kind (inheritance or composition) and references source and target elements. + +- **Module**: A logical grouping of CodeElements. By default inferred from directory structure, overridable via configuration. Represented as metadata on each CodeElement (`module_path`), not a separate hierarchy. + +- **Language**: The programming language of a source file. A closed set: Rust, CSharp, Python. Determines which analysis strategy is used. + +- **SourceFile**: A file to be analyzed. Carries a FilePath and its detected Language. + +## Analysis + +- **AnalysisResult**: The output of analyzing a single file — extracted CodeElements, Relationships, and any AnalysisWarnings. + +- **AnalysisWarning**: A non-fatal problem encountered during analysis — unparseable construct, unsupported syntax, etc. Carries file path, line, and message. + +- **AnalysisConfig**: Settings that control what gets analyzed — path excludes, granularity level, module-to-directory mappings. + +## Output + +- **RenderOutput**: The result of rendering a CodeGraph — a collection of RenderedFiles. + +- **RenderedFile**: A single output artifact — a named file with content (e.g., `overview.mmd` with Mermaid syntax). + +- **OutputConfig**: Settings that control rendering — format, output path, whether to split by module. + +## Relationships (kinds) + +- **Inheritance**: A type extends or implements another type (class extends class, struct implements trait, class implements interface). + +- **Composition**: A type owns or depends on another type via a field, property, or constructor-injected dependency. + +## Metadata on CodeElements + +- **Visibility**: Whether a type is public, private, internal, or other access level. Used as a filterable property, not shown on diagrams by default. + +- **Generics**: Type parameters on a CodeElement. Stored with the element, displayed in simplified form (e.g., `Repository` not full generic constraints). + +- **Attributes**: Decorators, annotations, or attributes on a type (e.g., `[ApiController]`, `#[derive(Clone)]`, `@dataclass`). Stored as metadata, usable for categorization and filtering. + +## Diagram Levels + +- **Project level**: Dependencies between projects, crates, or packages. Rendered as a flowchart. + +- **Module level**: Dependencies between logical modules (directory-based groupings). Rendered as a flowchart with subgraphs. + +- **Type level**: Individual types with their inheritance and composition relationships. Rendered as a UML class diagram. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..dd8711a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1120 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "archlens" +version = "0.1.0" +dependencies = [ + "anyhow", + "archlens-application", + "archlens-ascii", + "archlens-cargo-workspace", + "archlens-domain", + "archlens-file-writer", + "archlens-mermaid", + "archlens-stdout-writer", + "archlens-toml-config", + "archlens-tree-sitter", + "archlens-walkdir", + "clap", + "tempfile", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "archlens-application" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "rayon", + "thiserror", + "tracing", +] + +[[package]] +name = "archlens-ascii" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "thiserror", + "tracing", +] + +[[package]] +name = "archlens-cargo-workspace" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "serde", + "tempfile", + "thiserror", + "toml", + "tracing", +] + +[[package]] +name = "archlens-domain" +version = "0.1.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "archlens-file-writer" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "tempfile", + "thiserror", + "tracing", +] + +[[package]] +name = "archlens-mermaid" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "thiserror", + "tracing", +] + +[[package]] +name = "archlens-stdout-writer" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "thiserror", + "tracing", +] + +[[package]] +name = "archlens-toml-config" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "serde", + "tempfile", + "thiserror", + "toml", + "tracing", +] + +[[package]] +name = "archlens-tree-sitter" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "tempfile", + "thiserror", + "tracing", + "tree-sitter", + "tree-sitter-c-sharp", + "tree-sitter-python", + "tree-sitter-rust", +] + +[[package]] +name = "archlens-walkdir" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "ignore", + "tempfile", + "thiserror", + "tracing", + "walkdir", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ignore" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ecad204 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,59 @@ +[workspace] +resolver = "2" +members = [ + "crates/domain", + "crates/application", + "crates/presentation", + "crates/adapters/tree-sitter", + "crates/adapters/walkdir", + "crates/adapters/mermaid", + "crates/adapters/ascii", + "crates/adapters/file-writer", + "crates/adapters/stdout-writer", + "crates/adapters/toml-config", + "crates/adapters/cargo-workspace", +] + +[workspace.dependencies] +# Internal crates +archlens-domain = { path = "crates/domain" } +archlens-application = { path = "crates/application" } +archlens-tree-sitter = { path = "crates/adapters/tree-sitter" } +archlens-walkdir = { path = "crates/adapters/walkdir" } +archlens-mermaid = { path = "crates/adapters/mermaid" } +archlens-ascii = { path = "crates/adapters/ascii" } +archlens-file-writer = { path = "crates/adapters/file-writer" } +archlens-stdout-writer = { path = "crates/adapters/stdout-writer" } +archlens-toml-config = { path = "crates/adapters/toml-config" } +archlens-cargo-workspace = { path = "crates/adapters/cargo-workspace" } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Parsing +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-python = "0.23" +tree-sitter-c-sharp = "0.23" + +# File discovery +walkdir = "2" +ignore = "0.4" + +# Config +toml = "0.8" +serde = { version = "1", features = ["derive"] } + +# CLI +clap = { version = "4", features = ["derive"] } + +# Observability +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Parallelism +rayon = "1" + +# Testing +tempfile = "3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31bafd2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gabriel Kaszewski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e2f26a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.DEFAULT_GOAL := check + +# Run the full local check suite — same order as CI would. +check: fmt-check clippy test + @echo "✅ All checks passed" + +# Apply rustfmt to all files. +fmt: + cargo fmt + +# Check formatting without modifying files (CI-safe). +fmt-check: + cargo fmt --check + +# Run Clippy and treat warnings as errors. +clippy: + cargo clippy -- -D warnings + +# Run the test suite. +test: + cargo test + +# Apply fmt + clippy auto-fixes in one shot. +fix: + cargo fmt + cargo clippy --fix --allow-dirty --allow-staged + +.PHONY: check fmt fmt-check clippy test fix diff --git a/README.md b/README.md new file mode 100644 index 0000000..0aaa377 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# archlens + +Generate architecture diagrams from source code. Runs on CI to keep docs fresh. + +Supports Rust and Python. Produces Mermaid or ASCII output. + +## Install + +```bash +cargo install --path crates/presentation +``` + +## Usage + +```bash +# Module-level dependency graph (default) +archlens . + +# Project-level (crate/package dependencies from manifests) +archlens . --level project + +# Type-level class diagram +archlens . --level type + +# Write to file +archlens . --output docs/architecture.mmd + +# Split by module (one file per module + overview) +archlens . --level type --split-by-module --output docs/arch/ + +# ASCII output to terminal +archlens . --format ascii + +# Scope to a subtree +archlens . --scope src/domain + +# Exclude directories +archlens . --exclude tests/ --exclude generated/ + +# Verbose logging +archlens . -v # info +archlens . -vv # debug +``` + +## CI Integration + +Check if committed diagrams are up to date: + +```bash +archlens . --level project --check --output docs/architecture.mmd +``` + +Exit code 1 if the diagram has changed. Use `--strict` to also fail on parse warnings. + +Compare current state against an existing file: + +```bash +archlens diff docs/architecture.mmd --level project +``` + +## Config + +Generate a config file: + +```bash +archlens init +``` + +Creates `archlens.toml`: + +```toml +[analysis] +exclude = ["tests/", "vendor/", "generated/"] +level = "module" + +[modules] +# "src/infra" = "Infrastructure" + +[output] +format = "mermaid" +# path = "docs/architecture.mmd" +split_by_module = false +``` + +## Diagram Levels + +| Level | What it shows | Source | +|-------|--------------|--------| +| `project` | Crate/package dependencies | `Cargo.toml` | +| `module` | Module-level dependency graph | Imports + manifest deps | +| `type` | Class diagram with fields, methods, relationships | Source code (tree-sitter) | + +## Supported Languages + +| Language | Types | Inheritance | Composition | Imports | +|----------|-------|-------------|-------------|---------| +| Rust | struct, enum, trait | `impl Trait for Type` | struct fields | `use`, `mod` | +| Python | class | `class Foo(Bar)` | `__init__` params, type annotations | `import`, `from ... import` | +| C# | planned | - | - | - | + +## Architecture + +Built with hexagonal architecture (ports and adapters) + DDD. + +``` +crates/ + domain/ # Core model, zero external deps + application/ # Use cases, orchestration + adapters/ + tree-sitter/ # Source code parsing (Rust, Python) + cargo-workspace/ # Cargo.toml dependency extraction + walkdir/ # File discovery + mermaid/ # Mermaid diagram output + ascii/ # Terminal output + file-writer/ # Write to disk + stdout-writer/ # Write to stdout + toml-config/ # Config file parsing + presentation/ # CLI (clap), composition root +``` + +## License + +MIT diff --git a/crates/adapters/ascii/Cargo.toml b/crates/adapters/ascii/Cargo.toml new file mode 100644 index 0000000..8308808 --- /dev/null +++ b/crates/adapters/ascii/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "archlens-ascii" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/adapters/ascii/src/ascii_renderer.rs b/crates/adapters/ascii/src/ascii_renderer.rs new file mode 100644 index 0000000..d37a884 --- /dev/null +++ b/crates/adapters/ascii/src/ascii_renderer.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; + +use archlens_domain::{ + CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile, + ports::DiagramRenderer, +}; + +pub struct AsciiRenderer; + +impl Default for AsciiRenderer { + fn default() -> Self { + Self::new() + } +} + +impl AsciiRenderer { + pub fn new() -> Self { + Self + } + + fn format_kind(element: &CodeElement) -> &'static str { + match element.kind() { + archlens_domain::CodeElementKind::Class => "cls", + archlens_domain::CodeElementKind::Struct => "str", + archlens_domain::CodeElementKind::Trait => "trt", + archlens_domain::CodeElementKind::Interface => "ifc", + archlens_domain::CodeElementKind::Enum => "enm", + archlens_domain::CodeElementKind::Project => "prj", + } + } +} + +impl DiagramRenderer for AsciiRenderer { + fn render(&self, graph: &CodeGraph) -> Result { + let mut lines = Vec::new(); + + let total_elements = graph.elements().len(); + let total_rels = graph.relationships().len(); + let total_modules = graph.modules().len(); + + lines.push("╔══════════════════════════════════════╗".to_string()); + lines.push("║ Architecture Overview ║".to_string()); + lines.push("╠══════════════════════════════════════╣".to_string()); + lines.push(format!( + "║ Elements: {:<5} Modules: {:<5} ║", + total_elements, total_modules + )); + lines.push(format!("║ Relationships: {:<19} ║", total_rels)); + lines.push("╚══════════════════════════════════════╝".to_string()); + + if graph.elements().is_empty() { + lines.push(" (no elements found)".to_string()); + let content = lines.join("\n"); + let file = RenderedFile::new("diagram.txt", &content)?; + return Ok(RenderOutput::single(file)); + } + + let mut grouped: HashMap> = HashMap::new(); + let mut ungrouped: Vec<&CodeElement> = Vec::new(); + + for element in graph.elements() { + if let Some(module) = element.module() { + grouped + .entry(module.as_str().to_string()) + .or_default() + .push(element); + } else { + ungrouped.push(element); + } + } + + if !ungrouped.is_empty() { + lines.push(String::new()); + lines.push("┌─ (ungrouped)".to_string()); + for el in &ungrouped { + lines.push(format!("│ [{}] {}", Self::format_kind(el), el.name())); + } + lines.push("└───".to_string()); + } + + let mut module_names: Vec<&String> = grouped.keys().collect(); + module_names.sort(); + + for module in module_names { + let elements = &grouped[module]; + lines.push(String::new()); + lines.push(format!("┌─ {} ({} types)", module, elements.len())); + lines.push("│".to_string()); + for (i, el) in elements.iter().enumerate() { + let prefix = if i == elements.len() - 1 { + "└──" + } else { + "├──" + }; + let generics = if el.generics().is_empty() { + String::new() + } else { + format!("<{}>", el.generics().join(", ")) + }; + lines.push(format!( + "│ {} [{}] {}{}", + prefix, + Self::format_kind(el), + el.name(), + generics + )); + } + lines.push("└───".to_string()); + } + + let non_import_rels: Vec<_> = graph + .relationships() + .iter() + .filter(|r| r.kind() != RelationshipKind::Import) + .collect(); + + if !non_import_rels.is_empty() { + lines.push(String::new()); + lines.push("── Relationships ──".to_string()); + for rel in &non_import_rels { + let arrow = match rel.kind() { + RelationshipKind::Inheritance => "extends", + RelationshipKind::Composition => "has", + RelationshipKind::Import => "imports", + }; + lines.push(format!( + " {} ─[{}]─> {}", + rel.source(), + arrow, + rel.target() + )); + } + } + + let import_rels: Vec<_> = graph + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + if !import_rels.is_empty() { + lines.push(String::new()); + lines.push(format!("── Imports ({}) ──", import_rels.len())); + for rel in &import_rels { + lines.push(format!(" {} ···> {}", rel.source(), rel.target())); + } + } + + let content = lines.join("\n"); + let file = RenderedFile::new("diagram.txt", &content)?; + Ok(RenderOutput::single(file)) + } +} diff --git a/crates/adapters/ascii/src/lib.rs b/crates/adapters/ascii/src/lib.rs new file mode 100644 index 0000000..96dfcf6 --- /dev/null +++ b/crates/adapters/ascii/src/lib.rs @@ -0,0 +1,3 @@ +mod ascii_renderer; + +pub use ascii_renderer::AsciiRenderer; diff --git a/crates/adapters/ascii/tests/ascii_renderer_tests.rs b/crates/adapters/ascii/tests/ascii_renderer_tests.rs new file mode 100644 index 0000000..13d54ed --- /dev/null +++ b/crates/adapters/ascii/tests/ascii_renderer_tests.rs @@ -0,0 +1,48 @@ +use archlens_ascii::AsciiRenderer; +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, FilePath, Relationship, RelationshipKind, + ports::DiagramRenderer, +}; + +#[test] +fn renders_elements_and_relationships_as_text() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/service.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_element( + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_relationship( + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(), + ); + + let renderer = AsciiRenderer::new(); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("OrderService")); + assert!(content.contains("Order")); + assert!(content.contains("has")); +} + +#[test] +fn empty_graph_produces_header_only() { + let renderer = AsciiRenderer::new(); + let output = renderer.render(&CodeGraph::new()).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("Architecture")); +} diff --git a/crates/adapters/cargo-workspace/Cargo.toml b/crates/adapters/cargo-workspace/Cargo.toml new file mode 100644 index 0000000..3aa8934 --- /dev/null +++ b/crates/adapters/cargo-workspace/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "archlens-cargo-workspace" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true +toml.workspace = true +serde.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs b/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs new file mode 100644 index 0000000..2647668 --- /dev/null +++ b/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs @@ -0,0 +1,123 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde::Deserialize; + +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, DomainError, FilePath, ModuleName, Relationship, + RelationshipKind, ports::ProjectAnalyzer, +}; + +pub struct CargoWorkspaceAnalyzer; + +impl Default for CargoWorkspaceAnalyzer { + fn default() -> Self { + Self::new() + } +} + +impl CargoWorkspaceAnalyzer { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct WorkspaceToml { + workspace: Option, +} + +#[derive(Deserialize)] +struct WorkspaceSection { + #[serde(default)] + members: Vec, +} + +#[derive(Deserialize)] +struct MemberToml { + package: Option, + #[serde(default)] + dependencies: HashMap, +} + +#[derive(Deserialize)] +struct PackageSection { + name: String, +} + +impl ProjectAnalyzer for CargoWorkspaceAnalyzer { + fn analyze(&self, root: &Path) -> Result { + let workspace_toml_path = root.join("Cargo.toml"); + let content = std::fs::read_to_string(&workspace_toml_path) + .map_err(|e| DomainError::IoError(e.to_string()))?; + let workspace: WorkspaceToml = + toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?; + + let members = workspace.workspace.map(|w| w.members).unwrap_or_default(); + + let mut graph = CodeGraph::new(); + let mut name_set: HashSet = HashSet::new(); + let mut member_names: Vec<(String, String)> = Vec::new(); // (member_path, package_name) + + for member_path in &members { + let member_cargo = root.join(member_path).join("Cargo.toml"); + let member_content = std::fs::read_to_string(&member_cargo) + .map_err(|e| DomainError::IoError(e.to_string()))?; + let member: MemberToml = toml::from_str(&member_content) + .map_err(|e| DomainError::ConfigError(e.to_string()))?; + + let package_name = member + .package + .map(|p| p.name) + .unwrap_or_else(|| member_path.clone()); + + name_set.insert(package_name.clone()); + member_names.push((member_path.clone(), package_name)); + } + + for (member_path, package_name) in &member_names { + let file_path = FilePath::new(&format!("{}/Cargo.toml", member_path)) + .map_err(|e| DomainError::IoError(e.to_string()))?; + + let mut element = + CodeElement::new(package_name, CodeElementKind::Project, file_path, 1)?; + + if let Some(module) = infer_group(member_path) { + element = element.with_module(module); + } + + graph.add_element(element); + } + + for (member_path, package_name) in &member_names { + let member_cargo = root.join(member_path).join("Cargo.toml"); + let member_content = std::fs::read_to_string(&member_cargo) + .map_err(|e| DomainError::IoError(e.to_string()))?; + let member: MemberToml = toml::from_str(&member_content) + .map_err(|e| DomainError::ConfigError(e.to_string()))?; + + for dep_name in member.dependencies.keys() { + let normalized = dep_name.replace('_', "-"); + if name_set.contains(&normalized) + && let Ok(rel) = + Relationship::new(package_name, &normalized, RelationshipKind::Composition) + { + graph.add_relationship(rel); + } + } + } + + Ok(graph) + } +} + +fn infer_group(member_path: &str) -> Option { + let parts: Vec<&str> = member_path.split('/').collect(); + if parts.len() < 3 { + return None; + } + + let group = parts[parts.len() - 2]; + let capitalized = format!("{}{}", group[..1].to_uppercase(), &group[1..]); + ModuleName::new(&capitalized).ok() +} diff --git a/crates/adapters/cargo-workspace/src/lib.rs b/crates/adapters/cargo-workspace/src/lib.rs new file mode 100644 index 0000000..e91beb1 --- /dev/null +++ b/crates/adapters/cargo-workspace/src/lib.rs @@ -0,0 +1,3 @@ +mod cargo_workspace_analyzer; + +pub use cargo_workspace_analyzer::CargoWorkspaceAnalyzer; diff --git a/crates/adapters/cargo-workspace/tests/cargo_workspace_tests.rs b/crates/adapters/cargo-workspace/tests/cargo_workspace_tests.rs new file mode 100644 index 0000000..43661da --- /dev/null +++ b/crates/adapters/cargo-workspace/tests/cargo_workspace_tests.rs @@ -0,0 +1,132 @@ +use std::fs; + +use archlens_cargo_workspace::CargoWorkspaceAnalyzer; +use archlens_domain::{CodeElementKind, RelationshipKind, ports::ProjectAnalyzer}; + +fn create_workspace(dir: &std::path::Path) { + fs::write( + dir.join("Cargo.toml"), + r#" +[workspace] +members = ["crates/domain", "crates/application", "crates/adapters/sqlite"] +"#, + ) + .unwrap(); + + fs::create_dir_all(dir.join("crates/domain/src")).unwrap(); + fs::write( + dir.join("crates/domain/Cargo.toml"), + r#" +[package] +name = "myapp-domain" +version = "0.1.0" +edition = "2024" + +[dependencies] +"#, + ) + .unwrap(); + fs::write(dir.join("crates/domain/src/lib.rs"), "").unwrap(); + + fs::create_dir_all(dir.join("crates/application/src")).unwrap(); + fs::write( + dir.join("crates/application/Cargo.toml"), + r#" +[package] +name = "myapp-application" +version = "0.1.0" +edition = "2024" + +[dependencies] +myapp-domain = { path = "../domain" } +"#, + ) + .unwrap(); + fs::write(dir.join("crates/application/src/lib.rs"), "").unwrap(); + + fs::create_dir_all(dir.join("crates/adapters/sqlite/src")).unwrap(); + fs::write( + dir.join("crates/adapters/sqlite/Cargo.toml"), + r#" +[package] +name = "myapp-sqlite" +version = "0.1.0" +edition = "2024" + +[dependencies] +myapp-domain = { path = "../../domain" } +"#, + ) + .unwrap(); + fs::write(dir.join("crates/adapters/sqlite/src/lib.rs"), "").unwrap(); +} + +#[test] +fn discovers_workspace_members_as_project_elements() { + let dir = tempfile::tempdir().unwrap(); + create_workspace(dir.path()); + + let analyzer = CargoWorkspaceAnalyzer::new(); + let graph = analyzer.analyze(dir.path()).unwrap(); + + assert_eq!(graph.elements().len(), 3); + assert!( + graph + .elements() + .iter() + .all(|e| e.kind() == CodeElementKind::Project) + ); + + let names: Vec<&str> = graph.elements().iter().map(|e| e.name()).collect(); + assert!(names.contains(&"myapp-domain")); + assert!(names.contains(&"myapp-application")); + assert!(names.contains(&"myapp-sqlite")); +} + +#[test] +fn extracts_dependencies_between_workspace_members() { + let dir = tempfile::tempdir().unwrap(); + create_workspace(dir.path()); + + let analyzer = CargoWorkspaceAnalyzer::new(); + let graph = analyzer.analyze(dir.path()).unwrap(); + + assert_eq!(graph.relationships().len(), 2); + assert!( + graph + .relationships() + .iter() + .all(|r| r.kind() == RelationshipKind::Composition) + ); + + let deps: Vec<(&str, &str)> = graph + .relationships() + .iter() + .map(|r| (r.source(), r.target())) + .collect(); + assert!(deps.contains(&("myapp-application", "myapp-domain"))); + assert!(deps.contains(&("myapp-sqlite", "myapp-domain"))); +} + +#[test] +fn assigns_module_from_directory_grouping() { + let dir = tempfile::tempdir().unwrap(); + create_workspace(dir.path()); + + let analyzer = CargoWorkspaceAnalyzer::new(); + let graph = analyzer.analyze(dir.path()).unwrap(); + + let sqlite = graph + .elements() + .iter() + .find(|e| e.name() == "myapp-sqlite") + .unwrap(); + assert_eq!(sqlite.module().unwrap().as_str(), "Adapters"); + + let domain = graph + .elements() + .iter() + .find(|e| e.name() == "myapp-domain") + .unwrap(); + assert!(domain.module().is_none() || domain.module().unwrap().as_str() != "Adapters"); +} diff --git a/crates/adapters/file-writer/Cargo.toml b/crates/adapters/file-writer/Cargo.toml new file mode 100644 index 0000000..ae6d65a --- /dev/null +++ b/crates/adapters/file-writer/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "archlens-file-writer" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/file-writer/src/file_output_writer.rs b/crates/adapters/file-writer/src/file_output_writer.rs new file mode 100644 index 0000000..0798c7d --- /dev/null +++ b/crates/adapters/file-writer/src/file_output_writer.rs @@ -0,0 +1,52 @@ +use std::fs; +use std::path::PathBuf; + +use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter}; + +pub struct FileOutputWriter { + output_path: OutputPath, +} + +enum OutputPath { + Directory(PathBuf), + File(PathBuf), +} + +impl FileOutputWriter { + pub fn new(output_dir: PathBuf) -> Self { + Self { + output_path: OutputPath::Directory(output_dir), + } + } + + pub fn single_file(path: PathBuf) -> Self { + Self { + output_path: OutputPath::File(path), + } + } +} + +impl OutputWriter for FileOutputWriter { + fn write(&self, output: &RenderOutput) -> Result<(), DomainError> { + match &self.output_path { + OutputPath::File(path) => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| DomainError::IoError(e.to_string()))?; + } + let content = output.files().first().map(|f| f.content()).unwrap_or(""); + fs::write(path, content).map_err(|e| DomainError::IoError(e.to_string()))?; + } + OutputPath::Directory(dir) => { + fs::create_dir_all(dir).map_err(|e| DomainError::IoError(e.to_string()))?; + + for file in output.files() { + let path = dir.join(file.name()); + fs::write(&path, file.content()) + .map_err(|e| DomainError::IoError(e.to_string()))?; + } + } + } + + Ok(()) + } +} diff --git a/crates/adapters/file-writer/src/lib.rs b/crates/adapters/file-writer/src/lib.rs new file mode 100644 index 0000000..97b1243 --- /dev/null +++ b/crates/adapters/file-writer/src/lib.rs @@ -0,0 +1,3 @@ +mod file_output_writer; + +pub use file_output_writer::FileOutputWriter; diff --git a/crates/adapters/file-writer/tests/file_writer_tests.rs b/crates/adapters/file-writer/tests/file_writer_tests.rs new file mode 100644 index 0000000..5315165 --- /dev/null +++ b/crates/adapters/file-writer/tests/file_writer_tests.rs @@ -0,0 +1,35 @@ +use std::fs; + +use archlens_domain::{RenderOutput, RenderedFile, ports::OutputWriter}; +use archlens_file_writer::FileOutputWriter; + +#[test] +fn writes_single_file_to_directory() { + let dir = tempfile::tempdir().unwrap(); + let writer = FileOutputWriter::new(dir.path().to_path_buf()); + + let file = RenderedFile::new("arch.mmd", "classDiagram").unwrap(); + let output = RenderOutput::single(file); + + writer.write(&output).unwrap(); + + let content = fs::read_to_string(dir.path().join("arch.mmd")).unwrap(); + assert_eq!(content, "classDiagram"); +} + +#[test] +fn writes_multiple_files_to_directory() { + let dir = tempfile::tempdir().unwrap(); + let writer = FileOutputWriter::new(dir.path().to_path_buf()); + + let files = vec![ + RenderedFile::new("overview.mmd", "graph TD").unwrap(), + RenderedFile::new("orders.mmd", "classDiagram").unwrap(), + ]; + let output = RenderOutput::new(files); + + writer.write(&output).unwrap(); + + assert!(dir.path().join("overview.mmd").exists()); + assert!(dir.path().join("orders.mmd").exists()); +} diff --git a/crates/adapters/mermaid/Cargo.toml b/crates/adapters/mermaid/Cargo.toml new file mode 100644 index 0000000..3a19309 --- /dev/null +++ b/crates/adapters/mermaid/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "archlens-mermaid" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/adapters/mermaid/src/lib.rs b/crates/adapters/mermaid/src/lib.rs new file mode 100644 index 0000000..1e1e5ab --- /dev/null +++ b/crates/adapters/mermaid/src/lib.rs @@ -0,0 +1,3 @@ +mod mermaid_renderer; + +pub use mermaid_renderer::MermaidRenderer; diff --git a/crates/adapters/mermaid/src/mermaid_renderer.rs b/crates/adapters/mermaid/src/mermaid_renderer.rs new file mode 100644 index 0000000..8b70c5e --- /dev/null +++ b/crates/adapters/mermaid/src/mermaid_renderer.rs @@ -0,0 +1,266 @@ +use std::collections::{HashMap, HashSet}; + +use archlens_domain::{ + CodeElement, CodeGraph, DiagramLevel, DomainError, RelationshipKind, RenderOutput, + RenderedFile, Visibility, ports::DiagramRenderer, +}; + +pub struct MermaidRenderer { + level: DiagramLevel, +} + +impl Default for MermaidRenderer { + fn default() -> Self { + Self::new() + } +} + +impl MermaidRenderer { + pub fn new() -> Self { + Self { + level: DiagramLevel::Type, + } + } + + pub fn with_level(level: DiagramLevel) -> Self { + Self { level } + } + + fn format_element_name(element: &CodeElement) -> String { + let name = element.name(); + if element.generics().is_empty() { + name.to_string() + } else { + format!("{}~{}~", name, element.generics().join(", ")) + } + } + + fn format_visibility(visibility: Visibility) -> &'static str { + match visibility { + Visibility::Public => "public", + Visibility::Private => "private", + Visibility::Internal => "internal", + } + } + + fn render_class_diagram(&self, graph: &CodeGraph) -> String { + let mut lines = vec!["classDiagram".to_string()]; + + let mut grouped: HashMap> = HashMap::new(); + let mut ungrouped: Vec<&CodeElement> = Vec::new(); + + for element in graph.elements() { + if let Some(module) = element.module() { + grouped + .entry(module.as_str().to_string()) + .or_default() + .push(element); + } else { + ungrouped.push(element); + } + } + + let has_namespaces = !grouped.is_empty(); + + let mut seen: HashSet = HashSet::new(); + for element in &ungrouped { + if seen.insert(element.name().to_string()) { + Self::push_class_lines(&mut lines, element, " "); + } + } + + if has_namespaces { + for (namespace, elements) in &grouped { + lines.push(format!(" namespace {namespace} {{")); + let mut ns_seen: HashSet = HashSet::new(); + for element in elements { + if ns_seen.insert(element.name().to_string()) { + Self::push_class_lines(&mut lines, element, " "); + } + } + lines.push(" }".to_string()); + } + } + + let mut rel_seen: HashSet = HashSet::new(); + for rel in graph.relationships() { + if rel.kind() == RelationshipKind::Import { + continue; + } + let arrow = match rel.kind() { + RelationshipKind::Inheritance => "<|--", + RelationshipKind::Composition => "-->", + RelationshipKind::Import => "..>", + }; + let key = format!("{} {} {}", rel.source(), arrow, rel.target()); + if rel_seen.insert(key.clone()) { + lines.push(format!(" {key}")); + } + } + + lines.join("\n") + } + + fn push_class_lines(lines: &mut Vec, element: &CodeElement, indent: &str) { + lines.push(format!( + "{indent}class {}", + Self::format_element_name(element) + )); + if element.visibility() != Visibility::Public { + lines.push(format!( + "{indent}<<{}>> {}", + Self::format_visibility(element.visibility()), + element.name() + )); + } + let name = element.name(); + for field in element.fields() { + lines.push(format!("{indent}{name} : {field}")); + } + for method in element.methods() { + lines.push(format!("{indent}{name} : {method}")); + } + } + + fn render_module_flowchart(&self, graph: &CodeGraph) -> String { + let mut lines = vec!["graph TD".to_string()]; + + let mut name_to_modules: HashMap<&str, HashSet<&str>> = HashMap::new(); + let mut file_to_module: HashMap = HashMap::new(); + let mut modules: HashSet = HashSet::new(); + + for element in graph.elements() { + if let Some(module) = element.module() { + name_to_modules + .entry(element.name()) + .or_default() + .insert(module.as_str()); + modules.insert(module.as_str().to_string()); + + let file_stem = std::path::Path::new(element.file_path().as_str()) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + if !file_stem.is_empty() { + file_to_module.insert(file_stem.to_string(), module.as_str().to_string()); + } + } + } + + for module in &modules { + lines.push(format!(" {module}[{module}]")); + } + + let mut module_edges: HashSet<(String, String)> = HashSet::new(); + for rel in graph.relationships() { + match rel.kind() { + RelationshipKind::Import => { + let source_mod = file_to_module.get(rel.source()); + let target_top = rel.target().split('.').next().unwrap_or(""); + let target_mod = Self::capitalize(target_top); + + if let Some(src) = source_mod + && modules.contains(&target_mod) + && *src != target_mod + { + module_edges.insert((src.clone(), target_mod)); + } + } + _ => { + if modules.contains(rel.source()) + && modules.contains(rel.target()) + && rel.source() != rel.target() + { + module_edges.insert((rel.source().to_string(), rel.target().to_string())); + continue; + } + + let src_mods = name_to_modules.get(rel.source()); + let tgt_mods = name_to_modules.get(rel.target()); + + if let (Some(src_set), Some(tgt_set)) = (src_mods, tgt_mods) { + for src_mod in src_set { + if tgt_set.contains(src_mod) { + continue; + } + for tgt_mod in tgt_set { + if src_mod != tgt_mod { + module_edges.insert((src_mod.to_string(), tgt_mod.to_string())); + } + } + } + } + } + } + } + + for (source, target) in &module_edges { + lines.push(format!(" {source} --> {target}")); + } + + lines.join("\n") + } + + fn capitalize(s: &str) -> String { + if s.is_empty() { + return String::new(); + } + format!("{}{}", s[..1].to_uppercase(), &s[1..]) + } + + fn render_project_flowchart(&self, graph: &CodeGraph) -> String { + let mut lines = vec!["graph TD".to_string()]; + + let mut grouped: HashMap> = HashMap::new(); + let mut ungrouped: Vec<&CodeElement> = Vec::new(); + + for element in graph.elements() { + if let Some(module) = element.module() { + grouped + .entry(module.as_str().to_string()) + .or_default() + .push(element); + } else { + ungrouped.push(element); + } + } + + for element in &ungrouped { + let id = Self::sanitize_id(element.name()); + lines.push(format!(" {id}[{}]", element.name())); + } + + for (group, elements) in &grouped { + lines.push(format!(" subgraph {group}")); + for element in elements { + let id = Self::sanitize_id(element.name()); + lines.push(format!(" {id}[{}]", element.name())); + } + lines.push(" end".to_string()); + } + + for rel in graph.relationships() { + let source_id = Self::sanitize_id(rel.source()); + let target_id = Self::sanitize_id(rel.target()); + lines.push(format!(" {source_id} --> {target_id}")); + } + + lines.join("\n") + } + + fn sanitize_id(name: &str) -> String { + name.replace(['-', '.'], "_") + } +} + +impl DiagramRenderer for MermaidRenderer { + fn render(&self, graph: &CodeGraph) -> Result { + let content = match self.level { + DiagramLevel::Type => self.render_class_diagram(graph), + DiagramLevel::Module => self.render_module_flowchart(graph), + DiagramLevel::Project => self.render_project_flowchart(graph), + }; + let file = RenderedFile::new("diagram.mmd", &content)?; + Ok(RenderOutput::single(file)) + } +} diff --git a/crates/adapters/mermaid/tests/mermaid_renderer_tests.rs b/crates/adapters/mermaid/tests/mermaid_renderer_tests.rs new file mode 100644 index 0000000..f32b00a --- /dev/null +++ b/crates/adapters/mermaid/tests/mermaid_renderer_tests.rs @@ -0,0 +1,328 @@ +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, DiagramLevel, FilePath, ModuleName, Relationship, + RelationshipKind, Visibility, ports::DiagramRenderer, +}; +use archlens_mermaid::MermaidRenderer; + +fn build_type_level_graph() -> CodeGraph { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/service.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_element( + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_relationship( + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(), + ); + graph +} + +#[test] +fn renders_class_diagram_with_elements_and_composition() { + let renderer = MermaidRenderer::new(); + let output = renderer.render(&build_type_level_graph()).unwrap(); + + let content = output.files()[0].content(); + assert!(content.contains("classDiagram")); + assert!(content.contains("class OrderService")); + assert!(content.contains("class Order")); + assert!(content.contains("OrderService --> Order")); +} + +#[test] +fn inheritance_uses_different_arrow() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "Animal", + CodeElementKind::Class, + FilePath::new("src/animal.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_element( + CodeElement::new( + "Dog", + CodeElementKind::Class, + FilePath::new("src/dog.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_relationship( + Relationship::new("Dog", "Animal", RelationshipKind::Inheritance).unwrap(), + ); + + let renderer = MermaidRenderer::new(); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("Dog <|-- Animal")); +} + +#[test] +fn elements_show_kind_and_generics() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "Repository", + CodeElementKind::Trait, + FilePath::new("src/repo.rs").unwrap(), + 1, + ) + .unwrap() + .with_generics(vec!["T".to_string()]), + ); + + let renderer = MermaidRenderer::new(); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("class Repository~T~")); +} + +#[test] +fn private_elements_show_visibility_annotation() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "InternalHelper", + CodeElementKind::Class, + FilePath::new("src/helper.rs").unwrap(), + 1, + ) + .unwrap() + .with_visibility(Visibility::Private), + ); + + let renderer = MermaidRenderer::new(); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("class InternalHelper")); + assert!(content.contains("<>")); +} + +#[test] +fn renders_module_level_flowchart() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/orders/service.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "BillingService", + CodeElementKind::Class, + FilePath::new("src/billing/service.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Billing").unwrap()), + ); + graph.add_relationship( + Relationship::new( + "OrderService", + "BillingService", + RelationshipKind::Composition, + ) + .unwrap(), + ); + + let renderer = MermaidRenderer::with_level(DiagramLevel::Module); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("graph TD")); + assert!(content.contains("Orders")); + assert!(content.contains("Billing")); + assert!(content.contains("Orders --> Billing")); +} + +#[test] +fn empty_graph_produces_valid_diagram() { + let renderer = MermaidRenderer::new(); + let output = renderer.render(&CodeGraph::new()).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("classDiagram")); +} + +#[test] +fn project_level_renders_subgraphs_for_grouped_projects() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "myapp-domain", + CodeElementKind::Project, + FilePath::new("crates/domain/Cargo.toml").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_element( + CodeElement::new( + "myapp-sqlite", + CodeElementKind::Project, + FilePath::new("crates/adapters/sqlite/Cargo.toml").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Adapters").unwrap()), + ); + graph.add_element( + CodeElement::new( + "myapp-nats", + CodeElementKind::Project, + FilePath::new("crates/adapters/nats/Cargo.toml").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Adapters").unwrap()), + ); + graph.add_relationship( + Relationship::new( + "myapp-sqlite", + "myapp-domain", + RelationshipKind::Composition, + ) + .unwrap(), + ); + + let renderer = MermaidRenderer::with_level(DiagramLevel::Project); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("graph TD")); + assert!(content.contains("subgraph Adapters")); + assert!(content.contains("myapp-sqlite")); + assert!(content.contains("myapp-nats")); + assert!(content.contains("myapp-domain")); + assert!(content.contains("-->")); +} + +#[test] +fn type_level_groups_elements_by_module() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/orders/service.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/orders/order.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "Invoice", + CodeElementKind::Struct, + FilePath::new("src/billing/invoice.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Billing").unwrap()), + ); + + let renderer = MermaidRenderer::new(); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("namespace Orders")); + assert!(content.contains("namespace Billing")); + assert!(content.contains("OrderService")); + assert!(content.contains("Invoice")); +} + +#[test] +fn module_level_aggregates_cross_module_deps_into_single_arrow() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/orders/service.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "OrderRepo", + CodeElementKind::Trait, + FilePath::new("src/orders/repo.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "DbPool", + CodeElementKind::Struct, + FilePath::new("src/infra/db.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Infra").unwrap()), + ); + graph.add_element( + CodeElement::new( + "SqliteRepo", + CodeElementKind::Struct, + FilePath::new("src/infra/sqlite.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Infra").unwrap()), + ); + // Two types in Orders depend on two types in Infra + graph.add_relationship( + Relationship::new("OrderService", "DbPool", RelationshipKind::Composition).unwrap(), + ); + graph.add_relationship( + Relationship::new("OrderRepo", "SqliteRepo", RelationshipKind::Composition).unwrap(), + ); + + let renderer = MermaidRenderer::with_level(DiagramLevel::Module); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + let arrow_count = content.matches("Orders --> Infra").count(); + assert_eq!( + arrow_count, 1, + "should have exactly one aggregated arrow, got:\n{content}" + ); +} diff --git a/crates/adapters/stdout-writer/Cargo.toml b/crates/adapters/stdout-writer/Cargo.toml new file mode 100644 index 0000000..3868477 --- /dev/null +++ b/crates/adapters/stdout-writer/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "archlens-stdout-writer" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/adapters/stdout-writer/src/lib.rs b/crates/adapters/stdout-writer/src/lib.rs new file mode 100644 index 0000000..864099c --- /dev/null +++ b/crates/adapters/stdout-writer/src/lib.rs @@ -0,0 +1,3 @@ +mod stdout_output_writer; + +pub use stdout_output_writer::StdoutOutputWriter; diff --git a/crates/adapters/stdout-writer/src/stdout_output_writer.rs b/crates/adapters/stdout-writer/src/stdout_output_writer.rs new file mode 100644 index 0000000..5fcead6 --- /dev/null +++ b/crates/adapters/stdout-writer/src/stdout_output_writer.rs @@ -0,0 +1,24 @@ +use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter}; + +pub struct StdoutOutputWriter; + +impl Default for StdoutOutputWriter { + fn default() -> Self { + Self::new() + } +} + +impl StdoutOutputWriter { + pub fn new() -> Self { + Self + } +} + +impl OutputWriter for StdoutOutputWriter { + fn write(&self, output: &RenderOutput) -> Result<(), DomainError> { + for file in output.files() { + println!("{}", file.content()); + } + Ok(()) + } +} diff --git a/crates/adapters/stdout-writer/tests/stdout_writer_tests.rs b/crates/adapters/stdout-writer/tests/stdout_writer_tests.rs new file mode 100644 index 0000000..f0f9bda --- /dev/null +++ b/crates/adapters/stdout-writer/tests/stdout_writer_tests.rs @@ -0,0 +1,12 @@ +use archlens_domain::{RenderOutput, RenderedFile, ports::OutputWriter}; +use archlens_stdout_writer::StdoutOutputWriter; + +#[test] +fn writes_without_error() { + let writer = StdoutOutputWriter::new(); + let file = RenderedFile::new("arch.mmd", "classDiagram").unwrap(); + let output = RenderOutput::single(file); + + let result = writer.write(&output); + assert!(result.is_ok()); +} diff --git a/crates/adapters/toml-config/Cargo.toml b/crates/adapters/toml-config/Cargo.toml new file mode 100644 index 0000000..986f099 --- /dev/null +++ b/crates/adapters/toml-config/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "archlens-toml-config" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true +toml.workspace = true +serde.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/toml-config/src/lib.rs b/crates/adapters/toml-config/src/lib.rs new file mode 100644 index 0000000..9e2db16 --- /dev/null +++ b/crates/adapters/toml-config/src/lib.rs @@ -0,0 +1,3 @@ +mod toml_config_loader; + +pub use toml_config_loader::TomlConfigLoader; diff --git a/crates/adapters/toml-config/src/toml_config_loader.rs b/crates/adapters/toml-config/src/toml_config_loader.rs new file mode 100644 index 0000000..e8738ad --- /dev/null +++ b/crates/adapters/toml-config/src/toml_config_loader.rs @@ -0,0 +1,81 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::Deserialize; + +use archlens_domain::{ + AnalysisConfig, DiagramLevel, DomainError, OutputConfig, ports::ConfigLoader, +}; + +#[derive(Debug, Deserialize, Default)] +struct RawConfig { + #[serde(default)] + analysis: RawAnalysis, + #[serde(default)] + output: RawOutput, + #[serde(default)] + modules: HashMap, +} + +#[derive(Debug, Deserialize, Default)] +struct RawAnalysis { + #[serde(default)] + exclude: Vec, + #[serde(default)] + level: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct RawOutput { + #[serde(default)] + #[allow(dead_code)] + format: Option, + #[serde(default)] + path: Option, + #[serde(default)] + split_by_module: bool, +} + +#[derive(Default)] +pub struct TomlConfigLoader { + raw: RawConfig, +} + +impl TomlConfigLoader { + pub fn from_path(path: &Path) -> Result { + let content = + std::fs::read_to_string(path).map_err(|e| DomainError::IoError(e.to_string()))?; + let raw: RawConfig = + toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?; + Ok(Self { raw }) + } + + fn parse_level(level: &Option) -> DiagramLevel { + match level.as_deref() { + Some("type") => DiagramLevel::Type, + Some("project") => DiagramLevel::Project, + _ => DiagramLevel::Module, + } + } +} + +impl ConfigLoader for TomlConfigLoader { + fn load_analysis_config(&self) -> Result { + let config = AnalysisConfig::default() + .with_excludes(self.raw.analysis.exclude.clone()) + .with_level(Self::parse_level(&self.raw.analysis.level)) + .with_module_mappings(self.raw.modules.clone()); + Ok(config) + } + + fn load_output_config(&self) -> Result { + let mut config = + OutputConfig::default().with_split_by_module(self.raw.output.split_by_module); + + if let Some(path) = &self.raw.output.path { + config = config.with_output_path(path.clone()); + } + + Ok(config) + } +} diff --git a/crates/adapters/toml-config/tests/toml_config_tests.rs b/crates/adapters/toml-config/tests/toml_config_tests.rs new file mode 100644 index 0000000..0015abb --- /dev/null +++ b/crates/adapters/toml-config/tests/toml_config_tests.rs @@ -0,0 +1,68 @@ +use std::fs; + +use archlens_domain::{DiagramLevel, ports::ConfigLoader}; +use archlens_toml_config::TomlConfigLoader; + +#[test] +fn loads_analysis_config_from_toml_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("archlens.toml"); + fs::write( + &config_path, + r#" +[analysis] +exclude = ["tests/", "vendor/"] +level = "type" + +[modules] +"src/orders" = "Orders" +"src/billing" = "Billing" +"#, + ) + .unwrap(); + + let loader = TomlConfigLoader::from_path(&config_path).unwrap(); + let config = loader.load_analysis_config().unwrap(); + + assert_eq!(config.excludes(), &["tests/", "vendor/"]); + assert_eq!(config.level(), DiagramLevel::Type); + assert_eq!( + config.module_mappings().get("src/orders").unwrap(), + "Orders" + ); +} + +#[test] +fn loads_output_config_from_toml_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("archlens.toml"); + fs::write( + &config_path, + r#" +[output] +format = "mermaid" +path = "docs/arch.mmd" +split_by_module = true +"#, + ) + .unwrap(); + + let loader = TomlConfigLoader::from_path(&config_path).unwrap(); + let config = loader.load_output_config().unwrap(); + + assert!(config.split_by_module()); + assert_eq!(config.output_path(), Some("docs/arch.mmd")); +} + +#[test] +fn missing_file_returns_defaults() { + let loader = TomlConfigLoader::default(); + + let analysis = loader.load_analysis_config().unwrap(); + assert!(analysis.excludes().is_empty()); + assert_eq!(analysis.level(), DiagramLevel::Module); + + let output = loader.load_output_config().unwrap(); + assert!(!output.split_by_module()); + assert!(output.output_path().is_none()); +} diff --git a/crates/adapters/tree-sitter/Cargo.toml b/crates/adapters/tree-sitter/Cargo.toml new file mode 100644 index 0000000..1fe9a44 --- /dev/null +++ b/crates/adapters/tree-sitter/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "archlens-tree-sitter" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true +tree-sitter.workspace = true +tree-sitter-rust.workspace = true +tree-sitter-python.workspace = true +tree-sitter-c-sharp.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/tree-sitter/src/lib.rs b/crates/adapters/tree-sitter/src/lib.rs new file mode 100644 index 0000000..b2f45c4 --- /dev/null +++ b/crates/adapters/tree-sitter/src/lib.rs @@ -0,0 +1,5 @@ +mod python; +mod rust; +mod tree_sitter_analyzer; + +pub use tree_sitter_analyzer::TreeSitterAnalyzer; diff --git a/crates/adapters/tree-sitter/src/python/mod.rs b/crates/adapters/tree-sitter/src/python/mod.rs new file mode 100644 index 0000000..a4d6a74 --- /dev/null +++ b/crates/adapters/tree-sitter/src/python/mod.rs @@ -0,0 +1,337 @@ +use std::collections::HashSet; + +use tree_sitter::{Node, Parser}; + +use archlens_domain::{ + AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath, + Relationship, RelationshipKind, +}; + +pub fn analyze(source: &str, file_path: &FilePath) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_python::LANGUAGE.into()) + .map_err(|e| DomainError::AnalysisError(e.to_string()))?; + + let tree = parser + .parse(source, None) + .ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?; + + let mut elements = Vec::new(); + let mut relationships = Vec::new(); + let mut warnings = Vec::new(); + let mut type_names: HashSet = HashSet::new(); + + let root = tree.root_node(); + collect_classes( + &root, + source, + file_path, + &mut elements, + &mut type_names, + &mut relationships, + &mut warnings, + ); + collect_imports(&root, source, file_path, &mut relationships); + + let relationships = relationships + .into_iter() + .map(|r| r.with_source_file(file_path.clone())) + .collect(); + + Ok(AnalysisResult::new(elements, relationships, warnings)) +} + +fn collect_classes( + node: &Node, + source: &str, + file_path: &FilePath, + elements: &mut Vec, + type_names: &mut HashSet, + relationships: &mut Vec, + warnings: &mut Vec, +) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() != "class_definition" { + continue; + } + + let Some(name_node) = child.child_by_field_name("name") else { + continue; + }; + + let name = &source[name_node.byte_range()]; + let line = child.start_position().row + 1; + + match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) { + Ok(element) => { + type_names.insert(name.to_string()); + elements.push(element); + } + Err(e) => { + if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) { + warnings.push(w); + } + continue; + } + } + + if let Some(superclasses) = child.child_by_field_name("superclasses") { + collect_inheritance(&superclasses, source, name, type_names, relationships); + } + + if let Some(body) = child.child_by_field_name("body") { + collect_typed_fields(&body, source, name, type_names, relationships); + collect_constructor_params(&body, source, name, type_names, relationships); + } + } +} + +fn collect_inheritance( + superclasses: &Node, + source: &str, + class_name: &str, + _type_names: &HashSet, + relationships: &mut Vec, +) { + let mut cursor = superclasses.walk(); + for child in superclasses.children(&mut cursor) { + if child.kind() == "identifier" { + let base_name = &source[child.byte_range()]; + if !is_python_builtin(base_name) + && let Ok(rel) = + Relationship::new(class_name, base_name, RelationshipKind::Inheritance) + { + relationships.push(rel); + } + } + } +} + +const PYTHON_BUILTINS: &[&str] = &[ + "str", + "int", + "float", + "bool", + "bytes", + "list", + "dict", + "set", + "tuple", + "None", + "type", + "object", + "Exception", + "BaseException", + "Optional", + "Any", + "Union", + "List", + "Dict", + "Set", + "Tuple", + "Callable", + "Sequence", + "Mapping", + "Iterable", + "Iterator", + "Generator", + "Coroutine", + "AsyncGenerator", + "ClassVar", + "Final", + "Literal", + "TypeVar", + "Generic", + "Protocol", + "runtime_checkable", + "Self", +]; + +fn is_python_builtin(name: &str) -> bool { + PYTHON_BUILTINS.contains(&name) +} + +const STDLIB_MODULES: &[&str] = &[ + "os", + "sys", + "typing", + "logging", + "json", + "re", + "io", + "abc", + "collections", + "datetime", + "enum", + "functools", + "hashlib", + "http", + "importlib", + "inspect", + "itertools", + "math", + "pathlib", + "pickle", + "random", + "shutil", + "signal", + "socket", + "string", + "subprocess", + "tempfile", + "threading", + "time", + "traceback", + "unittest", + "urllib", + "uuid", + "warnings", + "contextlib", + "dataclasses", + "copy", + "struct", + "base64", + "csv", + "glob", + "operator", + "textwrap", + "asyncio", + "concurrent", + "multiprocessing", +]; + +fn is_external_import(module: &str) -> bool { + let top = module.split('.').next().unwrap_or(module); + if STDLIB_MODULES.contains(&top) { + return true; + } + if top.starts_with('_') { + return true; + } + false +} + +fn collect_imports( + node: &Node, + source: &str, + file_path: &FilePath, + relationships: &mut Vec, +) { + let file_name = std::path::Path::new(file_path.as_str()) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + match child.kind() { + "import_statement" => { + let mut name_cursor = child.walk(); + for name_child in child.children(&mut name_cursor) { + if name_child.kind() == "dotted_name" { + let module = &source[name_child.byte_range()]; + if !is_external_import(module) + && let Ok(rel) = + Relationship::new(file_name, module, RelationshipKind::Import) + { + relationships.push(rel); + } + } + } + } + "import_from_statement" => { + if let Some(module_node) = child.child_by_field_name("module_name") { + let module = &source[module_node.byte_range()]; + if !is_external_import(module) + && let Ok(rel) = + Relationship::new(file_name, module, RelationshipKind::Import) + { + relationships.push(rel); + } + } + } + _ => {} + } + } +} + +fn collect_constructor_params( + body: &Node, + source: &str, + class_name: &str, + _type_names: &HashSet, + relationships: &mut Vec, +) { + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + if child.kind() != "function_definition" { + continue; + } + let Some(fn_name) = child.child_by_field_name("name") else { + continue; + }; + if &source[fn_name.byte_range()] != "__init__" { + continue; + } + let Some(params) = child.child_by_field_name("parameters") else { + continue; + }; + let mut param_cursor = params.walk(); + for param in params.children(&mut param_cursor) { + if param.kind() == "typed_parameter" + && let Some(type_node) = param.child_by_field_name("type") + { + let type_text = &source[type_node.byte_range()]; + let base_type = type_text.split('[').next().unwrap_or(type_text).trim(); + if base_type != class_name + && !base_type.is_empty() + && !is_python_builtin(base_type) + && let Ok(rel) = + Relationship::new(class_name, base_type, RelationshipKind::Composition) + { + relationships.push(rel); + } + } + } + } +} + +fn collect_typed_fields( + body: &Node, + source: &str, + class_name: &str, + type_names: &HashSet, + relationships: &mut Vec, +) { + collect_typed_fields_recursive(body, source, class_name, type_names, relationships); +} + +fn collect_typed_fields_recursive( + node: &Node, + source: &str, + class_name: &str, + _type_names: &HashSet, + relationships: &mut Vec, +) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if (child.kind() == "assignment" || child.kind() == "typed_assignment") + && let Some(type_node) = child.child_by_field_name("type") + { + let type_text = &source[type_node.byte_range()]; + let base_type = type_text.split('[').next().unwrap_or(type_text).trim(); + if base_type != class_name + && !base_type.is_empty() + && !is_python_builtin(base_type) + && let Ok(rel) = + Relationship::new(class_name, base_type, RelationshipKind::Composition) + { + relationships.push(rel); + } + } + + collect_typed_fields_recursive(&child, source, class_name, _type_names, relationships); + } +} diff --git a/crates/adapters/tree-sitter/src/rust/mod.rs b/crates/adapters/tree-sitter/src/rust/mod.rs new file mode 100644 index 0000000..36f350f --- /dev/null +++ b/crates/adapters/tree-sitter/src/rust/mod.rs @@ -0,0 +1,349 @@ +use std::collections::HashSet; + +use tree_sitter::{Node, Parser}; + +const RUST_PRIMITIVES: &[&str] = &[ + "bool", + "char", + "str", + "String", + "u8", + "u16", + "u32", + "u64", + "u128", + "usize", + "i8", + "i16", + "i32", + "i64", + "i128", + "isize", + "f32", + "f64", + "Vec", + "Option", + "Result", + "Box", + "Rc", + "Arc", + "HashMap", + "HashSet", + "BTreeMap", + "BTreeSet", + "PhantomData", + "Pin", + "Cow", + "Self", +]; + +use archlens_domain::{ + AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath, + Relationship, RelationshipKind, Visibility, +}; + +pub fn analyze(source: &str, file_path: &FilePath) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_rust::LANGUAGE.into()) + .map_err(|e| DomainError::AnalysisError(e.to_string()))?; + + let tree = parser + .parse(source, None) + .ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?; + + let mut elements = Vec::new(); + let mut relationships = Vec::new(); + let mut warnings = Vec::new(); + let mut type_names: HashSet = HashSet::new(); + + let root = tree.root_node(); + collect_types( + &root, + source, + file_path, + &mut elements, + &mut type_names, + &mut warnings, + ); + collect_relationships( + &root, + source, + &type_names, + &mut relationships, + &mut warnings, + ); + collect_mod_declarations(&root, source, file_path, &mut relationships); + collect_use_imports(&root, source, file_path, &mut relationships); + + let relationships = relationships + .into_iter() + .map(|r| r.with_source_file(file_path.clone())) + .collect(); + + Ok(AnalysisResult::new(elements, relationships, warnings)) +} + +fn collect_types( + node: &Node, + source: &str, + file_path: &FilePath, + elements: &mut Vec, + type_names: &mut HashSet, + warnings: &mut Vec, +) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + let (kind, name_field) = match child.kind() { + "struct_item" => (CodeElementKind::Struct, "name"), + "enum_item" => (CodeElementKind::Enum, "name"), + "trait_item" => (CodeElementKind::Trait, "name"), + _ => continue, + }; + + if let Some(name_node) = child.child_by_field_name(name_field) { + let name = &source[name_node.byte_range()]; + let line = child.start_position().row + 1; + let visibility = detect_visibility(&child, source); + + match CodeElement::new(name, kind, file_path.clone(), line) { + Ok(element) => { + let fields = extract_fields(&child, source); + let methods = extract_methods(node, source, name); + let element = element + .with_visibility(visibility) + .with_fields(fields) + .with_methods(methods); + type_names.insert(name.to_string()); + elements.push(element); + } + Err(e) => { + if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) { + warnings.push(w); + } + } + } + } + } +} + +fn collect_relationships( + node: &Node, + source: &str, + type_names: &HashSet, + relationships: &mut Vec, + warnings: &mut Vec, +) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + match child.kind() { + "struct_item" => { + if let Some(name_node) = child.child_by_field_name("name") { + let struct_name = source[name_node.byte_range()].to_string(); + collect_field_compositions( + &child, + source, + &struct_name, + type_names, + relationships, + ); + } + } + "impl_item" => { + collect_trait_impl(&child, source, type_names, relationships, warnings); + } + _ => {} + } + } +} + +fn collect_field_compositions( + struct_node: &Node, + source: &str, + struct_name: &str, + _type_names: &HashSet, + relationships: &mut Vec, +) { + if let Some(body) = struct_node.child_by_field_name("body") { + let mut cursor = body.walk(); + for field in body.children(&mut cursor) { + if field.kind() == "field_declaration" + && let Some(type_node) = field.child_by_field_name("type") + { + let type_text = extract_base_type(&type_node, source); + if type_text != struct_name + && !type_text.is_empty() + && !RUST_PRIMITIVES.contains(&type_text.as_str()) + && let Ok(rel) = + Relationship::new(struct_name, &type_text, RelationshipKind::Composition) + { + relationships.push(rel); + } + } + } + } +} + +fn collect_trait_impl( + impl_node: &Node, + source: &str, + _type_names: &HashSet, + relationships: &mut Vec, + _warnings: &mut Vec, +) { + let trait_node = impl_node.child_by_field_name("trait"); + let type_node = impl_node.child_by_field_name("type"); + + if let (Some(trait_n), Some(type_n)) = (trait_node, type_node) { + let trait_name = extract_base_type(&trait_n, source); + let type_name = extract_base_type(&type_n, source); + + if !trait_name.is_empty() + && !type_name.is_empty() + && !RUST_PRIMITIVES.contains(&trait_name.as_str()) + && !RUST_PRIMITIVES.contains(&type_name.as_str()) + && let Ok(rel) = + Relationship::new(&type_name, &trait_name, RelationshipKind::Inheritance) + { + relationships.push(rel); + } + } +} + +fn extract_fields(node: &Node, source: &str) -> Vec { + let mut fields = Vec::new(); + if let Some(body) = node.child_by_field_name("body") { + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + if child.kind() == "field_declaration" { + let name = child + .child_by_field_name("name") + .map(|n| &source[n.byte_range()]); + let ty = child + .child_by_field_name("type") + .map(|n| extract_base_type(&n, source)); + if let (Some(name), Some(ty)) = (name, ty) { + fields.push(format!("{name}: {ty}")); + } + } + } + } + fields +} + +fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec { + let mut methods = Vec::new(); + let mut cursor = root.walk(); + for child in root.children(&mut cursor) { + if child.kind() != "impl_item" { + continue; + } + if child.child_by_field_name("trait").is_some() { + continue; + } + let type_node = child.child_by_field_name("type"); + if let Some(tn) = type_node { + let impl_name = extract_base_type(&tn, source); + if impl_name != type_name { + continue; + } + } + if let Some(body) = child.child_by_field_name("body") { + let mut body_cursor = body.walk(); + for item in body.children(&mut body_cursor) { + if item.kind() == "function_item" + && let Some(name_node) = item.child_by_field_name("name") + { + let fn_name = &source[name_node.byte_range()]; + let vis = if detect_visibility(&item, source) == Visibility::Public { + "+" + } else { + "-" + }; + methods.push(format!("{vis}{fn_name}()")); + } + } + } + } + methods +} + +fn collect_mod_declarations( + node: &Node, + source: &str, + file_path: &FilePath, + relationships: &mut Vec, +) { + let file_name = std::path::Path::new(file_path.as_str()) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() != "mod_item" { + continue; + } + if child.child_by_field_name("body").is_some() { + continue; + } + if let Some(name_node) = child.child_by_field_name("name") { + let mod_name = &source[name_node.byte_range()]; + let target = format!("crate::{mod_name}"); + if let Ok(rel) = Relationship::new(file_name, &target, RelationshipKind::Import) { + relationships.push(rel); + } + } + } +} + +fn collect_use_imports( + node: &Node, + source: &str, + file_path: &FilePath, + relationships: &mut Vec, +) { + let file_name = std::path::Path::new(file_path.as_str()) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() != "use_declaration" { + continue; + } + if let Some(arg) = child.child_by_field_name("argument") { + let text = &source[arg.byte_range()]; + let path = text + .split('{') + .next() + .unwrap_or(text) + .trim_end_matches("::"); + + if (path.starts_with("crate::") || path.starts_with("super::")) + && let Ok(rel) = Relationship::new(file_name, path, RelationshipKind::Import) + { + relationships.push(rel); + } + } + } +} + +fn extract_base_type(node: &Node, source: &str) -> String { + let text = &source[node.byte_range()]; + text.split('<').next().unwrap_or(text).trim().to_string() +} + +fn detect_visibility(node: &Node, source: &str) -> Visibility { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "visibility_modifier" { + let text = &source[child.byte_range()]; + if text.contains("pub") { + return Visibility::Public; + } + } + } + Visibility::Private +} diff --git a/crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs b/crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs new file mode 100644 index 0000000..30806f6 --- /dev/null +++ b/crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs @@ -0,0 +1,30 @@ +use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer}; + +use crate::{python, rust}; + +pub struct TreeSitterAnalyzer; + +impl Default for TreeSitterAnalyzer { + fn default() -> Self { + Self::new() + } +} + +impl TreeSitterAnalyzer { + pub fn new() -> Self { + Self + } +} + +impl SourceAnalyzer for TreeSitterAnalyzer { + fn analyze_file(&self, file: &SourceFile) -> Result { + let source = std::fs::read_to_string(file.path().as_str()) + .map_err(|e| DomainError::IoError(e.to_string()))?; + + match file.language() { + Language::Rust => rust::analyze(&source, file.path()), + Language::Python => python::analyze(&source, file.path()), + Language::CSharp => Ok(AnalysisResult::empty()), + } + } +} diff --git a/crates/adapters/tree-sitter/tests/python_analyzer_tests.rs b/crates/adapters/tree-sitter/tests/python_analyzer_tests.rs new file mode 100644 index 0000000..8956064 --- /dev/null +++ b/crates/adapters/tree-sitter/tests/python_analyzer_tests.rs @@ -0,0 +1,138 @@ +use archlens_domain::{ + CodeElementKind, FilePath, Language, RelationshipKind, SourceFile, ports::SourceAnalyzer, +}; +use archlens_tree_sitter::TreeSitterAnalyzer; + +fn analyze_python(source: &str, filename: &str) -> archlens_domain::AnalysisResult { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join(filename); + std::fs::write(&file_path, source).unwrap(); + + let analyzer = TreeSitterAnalyzer::new(); + let source_file = SourceFile::new( + FilePath::new(file_path.to_str().unwrap()).unwrap(), + Language::Python, + ); + analyzer.analyze_file(&source_file).unwrap() +} + +#[test] +fn extracts_python_class() { + let result = analyze_python("class Order:\n pass\n", "order.py"); + + assert_eq!(result.elements().len(), 1); + assert_eq!(result.elements()[0].name(), "Order"); + assert_eq!(result.elements()[0].kind(), CodeElementKind::Class); +} + +#[test] +fn extracts_python_inheritance() { + let source = "class Animal:\n pass\n\nclass Dog(Animal):\n pass\n"; + let result = analyze_python(source, "animals.py"); + + assert_eq!(result.elements().len(), 2); + + let inheritance: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Inheritance) + .collect(); + + assert_eq!(inheritance.len(), 1); + assert_eq!(inheritance[0].source(), "Dog"); + assert_eq!(inheritance[0].target(), "Animal"); +} + +#[test] +fn extracts_composition_from_type_annotated_fields() { + let source = "class Address:\n pass\n\nclass User:\n def __init__(self):\n self.address: Address = Address()\n"; + let result = analyze_python(source, "user.py"); + + let composition: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Composition) + .collect(); + + assert_eq!(composition.len(), 1); + assert_eq!(composition[0].source(), "User"); + assert_eq!(composition[0].target(), "Address"); +} + +#[test] +fn extracts_import_from_import_statement() { + let source = "import os\nfrom commons.src.schema import BaseModel\n"; + let result = analyze_python(source, "service.py"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert!(imports.iter().any(|r| r.target() == "commons.src.schema")); + assert!( + !imports.iter().any(|r| r.target() == "os"), + "stdlib should be filtered" + ); +} + +#[test] +fn extracts_relative_imports_from_init() { + let source = "from .schema import BaseModel\nfrom .client import ApiClient\n"; + let result = analyze_python(source, "__init__.py"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert_eq!(imports.len(), 2); +} + +#[test] +fn extracts_import_from_plain_import() { + let source = "import commons.utils\n"; + let result = analyze_python(source, "service.py"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert!(imports.iter().any(|r| r.target() == "commons.utils")); +} + +#[test] +fn extracts_composition_from_constructor_params() { + let source = "class Config:\n pass\n\nclass Service:\n def __init__(self, config: Config):\n pass\n"; + let result = analyze_python(source, "service.py"); + + let composition: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Composition) + .collect(); + + assert_eq!(composition.len(), 1); + assert_eq!(composition[0].source(), "Service"); + assert_eq!(composition[0].target(), "Config"); +} + +#[test] +fn extracts_composition_from_class_level_annotations() { + let source = "class Gad:\n pass\n\nclass Definition:\n gad: Gad\n name: str\n"; + let result = analyze_python(source, "models.py"); + + let composition: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Composition) + .collect(); + + assert_eq!(composition.len(), 1); + assert_eq!(composition[0].source(), "Definition"); + assert_eq!(composition[0].target(), "Gad"); +} diff --git a/crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs b/crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs new file mode 100644 index 0000000..f941671 --- /dev/null +++ b/crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs @@ -0,0 +1,130 @@ +use archlens_domain::{ + CodeElementKind, FilePath, Language, RelationshipKind, SourceFile, ports::SourceAnalyzer, +}; +use archlens_tree_sitter::TreeSitterAnalyzer; + +fn analyze_rust(source: &str, filename: &str) -> archlens_domain::AnalysisResult { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join(filename); + std::fs::write(&file_path, source).unwrap(); + + let analyzer = TreeSitterAnalyzer::new(); + let source_file = SourceFile::new( + FilePath::new(file_path.to_str().unwrap()).unwrap(), + Language::Rust, + ); + analyzer.analyze_file(&source_file).unwrap() +} + +#[test] +fn extracts_rust_struct() { + let result = analyze_rust("pub struct Order {\n id: u64,\n}", "order.rs"); + + assert_eq!(result.elements().len(), 1); + assert_eq!(result.elements()[0].name(), "Order"); + assert_eq!(result.elements()[0].kind(), CodeElementKind::Struct); +} + +#[test] +fn extracts_rust_enum() { + let result = analyze_rust( + "pub enum Status {\n Active,\n Inactive,\n}", + "status.rs", + ); + + assert_eq!(result.elements().len(), 1); + assert_eq!(result.elements()[0].name(), "Status"); + assert_eq!(result.elements()[0].kind(), CodeElementKind::Enum); +} + +#[test] +fn extracts_rust_trait() { + let result = analyze_rust("pub trait Repository {\n fn find(&self);\n}", "repo.rs"); + + assert_eq!(result.elements().len(), 1); + assert_eq!(result.elements()[0].name(), "Repository"); + assert_eq!(result.elements()[0].kind(), CodeElementKind::Trait); +} + +#[test] +fn extracts_composition_from_struct_fields() { + let source = + "pub struct Order {\n id: u64,\n}\npub struct OrderService {\n order: Order,\n}"; + let result = analyze_rust(source, "service.rs"); + + assert_eq!(result.elements().len(), 2); + assert_eq!(result.relationships().len(), 1); + assert_eq!(result.relationships()[0].source(), "OrderService"); + assert_eq!(result.relationships()[0].target(), "Order"); + assert_eq!( + result.relationships()[0].kind(), + RelationshipKind::Composition + ); +} + +#[test] +fn extracts_inheritance_from_trait_impl() { + let source = "pub trait Printable {}\npub struct Order {}\nimpl Printable for Order {}"; + let result = analyze_rust(source, "order.rs"); + + let inheritance: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Inheritance) + .collect(); + + assert_eq!(inheritance.len(), 1); + assert_eq!(inheritance[0].source(), "Order"); + assert_eq!(inheritance[0].target(), "Printable"); +} + +#[test] +fn extracts_use_imports() { + let source = + "use crate::domain::Order;\nuse crate::ports::Repository;\n\npub struct Service {}"; + let result = analyze_rust(source, "service.rs"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert!(imports.iter().any(|r| r.target() == "crate::domain::Order")); + assert!( + imports + .iter() + .any(|r| r.target() == "crate::ports::Repository") + ); +} + +#[test] +fn filters_std_and_external_crate_imports() { + let source = + "use std::collections::HashMap;\nuse serde::Serialize;\nuse crate::models::Order;\n"; + let result = analyze_rust(source, "lib.rs"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].target(), "crate::models::Order"); +} + +#[test] +fn extracts_mod_declarations() { + let source = "mod models;\nmod services;\npub struct App {}"; + let result = analyze_rust(source, "lib.rs"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert!(imports.iter().any(|r| r.target() == "crate::models")); + assert!(imports.iter().any(|r| r.target() == "crate::services")); +} diff --git a/crates/adapters/walkdir/Cargo.toml b/crates/adapters/walkdir/Cargo.toml new file mode 100644 index 0000000..0062108 --- /dev/null +++ b/crates/adapters/walkdir/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "archlens-walkdir" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true +walkdir.workspace = true +ignore.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/walkdir/src/lib.rs b/crates/adapters/walkdir/src/lib.rs new file mode 100644 index 0000000..cd1da50 --- /dev/null +++ b/crates/adapters/walkdir/src/lib.rs @@ -0,0 +1,3 @@ +mod walkdir_discovery; + +pub use walkdir_discovery::WalkdirDiscovery; diff --git a/crates/adapters/walkdir/src/walkdir_discovery.rs b/crates/adapters/walkdir/src/walkdir_discovery.rs new file mode 100644 index 0000000..658536b --- /dev/null +++ b/crates/adapters/walkdir/src/walkdir_discovery.rs @@ -0,0 +1,100 @@ +use std::path::Path; + +use ignore::WalkBuilder; + +use archlens_domain::{ + AnalysisConfig, DomainError, FilePath, Language, SourceFile, ports::FileDiscovery, +}; + +const DEFAULT_EXCLUDES: &[&str] = &[ + ".venv", + "venv", + "node_modules", + "__pycache__", + ".git", + "target", + "bin", + "obj", + "dist", + ".tox", + ".eggs", +]; + +pub struct WalkdirDiscovery; + +impl Default for WalkdirDiscovery { + fn default() -> Self { + Self::new() + } +} + +impl WalkdirDiscovery { + pub fn new() -> Self { + Self + } + + fn detect_language(path: &Path) -> Option { + match path.extension()?.to_str()? { + "rs" => Some(Language::Rust), + "py" => Some(Language::Python), + "cs" => Some(Language::CSharp), + _ => None, + } + } + + fn is_excluded(path: &Path, root: &Path, excludes: &[String]) -> bool { + let relative = path.strip_prefix(root).unwrap_or(path); + let relative_str = relative.to_string_lossy(); + + for component in relative.components() { + let name = component.as_os_str().to_string_lossy(); + if DEFAULT_EXCLUDES.iter().any(|e| name == *e) { + return true; + } + } + + excludes + .iter() + .any(|exclude| relative_str.contains(exclude.trim_end_matches('/'))) + } +} + +impl FileDiscovery for WalkdirDiscovery { + fn discover( + &self, + root: &Path, + config: &AnalysisConfig, + ) -> Result, DomainError> { + let mut files = Vec::new(); + + let walker = WalkBuilder::new(root).hidden(true).git_ignore(true).build(); + + for entry in walker.filter_map(|e| e.ok()) { + let path = entry.path(); + + if !path.is_file() { + continue; + } + + if Self::is_excluded(path, root, config.excludes()) { + continue; + } + + if let Some(scope) = config.scope() { + let relative = path.strip_prefix(root).unwrap_or(path); + if !relative.starts_with(scope) { + continue; + } + } + + if let Some(language) = Self::detect_language(path) { + let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let file_path = FilePath::new(&absolute.to_string_lossy()) + .map_err(|e| DomainError::IoError(e.to_string()))?; + files.push(SourceFile::new(file_path, language)); + } + } + + Ok(files) + } +} diff --git a/crates/adapters/walkdir/tests/walkdir_discovery_tests.rs b/crates/adapters/walkdir/tests/walkdir_discovery_tests.rs new file mode 100644 index 0000000..b00a798 --- /dev/null +++ b/crates/adapters/walkdir/tests/walkdir_discovery_tests.rs @@ -0,0 +1,71 @@ +use std::fs; + +use archlens_domain::{AnalysisConfig, Language, ports::FileDiscovery}; +use archlens_walkdir::WalkdirDiscovery; + +fn create_test_tree(dir: &std::path::Path) { + fs::create_dir_all(dir.join("src/orders")).unwrap(); + fs::create_dir_all(dir.join("src/billing")).unwrap(); + fs::write(dir.join("src/orders/service.rs"), "struct OrderService;").unwrap(); + fs::write(dir.join("src/orders/model.py"), "class Order: pass").unwrap(); + fs::write(dir.join("src/billing/invoice.cs"), "class Invoice {}").unwrap(); + fs::write(dir.join("src/readme.txt"), "not source code").unwrap(); +} + +#[test] +fn discovers_rust_python_and_csharp_files() { + let dir = tempfile::tempdir().unwrap(); + create_test_tree(dir.path()); + + let discovery = WalkdirDiscovery::new(); + let files = discovery + .discover(dir.path(), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(files.len(), 3); + + let languages: Vec = files.iter().map(|f| f.language()).collect(); + assert!(languages.contains(&Language::Rust)); + assert!(languages.contains(&Language::Python)); + assert!(languages.contains(&Language::CSharp)); +} + +#[test] +fn ignores_non_source_files() { + let dir = tempfile::tempdir().unwrap(); + create_test_tree(dir.path()); + + let discovery = WalkdirDiscovery::new(); + let files = discovery + .discover(dir.path(), &AnalysisConfig::default()) + .unwrap(); + + let paths: Vec<&str> = files.iter().map(|f| f.path().as_str()).collect(); + assert!(!paths.iter().any(|p| p.ends_with(".txt"))); +} + +#[test] +fn respects_exclude_patterns() { + let dir = tempfile::tempdir().unwrap(); + create_test_tree(dir.path()); + + let config = AnalysisConfig::default().with_excludes(vec!["billing".to_string()]); + + let discovery = WalkdirDiscovery::new(); + let files = discovery.discover(dir.path(), &config).unwrap(); + + assert_eq!(files.len(), 2); + assert!(!files.iter().any(|f| f.path().as_str().contains("billing"))); +} + +#[test] +fn empty_directory_returns_no_files() { + let dir = tempfile::tempdir().unwrap(); + + let discovery = WalkdirDiscovery::new(); + let files = discovery + .discover(dir.path(), &AnalysisConfig::default()) + .unwrap(); + + assert!(files.is_empty()); +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..9478a39 --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "archlens-application" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true +rayon.workspace = true diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..84c032e --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1 @@ +pub mod queries; diff --git a/crates/application/src/queries/analyze_codebase.rs b/crates/application/src/queries/analyze_codebase.rs new file mode 100644 index 0000000..ebfb132 --- /dev/null +++ b/crates/application/src/queries/analyze_codebase.rs @@ -0,0 +1,244 @@ +use std::collections::HashSet; +use std::path::Path; + +use rayon::prelude::*; + +use archlens_domain::{ + AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship, + RelationshipKind, + ports::{FileDiscovery, SourceAnalyzer}, +}; + +pub struct AnalyzeCodebase +where + F: FileDiscovery + Send + Sync, + S: SourceAnalyzer, +{ + file_discovery: F, + source_analyzer: S, +} + +impl AnalyzeCodebase +where + F: FileDiscovery + Send + Sync, + S: SourceAnalyzer, +{ + pub fn new(file_discovery: F, source_analyzer: S) -> Self { + Self { + file_discovery, + source_analyzer, + } + } + + pub fn execute( + &self, + root: &Path, + config: &AnalysisConfig, + ) -> Result { + let files = self.file_discovery.discover(root, config)?; + + let file_results: Vec<(Vec, Vec, Vec)> = files + .par_iter() + .map(|file| match self.source_analyzer.analyze_file(file) { + Ok(result) => { + let module = infer_module(file.path().as_str(), root, config); + let elements: Vec = result + .elements() + .iter() + .map(|el| { + let mut el = el.clone(); + if el.module().is_none() + && let Some(ref m) = module + { + el = el.with_module(m.clone()); + } + el + }) + .collect(); + ( + elements, + result.relationships().to_vec(), + result.warnings().to_vec(), + ) + } + Err(err) => { + let mut warnings = Vec::new(); + if let Ok(warning) = + AnalysisWarning::new(file.path().clone(), 0, &err.to_string()) + { + warnings.push(warning); + } + (Vec::new(), Vec::new(), warnings) + } + }) + .collect(); + + let mut graph = CodeGraph::new(); + let mut warnings = Vec::new(); + for (elements, relationships, warns) in file_results { + for el in elements { + graph.add_element(el); + } + for rel in relationships { + graph.add_relationship(rel); + } + warnings.extend(warns); + } + + let graph = resolve_cross_file_relationships(graph); + let graph = filter_external_imports(graph, root); + + Ok(AnalyzeCodebaseResult { graph, warnings }) + } +} + +fn resolve_cross_file_relationships(graph: CodeGraph) -> CodeGraph { + use std::collections::HashMap; + + let mut file_types: HashMap> = HashMap::new(); + let mut name_modules: HashMap<&str, HashSet>> = HashMap::new(); + let all_type_names: HashSet<&str> = graph.elements().iter().map(|e| e.name()).collect(); + + for element in graph.elements() { + file_types + .entry(element.file_path().as_str().to_string()) + .or_default() + .insert(element.name().to_string()); + name_modules + .entry(element.name()) + .or_default() + .insert(element.module().map(|m| m.as_str())); + } + + let mut resolved = CodeGraph::new(); + for element in graph.elements() { + resolved.add_element(element.clone()); + } + for rel in graph.relationships() { + match rel.kind() { + RelationshipKind::Import => { + resolved.add_relationship(rel.clone()); + } + _ => { + if !all_type_names.contains(rel.source()) || !all_type_names.contains(rel.target()) + { + continue; + } + + if let Some(src_file) = rel.source_file() { + let file_key = src_file.as_str().to_string(); + if let Some(types_in_file) = file_types.get(&file_key) + && types_in_file.contains(rel.target()) + { + resolved.add_relationship(rel.clone()); + continue; + } + } + + let tgt_modules = &name_modules[rel.target()]; + if tgt_modules.len() == 1 { + resolved.add_relationship(rel.clone()); + } + } + } + } + resolved +} + +fn filter_external_imports(graph: CodeGraph, root: &Path) -> CodeGraph { + let known_dirs: HashSet = std::fs::read_dir(root) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .map(|s| s.to_lowercase()) + .collect(); + + let module_names: HashSet = graph + .modules() + .iter() + .map(|m| m.as_str().to_lowercase()) + .collect(); + + let all_known: HashSet<&str> = known_dirs + .iter() + .map(|s| s.as_str()) + .chain(module_names.iter().map(|s| s.as_str())) + .collect(); + + let mut filtered = CodeGraph::new(); + for element in graph.elements() { + filtered.add_element(element.clone()); + } + for rel in graph.relationships() { + if rel.kind() == RelationshipKind::Import { + let target_top = rel.target().split('.').next().unwrap_or("").to_lowercase(); + if !all_known.contains(target_top.as_str()) { + continue; + } + } + filtered.add_relationship(rel.clone()); + } + filtered +} + +fn infer_module(file_path: &str, root: &Path, config: &AnalysisConfig) -> Option { + let relative = if let Some(stripped) = file_path.strip_prefix(root.to_str().unwrap_or("")) { + stripped.trim_start_matches('/') + } else { + file_path + }; + + for (pattern, module_name) in config.module_mappings() { + if relative.starts_with(pattern) { + return ModuleName::new(module_name).ok(); + } + } + + let parts: Vec<&str> = relative.split('/').collect(); + if parts.len() <= 1 { + return None; + } + + let module_dir = if parts[0] == "crates" && parts.len() > 2 { + // workspace: crates//src/... + parts[1] + } else if parts[0] == "src" && parts.len() > 2 { + // single project: src//... + parts[1] + } else if parts[0] != "src" && parts.len() > 1 { + parts[0] + } else { + return None; + }; + + let capitalized = module_dir + .split('-') + .map(|seg| { + if seg.is_empty() { + String::new() + } else { + format!("{}{}", seg[..1].to_uppercase(), &seg[1..]) + } + }) + .collect::>() + .join("-"); + + ModuleName::new(&capitalized).ok() +} + +pub struct AnalyzeCodebaseResult { + graph: CodeGraph, + warnings: Vec, +} + +impl AnalyzeCodebaseResult { + pub fn graph(&self) -> &CodeGraph { + &self.graph + } + + pub fn warnings(&self) -> &[AnalysisWarning] { + &self.warnings + } +} diff --git a/crates/application/src/queries/mod.rs b/crates/application/src/queries/mod.rs new file mode 100644 index 0000000..ee6e763 --- /dev/null +++ b/crates/application/src/queries/mod.rs @@ -0,0 +1,5 @@ +mod analyze_codebase; +mod render_diagrams; + +pub use analyze_codebase::{AnalyzeCodebase, AnalyzeCodebaseResult}; +pub use render_diagrams::RenderDiagrams; diff --git a/crates/application/src/queries/render_diagrams.rs b/crates/application/src/queries/render_diagrams.rs new file mode 100644 index 0000000..c39de64 --- /dev/null +++ b/crates/application/src/queries/render_diagrams.rs @@ -0,0 +1,45 @@ +use archlens_domain::{ + CodeGraph, DomainError, OutputConfig, + ports::{DiagramRenderer, OutputWriter}, +}; + +pub struct RenderDiagrams +where + R: DiagramRenderer, + W: OutputWriter, +{ + renderer: R, + writer: W, +} + +impl RenderDiagrams +where + R: DiagramRenderer, + W: OutputWriter, +{ + pub fn new(renderer: R, writer: W) -> Self { + Self { renderer, writer } + } + + pub fn execute(&self, graph: &CodeGraph, config: &OutputConfig) -> Result<(), DomainError> { + if config.split_by_module() { + let overview = self.renderer.render(graph)?; + self.writer.write(&overview)?; + + for module in graph.modules() { + let subgraph = graph.subgraph_by_module(&module); + let output = self.renderer.render(&subgraph)?; + self.writer.write(&output)?; + } + } else { + let output = self.renderer.render(graph)?; + self.writer.write(&output)?; + } + + Ok(()) + } + + pub fn writer(&self) -> &W { + &self.writer + } +} diff --git a/crates/application/tests/analyze_codebase_tests.rs b/crates/application/tests/analyze_codebase_tests.rs new file mode 100644 index 0000000..25406da --- /dev/null +++ b/crates/application/tests/analyze_codebase_tests.rs @@ -0,0 +1,344 @@ +mod fakes; + +use std::path::Path; + +use archlens_application::queries::AnalyzeCodebase; +use archlens_domain::{ + AnalysisConfig, AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, + FilePath, Language, Relationship, RelationshipKind, SourceFile, +}; + +use fakes::{FakeFileDiscovery, FakeSourceAnalyzer}; + +#[test] +fn analyzes_discovered_files_and_builds_code_graph() { + let files = vec![ + SourceFile::new(FilePath::new("src/order.rs").unwrap(), Language::Rust), + SourceFile::new(FilePath::new("src/service.rs").unwrap(), Language::Rust), + ]; + + let discovery = FakeFileDiscovery::new(files); + + let analyzer = FakeSourceAnalyzer::new() + .with_result( + "src/order.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ) + .with_result( + "src/service.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/service.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![ + Relationship::new("OrderService", "Order", RelationshipKind::Composition) + .unwrap(), + ], + vec![], + ), + ); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("."), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(result.graph().elements().len(), 2); + assert_eq!(result.graph().relationships().len(), 1); + assert!(result.warnings().is_empty()); +} + +#[test] +fn empty_file_list_returns_empty_graph() { + let discovery = FakeFileDiscovery::empty(); + let analyzer = FakeSourceAnalyzer::new(); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("."), &AnalysisConfig::default()) + .unwrap(); + + assert!(result.graph().elements().is_empty()); + assert!(result.graph().relationships().is_empty()); + assert!(result.warnings().is_empty()); +} + +#[test] +fn aggregates_warnings_from_multiple_files() { + let files = vec![ + SourceFile::new(FilePath::new("src/a.rs").unwrap(), Language::Rust), + SourceFile::new(FilePath::new("src/b.rs").unwrap(), Language::Rust), + ]; + + let discovery = FakeFileDiscovery::new(files); + let analyzer = FakeSourceAnalyzer::new() + .with_result( + "src/a.rs", + AnalysisResult::new( + vec![], + vec![], + vec![ + AnalysisWarning::new(FilePath::new("src/a.rs").unwrap(), 10, "unknown macro") + .unwrap(), + ], + ), + ) + .with_result( + "src/b.rs", + AnalysisResult::new( + vec![], + vec![], + vec![ + AnalysisWarning::new( + FilePath::new("src/b.rs").unwrap(), + 5, + "unparseable block", + ) + .unwrap(), + ], + ), + ); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("."), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(result.warnings().len(), 2); +} + +#[test] +fn analysis_error_on_file_collects_warning_and_continues() { + let files = vec![ + SourceFile::new(FilePath::new("src/good.rs").unwrap(), Language::Rust), + SourceFile::new(FilePath::new("src/broken.rs").unwrap(), Language::Rust), + ]; + + let discovery = FakeFileDiscovery::new(files); + let analyzer = FakeSourceAnalyzer::new() + .with_result( + "src/good.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "Good", + CodeElementKind::Struct, + FilePath::new("src/good.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ) + .with_error( + "src/broken.rs", + DomainError::AnalysisError("parse failed".to_string()), + ); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("."), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(result.graph().elements().len(), 1); + assert_eq!(result.graph().elements()[0].name(), "Good"); + assert_eq!(result.warnings().len(), 1); +} + +#[test] +fn infers_module_from_directory_structure() { + let files = vec![ + SourceFile::new( + FilePath::new("/project/src/orders/service.rs").unwrap(), + Language::Rust, + ), + SourceFile::new( + FilePath::new("/project/src/billing/invoice.rs").unwrap(), + Language::Rust, + ), + SourceFile::new( + FilePath::new("/project/src/lib.rs").unwrap(), + Language::Rust, + ), + ]; + + let discovery = FakeFileDiscovery::new(files); + let analyzer = FakeSourceAnalyzer::new() + .with_result( + "/project/src/orders/service.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("/project/src/orders/service.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ) + .with_result( + "/project/src/billing/invoice.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "Invoice", + CodeElementKind::Struct, + FilePath::new("/project/src/billing/invoice.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ) + .with_result( + "/project/src/lib.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "App", + CodeElementKind::Struct, + FilePath::new("/project/src/lib.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("/project"), &AnalysisConfig::default()) + .unwrap(); + + let order_svc = result + .graph() + .elements() + .iter() + .find(|e| e.name() == "OrderService") + .unwrap(); + assert_eq!(order_svc.module().unwrap().as_str(), "Orders"); + + let invoice = result + .graph() + .elements() + .iter() + .find(|e| e.name() == "Invoice") + .unwrap(); + assert_eq!(invoice.module().unwrap().as_str(), "Billing"); + + let app = result + .graph() + .elements() + .iter() + .find(|e| e.name() == "App") + .unwrap(); + assert!(app.module().is_none()); +} + +#[test] +fn infers_nested_module_from_deep_directories() { + let files = vec![SourceFile::new( + FilePath::new("/project/src/orders/models/order.rs").unwrap(), + Language::Rust, + )]; + + let discovery = FakeFileDiscovery::new(files); + let analyzer = FakeSourceAnalyzer::new().with_result( + "/project/src/orders/models/order.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("/project/src/orders/models/order.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("/project"), &AnalysisConfig::default()) + .unwrap(); + + let order = result + .graph() + .elements() + .iter() + .find(|e| e.name() == "Order") + .unwrap(); + assert_eq!(order.module().unwrap().as_str(), "Orders"); +} + +#[test] +fn respects_config_module_mappings() { + let files = vec![SourceFile::new( + FilePath::new("/project/src/infra/db.rs").unwrap(), + Language::Rust, + )]; + + let discovery = FakeFileDiscovery::new(files); + let analyzer = FakeSourceAnalyzer::new().with_result( + "/project/src/infra/db.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "DbPool", + CodeElementKind::Struct, + FilePath::new("/project/src/infra/db.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ); + + let mut mappings = std::collections::HashMap::new(); + mappings.insert("src/infra".to_string(), "Infrastructure".to_string()); + let config = AnalysisConfig::default().with_module_mappings(mappings); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case.execute(Path::new("/project"), &config).unwrap(); + + let db = result + .graph() + .elements() + .iter() + .find(|e| e.name() == "DbPool") + .unwrap(); + assert_eq!(db.module().unwrap().as_str(), "Infrastructure"); +} diff --git a/crates/application/tests/fakes/diagram_renderer.rs b/crates/application/tests/fakes/diagram_renderer.rs new file mode 100644 index 0000000..11061be --- /dev/null +++ b/crates/application/tests/fakes/diagram_renderer.rs @@ -0,0 +1,17 @@ +use archlens_domain::{CodeGraph, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer}; + +pub struct FakeDiagramRenderer; + +impl FakeDiagramRenderer { + pub fn new() -> Self { + Self + } +} + +impl DiagramRenderer for FakeDiagramRenderer { + fn render(&self, graph: &CodeGraph) -> Result { + let content = format!("graph with {} elements", graph.elements().len()); + let file = RenderedFile::new("output.mmd", &content)?; + Ok(RenderOutput::single(file)) + } +} diff --git a/crates/application/tests/fakes/file_discovery.rs b/crates/application/tests/fakes/file_discovery.rs new file mode 100644 index 0000000..a357314 --- /dev/null +++ b/crates/application/tests/fakes/file_discovery.rs @@ -0,0 +1,27 @@ +use std::path::Path; + +use archlens_domain::{AnalysisConfig, DomainError, SourceFile, ports::FileDiscovery}; + +pub struct FakeFileDiscovery { + files: Vec, +} + +impl FakeFileDiscovery { + pub fn new(files: Vec) -> Self { + Self { files } + } + + pub fn empty() -> Self { + Self { files: Vec::new() } + } +} + +impl FileDiscovery for FakeFileDiscovery { + fn discover( + &self, + _root: &Path, + _config: &AnalysisConfig, + ) -> Result, DomainError> { + Ok(self.files.clone()) + } +} diff --git a/crates/application/tests/fakes/mod.rs b/crates/application/tests/fakes/mod.rs new file mode 100644 index 0000000..b3eb355 --- /dev/null +++ b/crates/application/tests/fakes/mod.rs @@ -0,0 +1,9 @@ +mod diagram_renderer; +mod file_discovery; +mod output_writer; +mod source_analyzer; + +pub use diagram_renderer::FakeDiagramRenderer; +pub use file_discovery::FakeFileDiscovery; +pub use output_writer::FakeOutputWriter; +pub use source_analyzer::FakeSourceAnalyzer; diff --git a/crates/application/tests/fakes/output_writer.rs b/crates/application/tests/fakes/output_writer.rs new file mode 100644 index 0000000..7680a54 --- /dev/null +++ b/crates/application/tests/fakes/output_writer.rs @@ -0,0 +1,26 @@ +use std::cell::RefCell; + +use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter}; + +pub struct FakeOutputWriter { + written: RefCell>, +} + +impl FakeOutputWriter { + pub fn new() -> Self { + Self { + written: RefCell::new(Vec::new()), + } + } + + pub fn written_outputs(&self) -> Vec { + self.written.borrow().clone() + } +} + +impl OutputWriter for FakeOutputWriter { + fn write(&self, output: &RenderOutput) -> Result<(), DomainError> { + self.written.borrow_mut().push(output.clone()); + Ok(()) + } +} diff --git a/crates/application/tests/fakes/source_analyzer.rs b/crates/application/tests/fakes/source_analyzer.rs new file mode 100644 index 0000000..e61b198 --- /dev/null +++ b/crates/application/tests/fakes/source_analyzer.rs @@ -0,0 +1,43 @@ +use std::collections::HashMap; + +use archlens_domain::{AnalysisResult, DomainError, SourceFile, ports::SourceAnalyzer}; + +enum FakeResponse { + Success(AnalysisResult), + Failure(DomainError), +} + +pub struct FakeSourceAnalyzer { + results: HashMap, +} + +impl FakeSourceAnalyzer { + pub fn new() -> Self { + Self { + results: HashMap::new(), + } + } + + pub fn with_result(mut self, file_path: &str, result: AnalysisResult) -> Self { + self.results + .insert(file_path.to_string(), FakeResponse::Success(result)); + self + } + + pub fn with_error(mut self, file_path: &str, error: DomainError) -> Self { + self.results + .insert(file_path.to_string(), FakeResponse::Failure(error)); + self + } +} + +impl SourceAnalyzer for FakeSourceAnalyzer { + fn analyze_file(&self, file: &SourceFile) -> Result { + let key = file.path().as_str().to_string(); + match self.results.get(&key) { + Some(FakeResponse::Success(result)) => Ok(result.clone()), + Some(FakeResponse::Failure(_)) => Err(DomainError::AnalysisError(key)), + None => Ok(AnalysisResult::empty()), + } + } +} diff --git a/crates/application/tests/render_diagrams_tests.rs b/crates/application/tests/render_diagrams_tests.rs new file mode 100644 index 0000000..0d9accc --- /dev/null +++ b/crates/application/tests/render_diagrams_tests.rs @@ -0,0 +1,78 @@ +mod fakes; + +use archlens_application::queries::RenderDiagrams; +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, OutputConfig, +}; + +use fakes::{FakeDiagramRenderer, FakeOutputWriter}; + +fn build_graph() -> CodeGraph { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/service.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "BillingService", + CodeElementKind::Class, + FilePath::new("src/billing.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Billing").unwrap()), + ); + graph +} + +#[test] +fn renders_single_diagram_and_writes_output() { + let renderer = FakeDiagramRenderer::new(); + let writer = FakeOutputWriter::new(); + let config = OutputConfig::default(); + + let use_case = RenderDiagrams::new(renderer, writer); + use_case.execute(&build_graph(), &config).unwrap(); + + let outputs = use_case.writer().written_outputs(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].files().len(), 1); +} + +#[test] +fn split_mode_renders_overview_plus_per_module_diagrams() { + let renderer = FakeDiagramRenderer::new(); + let writer = FakeOutputWriter::new(); + let config = OutputConfig::default().with_split_by_module(true); + + let graph = build_graph(); // has 2 modules: Orders, Billing + + let use_case = RenderDiagrams::new(renderer, writer); + use_case.execute(&graph, &config).unwrap(); + + let outputs = use_case.writer().written_outputs(); + // 1 overview + 2 module diagrams = 3 writes + assert_eq!(outputs.len(), 3); +} + +#[test] +fn empty_graph_still_produces_output() { + let renderer = FakeDiagramRenderer::new(); + let writer = FakeOutputWriter::new(); + let config = OutputConfig::default(); + + let graph = CodeGraph::new(); + + let use_case = RenderDiagrams::new(renderer, writer); + use_case.execute(&graph, &config).unwrap(); + + let outputs = use_case.writer().written_outputs(); + assert_eq!(outputs.len(), 1); +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..6b6da7f --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "archlens-domain" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +thiserror.workspace = true diff --git a/crates/domain/src/aggregates/code_graph.rs b/crates/domain/src/aggregates/code_graph.rs new file mode 100644 index 0000000..90bc5f1 --- /dev/null +++ b/crates/domain/src/aggregates/code_graph.rs @@ -0,0 +1,78 @@ +use std::collections::HashSet; + +use crate::{CodeElement, ModuleName, Relationship}; + +#[derive(Debug, Clone)] +pub struct CodeGraph { + elements: Vec, + relationships: Vec, +} + +impl Default for CodeGraph { + fn default() -> Self { + Self::new() + } +} + +impl CodeGraph { + pub fn new() -> Self { + Self { + elements: Vec::new(), + relationships: Vec::new(), + } + } + + pub fn add_element(&mut self, element: CodeElement) { + self.elements.push(element); + } + + pub fn add_relationship(&mut self, relationship: Relationship) { + self.relationships.push(relationship); + } + + pub fn elements(&self) -> &[CodeElement] { + &self.elements + } + + pub fn relationships(&self) -> &[Relationship] { + &self.relationships + } + + pub fn modules(&self) -> Vec { + let mut seen = HashSet::new(); + let mut modules = Vec::new(); + + for element in &self.elements { + if let Some(module) = element.module() + && seen.insert(module.as_str().to_string()) + { + modules.push(module.clone()); + } + } + + modules + } + + pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph { + let filtered_elements: Vec = self + .elements + .iter() + .filter(|e| e.module().is_some_and(|m| m == module)) + .cloned() + .collect(); + + let element_names: HashSet<&str> = filtered_elements.iter().map(|e| e.name()).collect(); + + let filtered_relationships: Vec = self + .relationships + .iter() + .filter(|r| element_names.contains(r.source()) && element_names.contains(r.target())) + .cloned() + .collect(); + + CodeGraph { + elements: filtered_elements, + relationships: filtered_relationships, + } + } +} diff --git a/crates/domain/src/aggregates/mod.rs b/crates/domain/src/aggregates/mod.rs new file mode 100644 index 0000000..b048f79 --- /dev/null +++ b/crates/domain/src/aggregates/mod.rs @@ -0,0 +1,3 @@ +mod code_graph; + +pub use code_graph::CodeGraph; diff --git a/crates/domain/src/entities/code_element.rs b/crates/domain/src/entities/code_element.rs new file mode 100644 index 0000000..abfae2a --- /dev/null +++ b/crates/domain/src/entities/code_element.rs @@ -0,0 +1,111 @@ +use crate::{CodeElementKind, DomainError, FilePath, ModuleName, Visibility}; + +#[derive(Debug, Clone)] +pub struct CodeElement { + name: String, + kind: CodeElementKind, + file_path: FilePath, + line: usize, + visibility: Visibility, + module: Option, + generics: Vec, + attributes: Vec, + fields: Vec, + methods: Vec, +} + +impl CodeElement { + pub fn new( + name: &str, + kind: CodeElementKind, + file_path: FilePath, + line: usize, + ) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(DomainError::EmptyValue("CodeElement name")); + } + Ok(Self { + name: trimmed.to_string(), + kind, + file_path, + line, + visibility: Visibility::Public, + module: None, + generics: Vec::new(), + attributes: Vec::new(), + fields: Vec::new(), + methods: Vec::new(), + }) + } + + pub fn with_visibility(mut self, visibility: Visibility) -> Self { + self.visibility = visibility; + self + } + + pub fn with_module(mut self, module: ModuleName) -> Self { + self.module = Some(module); + self + } + + pub fn with_generics(mut self, generics: Vec) -> Self { + self.generics = generics; + self + } + + pub fn with_attributes(mut self, attributes: Vec) -> Self { + self.attributes = attributes; + self + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn kind(&self) -> CodeElementKind { + self.kind + } + + pub fn file_path(&self) -> &FilePath { + &self.file_path + } + + pub fn line(&self) -> usize { + self.line + } + + pub fn visibility(&self) -> Visibility { + self.visibility + } + + pub fn module(&self) -> Option<&ModuleName> { + self.module.as_ref() + } + + pub fn generics(&self) -> &[String] { + &self.generics + } + + pub fn attributes(&self) -> &[String] { + &self.attributes + } + + pub fn with_fields(mut self, fields: Vec) -> Self { + self.fields = fields; + self + } + + pub fn with_methods(mut self, methods: Vec) -> Self { + self.methods = methods; + self + } + + pub fn fields(&self) -> &[String] { + &self.fields + } + + pub fn methods(&self) -> &[String] { + &self.methods + } +} diff --git a/crates/domain/src/entities/mod.rs b/crates/domain/src/entities/mod.rs new file mode 100644 index 0000000..6d98832 --- /dev/null +++ b/crates/domain/src/entities/mod.rs @@ -0,0 +1,5 @@ +mod code_element; +mod relationship; + +pub use code_element::CodeElement; +pub use relationship::Relationship; diff --git a/crates/domain/src/entities/relationship.rs b/crates/domain/src/entities/relationship.rs new file mode 100644 index 0000000..6421d3d --- /dev/null +++ b/crates/domain/src/entities/relationship.rs @@ -0,0 +1,49 @@ +use crate::{DomainError, FilePath, RelationshipKind}; + +#[derive(Debug, Clone)] +pub struct Relationship { + source: String, + target: String, + kind: RelationshipKind, + source_file: Option, +} + +impl Relationship { + pub fn new(source: &str, target: &str, kind: RelationshipKind) -> Result { + let source = source.trim(); + let target = target.trim(); + if source.is_empty() { + return Err(DomainError::EmptyValue("Relationship source")); + } + if target.is_empty() { + return Err(DomainError::EmptyValue("Relationship target")); + } + Ok(Self { + source: source.to_string(), + target: target.to_string(), + kind, + source_file: None, + }) + } + + pub fn with_source_file(mut self, file: FilePath) -> Self { + self.source_file = Some(file); + self + } + + pub fn source(&self) -> &str { + &self.source + } + + pub fn target(&self) -> &str { + &self.target + } + + pub fn kind(&self) -> RelationshipKind { + self.kind + } + + pub fn source_file(&self) -> Option<&FilePath> { + self.source_file.as_ref() + } +} diff --git a/crates/domain/src/error.rs b/crates/domain/src/error.rs new file mode 100644 index 0000000..122534f --- /dev/null +++ b/crates/domain/src/error.rs @@ -0,0 +1,14 @@ +#[derive(Debug, thiserror::Error)] +pub enum DomainError { + #[error("{0} cannot be empty")] + EmptyValue(&'static str), + + #[error("failed to analyze: {0}")] + AnalysisError(String), + + #[error("IO error: {0}")] + IoError(String), + + #[error("config error: {0}")] + ConfigError(String), +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..2f74a34 --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,14 @@ +mod error; + +pub mod aggregates; +pub mod entities; +pub mod ports; +pub mod value_objects; + +pub use aggregates::CodeGraph; +pub use entities::{CodeElement, Relationship}; +pub use error::DomainError; +pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning}; +pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility}; +pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile}; +pub use value_objects::source::{FilePath, Language, ModuleName, SourceFile}; diff --git a/crates/domain/src/ports/config_loader.rs b/crates/domain/src/ports/config_loader.rs new file mode 100644 index 0000000..55820b8 --- /dev/null +++ b/crates/domain/src/ports/config_loader.rs @@ -0,0 +1,6 @@ +use crate::{AnalysisConfig, DomainError, OutputConfig}; + +pub trait ConfigLoader { + fn load_analysis_config(&self) -> Result; + fn load_output_config(&self) -> Result; +} diff --git a/crates/domain/src/ports/diagram_renderer.rs b/crates/domain/src/ports/diagram_renderer.rs new file mode 100644 index 0000000..2df6dec --- /dev/null +++ b/crates/domain/src/ports/diagram_renderer.rs @@ -0,0 +1,5 @@ +use crate::{CodeGraph, DomainError, RenderOutput}; + +pub trait DiagramRenderer { + fn render(&self, graph: &CodeGraph) -> Result; +} diff --git a/crates/domain/src/ports/file_discovery.rs b/crates/domain/src/ports/file_discovery.rs new file mode 100644 index 0000000..4c0d245 --- /dev/null +++ b/crates/domain/src/ports/file_discovery.rs @@ -0,0 +1,9 @@ +use crate::{AnalysisConfig, DomainError, SourceFile}; + +pub trait FileDiscovery { + fn discover( + &self, + root: &std::path::Path, + config: &AnalysisConfig, + ) -> Result, DomainError>; +} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs new file mode 100644 index 0000000..da02252 --- /dev/null +++ b/crates/domain/src/ports/mod.rs @@ -0,0 +1,13 @@ +mod config_loader; +mod diagram_renderer; +mod file_discovery; +mod output_writer; +mod project_analyzer; +mod source_analyzer; + +pub use config_loader::ConfigLoader; +pub use diagram_renderer::DiagramRenderer; +pub use file_discovery::FileDiscovery; +pub use output_writer::OutputWriter; +pub use project_analyzer::ProjectAnalyzer; +pub use source_analyzer::SourceAnalyzer; diff --git a/crates/domain/src/ports/output_writer.rs b/crates/domain/src/ports/output_writer.rs new file mode 100644 index 0000000..5a4505b --- /dev/null +++ b/crates/domain/src/ports/output_writer.rs @@ -0,0 +1,5 @@ +use crate::{DomainError, RenderOutput}; + +pub trait OutputWriter { + fn write(&self, output: &RenderOutput) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/project_analyzer.rs b/crates/domain/src/ports/project_analyzer.rs new file mode 100644 index 0000000..b708cd9 --- /dev/null +++ b/crates/domain/src/ports/project_analyzer.rs @@ -0,0 +1,7 @@ +use std::path::Path; + +use crate::{CodeGraph, DomainError}; + +pub trait ProjectAnalyzer { + fn analyze(&self, root: &Path) -> Result; +} diff --git a/crates/domain/src/ports/source_analyzer.rs b/crates/domain/src/ports/source_analyzer.rs new file mode 100644 index 0000000..28a0dcb --- /dev/null +++ b/crates/domain/src/ports/source_analyzer.rs @@ -0,0 +1,5 @@ +use crate::{AnalysisResult, DomainError, SourceFile}; + +pub trait SourceAnalyzer: Send + Sync { + fn analyze_file(&self, file: &SourceFile) -> Result; +} diff --git a/crates/domain/src/value_objects/analysis/analysis_config.rs b/crates/domain/src/value_objects/analysis/analysis_config.rs new file mode 100644 index 0000000..9d35769 --- /dev/null +++ b/crates/domain/src/value_objects/analysis/analysis_config.rs @@ -0,0 +1,60 @@ +use std::collections::HashMap; + +use crate::DiagramLevel; + +#[derive(Debug, Clone)] +pub struct AnalysisConfig { + excludes: Vec, + level: DiagramLevel, + module_mappings: HashMap, + scope: Option, +} + +impl AnalysisConfig { + pub fn with_excludes(mut self, excludes: Vec) -> Self { + self.excludes = excludes; + self + } + + pub fn with_level(mut self, level: DiagramLevel) -> Self { + self.level = level; + self + } + + pub fn with_module_mappings(mut self, mappings: HashMap) -> Self { + self.module_mappings = mappings; + self + } + + pub fn excludes(&self) -> &[String] { + &self.excludes + } + + pub fn level(&self) -> DiagramLevel { + self.level + } + + pub fn with_scope(mut self, scope: String) -> Self { + self.scope = Some(scope); + self + } + + pub fn module_mappings(&self) -> &HashMap { + &self.module_mappings + } + + pub fn scope(&self) -> Option<&str> { + self.scope.as_deref() + } +} + +impl Default for AnalysisConfig { + fn default() -> Self { + Self { + excludes: Vec::new(), + level: DiagramLevel::Module, + module_mappings: HashMap::new(), + scope: None, + } + } +} diff --git a/crates/domain/src/value_objects/analysis/analysis_result.rs b/crates/domain/src/value_objects/analysis/analysis_result.rs new file mode 100644 index 0000000..8c52023 --- /dev/null +++ b/crates/domain/src/value_objects/analysis/analysis_result.rs @@ -0,0 +1,42 @@ +use crate::{AnalysisWarning, CodeElement, Relationship}; + +#[derive(Debug, Clone)] +pub struct AnalysisResult { + elements: Vec, + relationships: Vec, + warnings: Vec, +} + +impl AnalysisResult { + pub fn new( + elements: Vec, + relationships: Vec, + warnings: Vec, + ) -> Self { + Self { + elements, + relationships, + warnings, + } + } + + pub fn empty() -> Self { + Self { + elements: Vec::new(), + relationships: Vec::new(), + warnings: Vec::new(), + } + } + + pub fn elements(&self) -> &[CodeElement] { + &self.elements + } + + pub fn relationships(&self) -> &[Relationship] { + &self.relationships + } + + pub fn warnings(&self) -> &[AnalysisWarning] { + &self.warnings + } +} diff --git a/crates/domain/src/value_objects/analysis/analysis_warning.rs b/crates/domain/src/value_objects/analysis/analysis_warning.rs new file mode 100644 index 0000000..6d4a5a9 --- /dev/null +++ b/crates/domain/src/value_objects/analysis/analysis_warning.rs @@ -0,0 +1,34 @@ +use crate::{DomainError, FilePath}; + +#[derive(Debug, Clone)] +pub struct AnalysisWarning { + file_path: FilePath, + line: usize, + message: String, +} + +impl AnalysisWarning { + pub fn new(file_path: FilePath, line: usize, message: &str) -> Result { + let message = message.trim(); + if message.is_empty() { + return Err(DomainError::EmptyValue("AnalysisWarning message")); + } + Ok(Self { + file_path, + line, + message: message.to_string(), + }) + } + + pub fn file_path(&self) -> &FilePath { + &self.file_path + } + + pub fn line(&self) -> usize { + self.line + } + + pub fn message(&self) -> &str { + &self.message + } +} diff --git a/crates/domain/src/value_objects/analysis/mod.rs b/crates/domain/src/value_objects/analysis/mod.rs new file mode 100644 index 0000000..411b22d --- /dev/null +++ b/crates/domain/src/value_objects/analysis/mod.rs @@ -0,0 +1,7 @@ +mod analysis_config; +mod analysis_result; +mod analysis_warning; + +pub use analysis_config::AnalysisConfig; +pub use analysis_result::AnalysisResult; +pub use analysis_warning::AnalysisWarning; diff --git a/crates/domain/src/value_objects/graph/code_element_kind.rs b/crates/domain/src/value_objects/graph/code_element_kind.rs new file mode 100644 index 0000000..5716941 --- /dev/null +++ b/crates/domain/src/value_objects/graph/code_element_kind.rs @@ -0,0 +1,9 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CodeElementKind { + Class, + Struct, + Trait, + Interface, + Enum, + Project, +} diff --git a/crates/domain/src/value_objects/graph/mod.rs b/crates/domain/src/value_objects/graph/mod.rs new file mode 100644 index 0000000..459dabb --- /dev/null +++ b/crates/domain/src/value_objects/graph/mod.rs @@ -0,0 +1,7 @@ +mod code_element_kind; +mod relationship_kind; +mod visibility; + +pub use code_element_kind::CodeElementKind; +pub use relationship_kind::RelationshipKind; +pub use visibility::Visibility; diff --git a/crates/domain/src/value_objects/graph/relationship_kind.rs b/crates/domain/src/value_objects/graph/relationship_kind.rs new file mode 100644 index 0000000..4436305 --- /dev/null +++ b/crates/domain/src/value_objects/graph/relationship_kind.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RelationshipKind { + Inheritance, + Composition, + Import, +} diff --git a/crates/domain/src/value_objects/graph/visibility.rs b/crates/domain/src/value_objects/graph/visibility.rs new file mode 100644 index 0000000..686304e --- /dev/null +++ b/crates/domain/src/value_objects/graph/visibility.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Visibility { + Public, + Private, + Internal, +} diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs new file mode 100644 index 0000000..3d3cc00 --- /dev/null +++ b/crates/domain/src/value_objects/mod.rs @@ -0,0 +1,4 @@ +pub mod analysis; +pub mod graph; +pub mod output; +pub mod source; diff --git a/crates/domain/src/value_objects/output/diagram_level.rs b/crates/domain/src/value_objects/output/diagram_level.rs new file mode 100644 index 0000000..cb092cc --- /dev/null +++ b/crates/domain/src/value_objects/output/diagram_level.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DiagramLevel { + Project, + Module, + Type, +} diff --git a/crates/domain/src/value_objects/output/mod.rs b/crates/domain/src/value_objects/output/mod.rs new file mode 100644 index 0000000..48f2ec3 --- /dev/null +++ b/crates/domain/src/value_objects/output/mod.rs @@ -0,0 +1,9 @@ +mod diagram_level; +mod output_config; +mod render_output; +mod rendered_file; + +pub use diagram_level::DiagramLevel; +pub use output_config::OutputConfig; +pub use render_output::RenderOutput; +pub use rendered_file::RenderedFile; diff --git a/crates/domain/src/value_objects/output/output_config.rs b/crates/domain/src/value_objects/output/output_config.rs new file mode 100644 index 0000000..e9f91e9 --- /dev/null +++ b/crates/domain/src/value_objects/output/output_config.rs @@ -0,0 +1,25 @@ +#[derive(Debug, Clone, Default)] +pub struct OutputConfig { + split_by_module: bool, + output_path: Option, +} + +impl OutputConfig { + pub fn with_split_by_module(mut self, split: bool) -> Self { + self.split_by_module = split; + self + } + + pub fn with_output_path(mut self, path: String) -> Self { + self.output_path = Some(path); + self + } + + pub fn split_by_module(&self) -> bool { + self.split_by_module + } + + pub fn output_path(&self) -> Option<&str> { + self.output_path.as_deref() + } +} diff --git a/crates/domain/src/value_objects/output/render_output.rs b/crates/domain/src/value_objects/output/render_output.rs new file mode 100644 index 0000000..4261f9d --- /dev/null +++ b/crates/domain/src/value_objects/output/render_output.rs @@ -0,0 +1,20 @@ +use crate::RenderedFile; + +#[derive(Debug, Clone)] +pub struct RenderOutput { + files: Vec, +} + +impl RenderOutput { + pub fn new(files: Vec) -> Self { + Self { files } + } + + pub fn single(file: RenderedFile) -> Self { + Self { files: vec![file] } + } + + pub fn files(&self) -> &[RenderedFile] { + &self.files + } +} diff --git a/crates/domain/src/value_objects/output/rendered_file.rs b/crates/domain/src/value_objects/output/rendered_file.rs new file mode 100644 index 0000000..d7ea46e --- /dev/null +++ b/crates/domain/src/value_objects/output/rendered_file.rs @@ -0,0 +1,32 @@ +use crate::DomainError; + +#[derive(Debug, Clone)] +pub struct RenderedFile { + name: String, + content: String, +} + +impl RenderedFile { + pub fn new(name: &str, content: &str) -> Result { + let name = name.trim(); + let content = content.trim(); + if name.is_empty() { + return Err(DomainError::EmptyValue("RenderedFile name")); + } + if content.is_empty() { + return Err(DomainError::EmptyValue("RenderedFile content")); + } + Ok(Self { + name: name.to_string(), + content: content.to_string(), + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn content(&self) -> &str { + &self.content + } +} diff --git a/crates/domain/src/value_objects/source/file_path.rs b/crates/domain/src/value_objects/source/file_path.rs new file mode 100644 index 0000000..4cf7212 --- /dev/null +++ b/crates/domain/src/value_objects/source/file_path.rs @@ -0,0 +1,18 @@ +use crate::DomainError; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct FilePath(String); + +impl FilePath { + pub fn new(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(DomainError::EmptyValue("FilePath")); + } + Ok(Self(trimmed.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/crates/domain/src/value_objects/source/language.rs b/crates/domain/src/value_objects/source/language.rs new file mode 100644 index 0000000..0f06117 --- /dev/null +++ b/crates/domain/src/value_objects/source/language.rs @@ -0,0 +1,16 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Language { + Rust, + CSharp, + Python, +} + +impl Language { + pub fn name(&self) -> &'static str { + match self { + Self::Rust => "Rust", + Self::CSharp => "CSharp", + Self::Python => "Python", + } + } +} diff --git a/crates/domain/src/value_objects/source/mod.rs b/crates/domain/src/value_objects/source/mod.rs new file mode 100644 index 0000000..1d35419 --- /dev/null +++ b/crates/domain/src/value_objects/source/mod.rs @@ -0,0 +1,9 @@ +mod file_path; +mod language; +mod module_name; +mod source_file; + +pub use file_path::FilePath; +pub use language::Language; +pub use module_name::ModuleName; +pub use source_file::SourceFile; diff --git a/crates/domain/src/value_objects/source/module_name.rs b/crates/domain/src/value_objects/source/module_name.rs new file mode 100644 index 0000000..21ba2ce --- /dev/null +++ b/crates/domain/src/value_objects/source/module_name.rs @@ -0,0 +1,18 @@ +use crate::DomainError; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ModuleName(String); + +impl ModuleName { + pub fn new(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(DomainError::EmptyValue("ModuleName")); + } + Ok(Self(trimmed.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/crates/domain/src/value_objects/source/source_file.rs b/crates/domain/src/value_objects/source/source_file.rs new file mode 100644 index 0000000..85f1217 --- /dev/null +++ b/crates/domain/src/value_objects/source/source_file.rs @@ -0,0 +1,21 @@ +use crate::{FilePath, Language}; + +#[derive(Debug, Clone)] +pub struct SourceFile { + path: FilePath, + language: Language, +} + +impl SourceFile { + pub fn new(path: FilePath, language: Language) -> Self { + Self { path, language } + } + + pub fn path(&self) -> &FilePath { + &self.path + } + + pub fn language(&self) -> Language { + self.language + } +} diff --git a/crates/domain/tests/analysis_result_tests.rs b/crates/domain/tests/analysis_result_tests.rs new file mode 100644 index 0000000..bd08f9b --- /dev/null +++ b/crates/domain/tests/analysis_result_tests.rs @@ -0,0 +1,39 @@ +use archlens_domain::{ + AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, FilePath, Relationship, + RelationshipKind, +}; + +#[test] +fn analysis_result_collects_elements_relationships_and_warnings() { + let element = CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap(); + + let relationship = + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(); + + let warning = AnalysisWarning::new( + FilePath::new("src/broken.rs").unwrap(), + 10, + "unparseable macro", + ) + .unwrap(); + + let result = AnalysisResult::new(vec![element], vec![relationship], vec![warning]); + + assert_eq!(result.elements().len(), 1); + assert_eq!(result.relationships().len(), 1); + assert_eq!(result.warnings().len(), 1); +} + +#[test] +fn empty_analysis_result() { + let result = AnalysisResult::empty(); + assert!(result.elements().is_empty()); + assert!(result.relationships().is_empty()); + assert!(result.warnings().is_empty()); +} diff --git a/crates/domain/tests/analysis_warning_tests.rs b/crates/domain/tests/analysis_warning_tests.rs new file mode 100644 index 0000000..9066075 --- /dev/null +++ b/crates/domain/tests/analysis_warning_tests.rs @@ -0,0 +1,21 @@ +use archlens_domain::{AnalysisWarning, FilePath}; + +#[test] +fn warning_carries_location_and_message() { + let warning = AnalysisWarning::new( + FilePath::new("src/broken.rs").unwrap(), + 42, + "could not parse struct definition", + ) + .unwrap(); + + assert_eq!(warning.file_path().as_str(), "src/broken.rs"); + assert_eq!(warning.line(), 42); + assert_eq!(warning.message(), "could not parse struct definition"); +} + +#[test] +fn warning_rejects_empty_message() { + let result = AnalysisWarning::new(FilePath::new("src/broken.rs").unwrap(), 1, ""); + assert!(result.is_err()); +} diff --git a/crates/domain/tests/code_element_tests.rs b/crates/domain/tests/code_element_tests.rs new file mode 100644 index 0000000..1624a0e --- /dev/null +++ b/crates/domain/tests/code_element_tests.rs @@ -0,0 +1,107 @@ +use archlens_domain::{CodeElement, CodeElementKind, FilePath, ModuleName, Visibility}; + +#[test] +fn code_element_is_created_with_required_fields() { + let element = CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/orders/service.rs").unwrap(), + 42, + ) + .unwrap(); + + assert_eq!(element.name(), "OrderService"); + assert_eq!(element.kind(), CodeElementKind::Class); + assert_eq!(element.file_path().as_str(), "src/orders/service.rs"); + assert_eq!(element.line(), 42); +} + +#[test] +fn code_element_with_empty_name_is_rejected() { + let result = CodeElement::new( + "", + CodeElementKind::Class, + FilePath::new("src/main.rs").unwrap(), + 1, + ); + assert!(result.is_err()); +} + +#[test] +fn code_element_defaults_to_public_visibility() { + let element = CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap(); + + assert_eq!(element.visibility(), Visibility::Public); +} + +#[test] +fn code_element_with_visibility() { + let element = CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap() + .with_visibility(Visibility::Private); + + assert_eq!(element.visibility(), Visibility::Private); +} + +#[test] +fn code_element_with_module_path() { + let module = ModuleName::new("Orders").unwrap(); + let element = CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/orders/order.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(module.clone()); + + assert_eq!(element.module(), Some(&module)); +} + +#[test] +fn code_element_with_generics() { + let element = CodeElement::new( + "Repository", + CodeElementKind::Trait, + FilePath::new("src/repo.rs").unwrap(), + 1, + ) + .unwrap() + .with_generics(vec!["T".to_string()]); + + assert_eq!(element.generics(), &["T"]); +} + +#[test] +fn code_element_with_attributes() { + let element = CodeElement::new( + "OrderController", + CodeElementKind::Class, + FilePath::new("src/controller.cs").unwrap(), + 1, + ) + .unwrap() + .with_attributes(vec!["ApiController".to_string()]); + + assert_eq!(element.attributes(), &["ApiController"]); +} + +#[test] +fn all_element_kinds_exist() { + let _class = CodeElementKind::Class; + let _struct = CodeElementKind::Struct; + let _trait = CodeElementKind::Trait; + let _interface = CodeElementKind::Interface; + let _enum = CodeElementKind::Enum; +} diff --git a/crates/domain/tests/code_graph_tests.rs b/crates/domain/tests/code_graph_tests.rs new file mode 100644 index 0000000..64cc35c --- /dev/null +++ b/crates/domain/tests/code_graph_tests.rs @@ -0,0 +1,130 @@ +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, Relationship, RelationshipKind, +}; + +fn make_element(name: &str, module: Option<&str>) -> CodeElement { + let mut element = CodeElement::new( + name, + CodeElementKind::Class, + FilePath::new(&format!("src/{name}.rs")).unwrap(), + 1, + ) + .unwrap(); + + if let Some(m) = module { + element = element.with_module(ModuleName::new(m).unwrap()); + } + + element +} + +#[test] +fn empty_graph_has_no_elements() { + let graph = CodeGraph::new(); + assert!(graph.elements().is_empty()); + assert!(graph.relationships().is_empty()); +} + +#[test] +fn graph_stores_added_elements() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", None)); + graph.add_element(make_element("Order", None)); + + assert_eq!(graph.elements().len(), 2); +} + +#[test] +fn graph_stores_relationships() { + let mut graph = CodeGraph::new(); + let service = make_element("OrderService", None); + let repo = make_element("OrderRepository", None); + + graph.add_element(service); + graph.add_element(repo); + graph.add_relationship( + Relationship::new( + "OrderService", + "OrderRepository", + RelationshipKind::Composition, + ) + .unwrap(), + ); + + assert_eq!(graph.relationships().len(), 1); + let rel = &graph.relationships()[0]; + assert_eq!(rel.source(), "OrderService"); + assert_eq!(rel.target(), "OrderRepository"); + assert_eq!(rel.kind(), RelationshipKind::Composition); +} + +#[test] +fn subgraph_by_module_filters_elements() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", Some("Orders"))); + graph.add_element(make_element("Order", Some("Orders"))); + graph.add_element(make_element("BillingService", Some("Billing"))); + + let module = ModuleName::new("Orders").unwrap(); + let subgraph = graph.subgraph_by_module(&module); + + assert_eq!(subgraph.elements().len(), 2); + assert!( + subgraph + .elements() + .iter() + .all(|e| e.module().unwrap().as_str() == "Orders") + ); +} + +#[test] +fn subgraph_includes_relationships_within_module() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", Some("Orders"))); + graph.add_element(make_element("Order", Some("Orders"))); + graph.add_element(make_element("BillingService", Some("Billing"))); + + graph.add_relationship( + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(), + ); + graph.add_relationship( + Relationship::new( + "OrderService", + "BillingService", + RelationshipKind::Composition, + ) + .unwrap(), + ); + + let module = ModuleName::new("Orders").unwrap(); + let subgraph = graph.subgraph_by_module(&module); + + assert_eq!(subgraph.relationships().len(), 1); + assert_eq!(subgraph.relationships()[0].target(), "Order"); +} + +#[test] +fn subgraph_of_nonexistent_module_is_empty() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", Some("Orders"))); + + let module = ModuleName::new("Unknown").unwrap(); + let subgraph = graph.subgraph_by_module(&module); + + assert!(subgraph.elements().is_empty()); + assert!(subgraph.relationships().is_empty()); +} + +#[test] +fn graph_lists_unique_modules() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", Some("Orders"))); + graph.add_element(make_element("Order", Some("Orders"))); + graph.add_element(make_element("BillingService", Some("Billing"))); + graph.add_element(make_element("Orphan", None)); + + let modules = graph.modules(); + assert_eq!(modules.len(), 2); + assert!(modules.iter().any(|m| m.as_str() == "Orders")); + assert!(modules.iter().any(|m| m.as_str() == "Billing")); +} diff --git a/crates/domain/tests/config_tests.rs b/crates/domain/tests/config_tests.rs new file mode 100644 index 0000000..1476317 --- /dev/null +++ b/crates/domain/tests/config_tests.rs @@ -0,0 +1,37 @@ +use archlens_domain::{AnalysisConfig, DiagramLevel, OutputConfig}; + +#[test] +fn analysis_config_has_sensible_defaults() { + let config = AnalysisConfig::default(); + assert!(config.excludes().is_empty()); + assert_eq!(config.level(), DiagramLevel::Module); + assert!(config.module_mappings().is_empty()); +} + +#[test] +fn analysis_config_with_excludes() { + let config = + AnalysisConfig::default().with_excludes(vec!["tests/".to_string(), "vendor/".to_string()]); + + assert_eq!(config.excludes().len(), 2); +} + +#[test] +fn output_config_has_sensible_defaults() { + let config = OutputConfig::default(); + assert!(!config.split_by_module()); + assert!(config.output_path().is_none()); +} + +#[test] +fn output_config_with_split() { + let config = OutputConfig::default().with_split_by_module(true); + assert!(config.split_by_module()); +} + +#[test] +fn all_diagram_levels_exist() { + let _project = DiagramLevel::Project; + let _module = DiagramLevel::Module; + let _type_level = DiagramLevel::Type; +} diff --git a/crates/domain/tests/file_path_tests.rs b/crates/domain/tests/file_path_tests.rs new file mode 100644 index 0000000..850c77e --- /dev/null +++ b/crates/domain/tests/file_path_tests.rs @@ -0,0 +1,29 @@ +use archlens_domain::FilePath; + +#[test] +fn valid_file_path_is_created() { + let path = FilePath::new("src/main.rs").unwrap(); + assert_eq!(path.as_str(), "src/main.rs"); +} + +#[test] +fn empty_file_path_is_rejected() { + let result = FilePath::new(""); + assert!(result.is_err()); +} + +#[test] +fn whitespace_only_file_path_is_rejected() { + let result = FilePath::new(" "); + assert!(result.is_err()); +} + +#[test] +fn file_paths_are_comparable() { + let a = FilePath::new("src/main.rs").unwrap(); + let b = FilePath::new("src/main.rs").unwrap(); + let c = FilePath::new("src/lib.rs").unwrap(); + + assert_eq!(a, b); + assert_ne!(a, c); +} diff --git a/crates/domain/tests/language_tests.rs b/crates/domain/tests/language_tests.rs new file mode 100644 index 0000000..f3bb412 --- /dev/null +++ b/crates/domain/tests/language_tests.rs @@ -0,0 +1,18 @@ +use archlens_domain::Language; + +#[test] +fn known_languages_are_available() { + let rust = Language::Rust; + let csharp = Language::CSharp; + let python = Language::Python; + + assert_eq!(rust.name(), "Rust"); + assert_eq!(csharp.name(), "CSharp"); + assert_eq!(python.name(), "Python"); +} + +#[test] +fn languages_are_comparable() { + assert_eq!(Language::Rust, Language::Rust); + assert_ne!(Language::Rust, Language::Python); +} diff --git a/crates/domain/tests/module_name_tests.rs b/crates/domain/tests/module_name_tests.rs new file mode 100644 index 0000000..80ac6ec --- /dev/null +++ b/crates/domain/tests/module_name_tests.rs @@ -0,0 +1,23 @@ +use archlens_domain::ModuleName; + +#[test] +fn valid_module_name_is_created() { + let name = ModuleName::new("Orders").unwrap(); + assert_eq!(name.as_str(), "Orders"); +} + +#[test] +fn empty_module_name_is_rejected() { + let result = ModuleName::new(""); + assert!(result.is_err()); +} + +#[test] +fn module_names_are_comparable() { + let a = ModuleName::new("Orders").unwrap(); + let b = ModuleName::new("Orders").unwrap(); + let c = ModuleName::new("Billing").unwrap(); + + assert_eq!(a, b); + assert_ne!(a, c); +} diff --git a/crates/domain/tests/render_output_tests.rs b/crates/domain/tests/render_output_tests.rs new file mode 100644 index 0000000..7a5b30f --- /dev/null +++ b/crates/domain/tests/render_output_tests.rs @@ -0,0 +1,37 @@ +use archlens_domain::{RenderOutput, RenderedFile}; + +#[test] +fn rendered_file_carries_name_and_content() { + let file = RenderedFile::new("overview.mmd", "graph TD;").unwrap(); + assert_eq!(file.name(), "overview.mmd"); + assert_eq!(file.content(), "graph TD;"); +} + +#[test] +fn rendered_file_rejects_empty_name() { + let result = RenderedFile::new("", "content"); + assert!(result.is_err()); +} + +#[test] +fn rendered_file_rejects_empty_content() { + let result = RenderedFile::new("file.mmd", ""); + assert!(result.is_err()); +} + +#[test] +fn render_output_holds_multiple_files() { + let files = vec![ + RenderedFile::new("overview.mmd", "graph TD;").unwrap(), + RenderedFile::new("orders.mmd", "classDiagram").unwrap(), + ]; + let output = RenderOutput::new(files); + assert_eq!(output.files().len(), 2); +} + +#[test] +fn render_output_can_be_single_file() { + let file = RenderedFile::new("arch.mmd", "graph TD;").unwrap(); + let output = RenderOutput::single(file); + assert_eq!(output.files().len(), 1); +} diff --git a/crates/domain/tests/source_file_tests.rs b/crates/domain/tests/source_file_tests.rs new file mode 100644 index 0000000..21011cd --- /dev/null +++ b/crates/domain/tests/source_file_tests.rs @@ -0,0 +1,10 @@ +use archlens_domain::{FilePath, Language, SourceFile}; + +#[test] +fn source_file_carries_path_and_language() { + let path = FilePath::new("src/main.rs").unwrap(); + let file = SourceFile::new(path.clone(), Language::Rust); + + assert_eq!(file.path(), &path); + assert_eq!(file.language(), Language::Rust); +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml new file mode 100644 index 0000000..df01a81 --- /dev/null +++ b/crates/presentation/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "archlens" +version = "0.1.0" +edition = "2024" +publish = false + +[[bin]] +name = "archlens" +path = "src/main.rs" + +[dependencies] +archlens-domain.workspace = true +archlens-application.workspace = true +archlens-tree-sitter.workspace = true +archlens-walkdir.workspace = true +archlens-mermaid.workspace = true +archlens-ascii.workspace = true +archlens-file-writer.workspace = true +archlens-stdout-writer.workspace = true +archlens-toml-config.workspace = true +archlens-cargo-workspace.workspace = true +anyhow.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/presentation/src/cli.rs b/crates/presentation/src/cli.rs new file mode 100644 index 0000000..0b56f99 --- /dev/null +++ b/crates/presentation/src/cli.rs @@ -0,0 +1,61 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command( + name = "archlens", + about = "Generate architecture diagrams from source code" +)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, + + #[arg(default_value = ".")] + pub path: PathBuf, + + #[arg(long, default_value = "module")] + pub level: String, + + #[arg(long, default_value = "mermaid")] + pub format: String, + + #[arg(long)] + pub output: Option, + + #[arg(long)] + pub config: Option, + + #[arg(long)] + pub scope: Option, + + #[arg(long)] + pub exclude: Vec, + + #[arg(long)] + pub split_by_module: bool, + + #[arg(long)] + pub strict: bool, + + #[arg( + long, + help = "Check if output matches existing file, exit 1 if different" + )] + pub check: bool, + + #[arg(short, long, action = clap::ArgAction::Count)] + pub verbose: u8, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + Init { + #[arg(default_value = ".")] + path: PathBuf, + }, + Diff { + #[arg(help = "Path to existing diagram file to compare against")] + existing: PathBuf, + }, +} diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs new file mode 100644 index 0000000..0ef5c8b --- /dev/null +++ b/crates/presentation/src/lib.rs @@ -0,0 +1,392 @@ +mod cli; + +use std::path::PathBuf; + +use anyhow::{Result, bail}; + +use archlens_application::queries::AnalyzeCodebase; +use archlens_ascii::AsciiRenderer; +use archlens_cargo_workspace::CargoWorkspaceAnalyzer; +use archlens_domain::{ + CodeGraph, DiagramLevel, + ports::{ConfigLoader, OutputWriter, ProjectAnalyzer}, +}; +use archlens_file_writer::FileOutputWriter; +use archlens_mermaid::MermaidRenderer; +use archlens_stdout_writer::StdoutOutputWriter; +use archlens_toml_config::TomlConfigLoader; +use archlens_tree_sitter::TreeSitterAnalyzer; +use archlens_walkdir::WalkdirDiscovery; + +pub use cli::{Cli, Command}; + +pub type CliArgs = Cli; + +pub fn run(args: Cli) -> Result<()> { + match &args.command { + Some(Command::Init { path }) => return init_config(path), + Some(Command::Diff { existing }) => return run_diff(&args, existing), + None => {} + } + init_tracing(args.verbose); + + let config_loader = match &args.config { + Some(path) => TomlConfigLoader::from_path(std::path::Path::new(path))?, + None => { + let default_path = args.path.join("archlens.toml"); + if default_path.exists() { + TomlConfigLoader::from_path(&default_path)? + } else { + TomlConfigLoader::default() + } + } + }; + + let mut analysis_config = config_loader.load_analysis_config()?; + let level = parse_level(&args.level); + analysis_config = analysis_config.with_level(level); + if let Some(ref scope) = args.scope { + analysis_config = analysis_config.with_scope(scope.clone()); + } + if !args.exclude.is_empty() { + let mut excludes = analysis_config.excludes().to_vec(); + excludes.extend(args.exclude.iter().cloned()); + analysis_config = analysis_config.with_excludes(excludes); + } + + let graph = if level == DiagramLevel::Project { + let project_analyzer = CargoWorkspaceAnalyzer::new(); + project_analyzer.analyze(&args.path)? + } else { + let discovery = WalkdirDiscovery::new(); + let analyzer = TreeSitterAnalyzer::new(); + let analyze = AnalyzeCodebase::new(discovery, analyzer); + let result = analyze.execute(&args.path, &analysis_config)?; + + if !result.warnings().is_empty() { + for warning in result.warnings() { + eprintln!( + "WARNING: {}:{} {}", + warning.file_path().as_str(), + warning.line(), + warning.message() + ); + } + if args.strict { + bail!( + "analysis produced {} warning(s) in strict mode", + result.warnings().len() + ); + } + } + + let mut graph = result.graph().clone(); + + 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); + } + } + + graph + }; + + let renderer: Box = match &args.format[..] { + "mermaid" => Box::new(MermaidRenderer::with_level(level)), + "ascii" => Box::new(AsciiRenderer::new()), + fmt => bail!("unknown format: {fmt}"), + }; + + let ext = match &args.format[..] { + "mermaid" => "mmd", + _ => "txt", + }; + + if args.check { + if let Some(ref path) = args.output { + let output = renderer.render(&graph)?; + let current = output.files().first().map(|f| f.content()).unwrap_or(""); + let existing = std::fs::read_to_string(path).unwrap_or_default(); + if current != existing { + eprintln!("Architecture diagram is outdated: {path}"); + std::process::exit(1); + } + println!("Architecture diagram is up to date."); + return Ok(()); + } else { + bail!("--check requires --output to specify the file to check against"); + } + } + + if args.split_by_module { + write_split(&graph, &*renderer, &args.output, ext)?; + } else { + write_single(&graph, &*renderer, &args.output)?; + } + + Ok(()) +} + +fn write_split( + graph: &CodeGraph, + renderer: &dyn archlens_domain::ports::DiagramRenderer, + output: &Option, + ext: &str, +) -> Result<()> { + let output_dir = output + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let writer = FileOutputWriter::new(output_dir); + + let overview = renderer.render(graph)?; + let overview_file = archlens_domain::RenderedFile::new( + &format!("overview.{ext}"), + overview.files().first().map(|f| f.content()).unwrap_or(""), + )?; + writer.write(&archlens_domain::RenderOutput::single(overview_file))?; + + for module in graph.modules() { + let subgraph = graph.subgraph_by_module(&module); + let module_output = renderer.render(&subgraph)?; + let module_file = archlens_domain::RenderedFile::new( + &format!("{}.{ext}", module.as_str().to_lowercase()), + module_output + .files() + .first() + .map(|f| f.content()) + .unwrap_or(""), + )?; + writer.write(&archlens_domain::RenderOutput::single(module_file))?; + } + + Ok(()) +} + +fn write_single( + graph: &CodeGraph, + renderer: &dyn archlens_domain::ports::DiagramRenderer, + output: &Option, +) -> Result<()> { + let rendered = renderer.render(graph)?; + + match output { + Some(path) => { + let writer = FileOutputWriter::single_file(PathBuf::from(path)); + writer.write(&rendered)?; + } + None => { + let writer = StdoutOutputWriter::new(); + writer.write(&rendered)?; + } + } + + Ok(()) +} + +fn merge_project_deps_as_module_edges( + graph: &mut archlens_domain::CodeGraph, + project_graph: &archlens_domain::CodeGraph, +) { + use std::collections::HashMap; + + let mut crate_to_module: HashMap<&str, &str> = HashMap::new(); + for element in project_graph.elements() { + let module = element + .module() + .map(|m| m.as_str()) + .unwrap_or(element.name()); + crate_to_module.insert(element.name(), module); + } + + let graph_modules: std::collections::HashSet = graph + .modules() + .iter() + .map(|m| m.as_str().to_string()) + .collect(); + + for rel in project_graph.relationships() { + let src_module = crate_to_module.get(rel.source()); + let tgt_module = crate_to_module.get(rel.target()); + + if let (Some(src), Some(tgt)) = (src_module, tgt_module) { + let src_cap = capitalize(src); + let tgt_cap = capitalize(tgt); + + if src_cap != tgt_cap + && graph_modules.contains(&src_cap) + && graph_modules.contains(&tgt_cap) + && let Ok(edge) = archlens_domain::Relationship::new( + &src_cap, + &tgt_cap, + archlens_domain::RelationshipKind::Composition, + ) + { + graph.add_relationship(edge); + } + } + } +} + +fn capitalize(s: &str) -> String { + s.split('-') + .map(|seg| { + if seg.is_empty() { + String::new() + } else { + format!("{}{}", seg[..1].to_uppercase(), &seg[1..]) + } + }) + .collect::>() + .join("-") +} + +fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> { + init_tracing(args.verbose); + + let config_loader = match &args.config { + Some(path) => TomlConfigLoader::from_path(std::path::Path::new(path))?, + None => { + let default_path = args.path.join("archlens.toml"); + if default_path.exists() { + TomlConfigLoader::from_path(&default_path)? + } else { + TomlConfigLoader::default() + } + } + }; + + let mut analysis_config = config_loader.load_analysis_config()?; + let level = parse_level(&args.level); + analysis_config = analysis_config.with_level(level); + + let graph = if level == DiagramLevel::Project { + CargoWorkspaceAnalyzer::new().analyze(&args.path)? + } else { + let discovery = WalkdirDiscovery::new(); + let analyzer = TreeSitterAnalyzer::new(); + let analyze = AnalyzeCodebase::new(discovery, analyzer); + let result = analyze.execute(&args.path, &analysis_config)?; + let mut graph = result.graph().clone(); + 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); + } + } + graph + }; + + let renderer: Box = match &args.format[..] { + "mermaid" => Box::new(MermaidRenderer::with_level(level)), + "ascii" => Box::new(AsciiRenderer::new()), + fmt => bail!("unknown format: {fmt}"), + }; + + let output = renderer.render(&graph)?; + let current = output.files().first().map(|f| f.content()).unwrap_or(""); + + let existing = std::fs::read_to_string(existing_path).unwrap_or_default(); + + if current == existing { + println!("No changes detected."); + return Ok(()); + } + + let current_lines: Vec<&str> = current.lines().collect(); + let existing_lines: Vec<&str> = existing.lines().collect(); + + let mut added = Vec::new(); + let mut removed = Vec::new(); + + for line in ¤t_lines { + if !existing_lines.contains(line) { + added.push(*line); + } + } + for line in &existing_lines { + if !current_lines.contains(line) { + removed.push(*line); + } + } + + if !removed.is_empty() { + println!("Removed:"); + for line in &removed { + println!(" - {line}"); + } + } + if !added.is_empty() { + println!("Added:"); + for line in &added { + println!(" + {line}"); + } + } + + println!("\n{} added, {} removed", added.len(), removed.len()); + std::process::exit(1); +} + +fn init_config(path: &std::path::Path) -> Result<()> { + let config_path = path.join("archlens.toml"); + if config_path.exists() { + bail!("archlens.toml already exists at {}", config_path.display()); + } + + let content = r#"[analysis] +# Directories to exclude from analysis +exclude = ["tests/", "vendor/", "generated/"] + +# Default granularity: "module", "type", or "project" +level = "module" + +[modules] +# Map directories to module names (overrides auto-detection) +# "src/infra" = "Infrastructure" +# "src/api" = "API" + +[output] +# Default output format +format = "mermaid" + +# Default output path (omit for stdout) +# path = "docs/architecture.mmd" + +# Generate separate files per module +split_by_module = false +"#; + + std::fs::write(&config_path, content)?; + println!("Created {}", config_path.display()); + Ok(()) +} + +fn parse_level(level: &str) -> DiagramLevel { + match level { + "type" => DiagramLevel::Type, + "project" => DiagramLevel::Project, + _ => DiagramLevel::Module, + } +} + +fn init_tracing(verbosity: u8) { + let filter = match verbosity { + 0 => "warn", + 1 => "info", + 2 => "debug", + _ => "trace", + }; + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)), + ) + .try_init() + .ok(); +} diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs new file mode 100644 index 0000000..f688553 --- /dev/null +++ b/crates/presentation/src/main.rs @@ -0,0 +1,9 @@ +use anyhow::Result; +use clap::Parser; + +use archlens::Cli; + +fn main() -> Result<()> { + let args = Cli::parse(); + archlens::run(args) +} diff --git a/crates/presentation/tests/end_to_end_tests.rs b/crates/presentation/tests/end_to_end_tests.rs new file mode 100644 index 0000000..043eb96 --- /dev/null +++ b/crates/presentation/tests/end_to_end_tests.rs @@ -0,0 +1,133 @@ +use std::fs; + +use archlens::run; + +fn create_rust_project(dir: &std::path::Path) { + fs::create_dir_all(dir.join("src")).unwrap(); + fs::write( + dir.join("src/order.rs"), + "pub struct Order {\n pub id: u64,\n}\n", + ) + .unwrap(); + fs::write( + dir.join("src/service.rs"), + "pub struct OrderService {\n order: Order,\n}\n", + ) + .unwrap(); +} + +fn create_multi_module_project(dir: &std::path::Path) { + fs::create_dir_all(dir.join("src/orders")).unwrap(); + fs::create_dir_all(dir.join("src/billing")).unwrap(); + fs::write( + dir.join("src/orders/order.rs"), + "pub struct Order {\n pub id: u64,\n}\n", + ) + .unwrap(); + fs::write( + dir.join("src/orders/service.rs"), + "pub struct OrderService {\n order: Order,\n}\n", + ) + .unwrap(); + fs::write( + dir.join("src/billing/invoice.rs"), + "pub struct Invoice {\n pub total: f64,\n}\n", + ) + .unwrap(); +} + +#[test] +fn analyzes_rust_project_and_writes_mermaid_to_file() { + let project = tempfile::tempdir().unwrap(); + create_rust_project(project.path()); + + let output_dir = tempfile::tempdir().unwrap(); + let output_file = output_dir.path().join("arch.mmd"); + + run(archlens::CliArgs { + command: None, + path: project.path().to_path_buf(), + level: "type".to_string(), + format: "mermaid".to_string(), + output: Some(output_file.to_str().unwrap().to_string()), + config: None, + scope: None, + exclude: vec![], + split_by_module: false, + strict: false, + check: false, + verbose: 0, + }) + .unwrap(); + + let content = fs::read_to_string(&output_file).unwrap(); + assert!(content.contains("classDiagram")); + assert!(content.contains("Order")); + assert!(content.contains("OrderService")); +} + +#[test] +fn works_without_config_file() { + let project = tempfile::tempdir().unwrap(); + create_rust_project(project.path()); + + let output_dir = tempfile::tempdir().unwrap(); + let output_file = output_dir.path().join("arch.mmd"); + + let result = run(archlens::CliArgs { + command: None, + path: project.path().to_path_buf(), + level: "type".to_string(), + format: "mermaid".to_string(), + output: Some(output_file.to_str().unwrap().to_string()), + config: None, + scope: None, + exclude: vec![], + split_by_module: false, + strict: false, + check: false, + verbose: 0, + }); + + assert!(result.is_ok()); +} + +#[test] +fn split_by_module_writes_overview_and_per_module_files() { + let project = tempfile::tempdir().unwrap(); + create_multi_module_project(project.path()); + + let output_dir = tempfile::tempdir().unwrap(); + + run(archlens::CliArgs { + command: None, + path: project.path().to_path_buf(), + level: "module".to_string(), + format: "mermaid".to_string(), + output: Some(output_dir.path().to_str().unwrap().to_string()), + config: None, + scope: None, + exclude: vec![], + split_by_module: true, + strict: false, + check: false, + verbose: 0, + }) + .unwrap(); + + let overview = output_dir.path().join("overview.mmd"); + assert!(overview.exists(), "overview.mmd should exist"); + + let overview_content = fs::read_to_string(&overview).unwrap(); + assert!(overview_content.contains("graph TD") || overview_content.contains("classDiagram")); + + let entries: Vec<_> = fs::read_dir(output_dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + + assert!( + entries.len() > 1, + "should have overview + at least one module file" + ); +} diff --git a/docs/adr/0001-hexagonal-architecture-with-ddd.md b/docs/adr/0001-hexagonal-architecture-with-ddd.md new file mode 100644 index 0000000..605851b --- /dev/null +++ b/docs/adr/0001-hexagonal-architecture-with-ddd.md @@ -0,0 +1,51 @@ +# 0001 — Hexagonal Architecture with DDD + +**Status:** Accepted +**Date:** 2026-06-16 + +## Context + +Archlens is a language-agnostic architecture diagram generator. It needs to support multiple input languages (Rust, C#, Python), multiple output formats (Mermaid, ASCII, future: D2, interactive web), and multiple IO strategies (file, stdout). The tool must be extensible without modifying core logic. + +## Decision + +Use hexagonal architecture (ports and adapters) with DDD structuring. Workspace layout: + +``` +crates/ + domain/ — zero external deps (no tracing, no rayon, nothing) + application/ — depends on domain only + rayon, tracing (pragmatic utility exceptions) + adapters/ + tree-sitter/ — SourceAnalyzer (internal modules per language) + walkdir/ — FileDiscovery + mermaid/ — DiagramRenderer + ascii/ — DiagramRenderer + file-writer/ — OutputWriter + stdout-writer/ — OutputWriter + toml-config/ — ConfigLoader + presentation/ — CLI (clap), composition root, tracing-subscriber setup +``` + +**Dependency rules:** +- Domain depends on nothing +- Application depends on domain +- Adapters depend on domain (not application) +- Presentation depends on application + adapters (composition root) + +**Use cases** are generic over port traits (static dispatch), not trait objects. + +**Pragmatic exceptions:** `rayon` and `tracing` in application crate. These are general-purpose utilities, not external system adapters. Documented explicitly to avoid precedent creep. + +## Alternatives Considered + +- **Trait objects for DI:** Simpler type signatures but runtime dispatch overhead and less compile-time safety. Rejected — generics align better with Rust idioms and zero-cost goals. +- **DI container (shaku):** Unnecessary ceremony for Rust. Manual wiring in presentation is explicit and sufficient. +- **Domain with tracing dependency:** Rejected to keep domain fully pure. Application wraps domain calls in spans. + +## Consequences + +- Adding a new language = new module in tree-sitter adapter (or new adapter crate) +- Adding a new output format = new adapter crate implementing DiagramRenderer +- Adding a new output destination = new adapter crate implementing OutputWriter +- Use case type signatures carry multiple generic parameters (accepted tradeoff) +- Domain crate is testable with zero setup — no mocks for infrastructure needed diff --git a/docs/adr/0002-tree-sitter-for-parsing.md b/docs/adr/0002-tree-sitter-for-parsing.md new file mode 100644 index 0000000..2b2e3ae --- /dev/null +++ b/docs/adr/0002-tree-sitter-for-parsing.md @@ -0,0 +1,31 @@ +# 0002 — Tree-sitter for Source Code Parsing + +**Status:** Accepted +**Date:** 2026-06-16 + +## Context + +Archlens needs to extract type-level information (classes, structs, traits, interfaces, enums, fields, inheritance) from Rust, C#, and Python source code. The tool must be language-agnostic in design, fast enough for CI, and memory-efficient for large codebases. + +## Decision + +Use tree-sitter as the primary parsing backend. One `tree-sitter` adapter crate with internal modules per language. Each language module defines tree-sitter S-expression queries to extract CodeElements and Relationships. + +**Tradeoff accepted:** Tree-sitter provides syntactic analysis only — no cross-file type resolution, no generics resolution, no resolved imports. This is sufficient for architecture diagrams where we care about structural relationships visible in the source text. + +Semantic resolution is a future concern, handled by a separate adapter (e.g., `roslyn-adapter` for C#) implementing the same `SourceAnalyzer` port. + +## Alternatives Considered + +- **Custom parsers/lexers per language:** Full control but enormous implementation and maintenance effort. Rejected. +- **LSP servers:** Rich semantic info but heavy to run, hard to orchestrate in CI, each language needs its own server process. Rejected for CI use case. +- **Native AST APIs (Roslyn, rustc_ast, Python ast):** Very accurate but each is a completely different API and ecosystem. Can't run Roslyn from Rust easily. Rejected as primary approach — viable as future specialized adapters. +- **Regex/heuristic:** Breaks on edge cases. Not serious for a real tool. + +## Consequences + +- Single unified parsing approach across all languages +- Adding a new language = writing tree-sitter queries (hours, not weeks) +- No semantic type resolution — `use Foo` doesn't tell us which `Foo` across modules +- Memory-efficient: parse trees are per-file and dropped after extraction +- Fast: tree-sitter is incremental and optimized for performance