diff --git a/Cargo.lock b/Cargo.lock index dd8711a..726ed5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -75,14 +75,18 @@ dependencies = [ "archlens-application", "archlens-ascii", "archlens-cargo-workspace", + "archlens-d2", "archlens-domain", "archlens-file-writer", + "archlens-html", "archlens-mermaid", + "archlens-python-project", "archlens-stdout-writer", "archlens-toml-config", "archlens-tree-sitter", "archlens-walkdir", "clap", + "notify", "tempfile", "tracing", "tracing-subscriber", @@ -119,6 +123,14 @@ dependencies = [ "tracing", ] +[[package]] +name = "archlens-d2" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "tempfile", +] + [[package]] name = "archlens-domain" version = "0.1.0" @@ -136,6 +148,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "archlens-html" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "archlens-mermaid" version = "0.1.0" @@ -145,6 +167,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "archlens-python-project" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "serde", + "tempfile", + "toml", +] + [[package]] name = "archlens-stdout-writer" version = "0.1.0" @@ -192,6 +224,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.0" @@ -314,7 +352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -323,6 +361,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -335,6 +383,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -416,6 +473,35 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -428,6 +514,26 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -473,13 +579,54 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.13.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", + "serde", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -589,11 +736,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -717,7 +864,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -934,6 +1081,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -980,7 +1133,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -992,7 +1145,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1001,6 +1154,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1010,6 +1172,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -1083,7 +1309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.0", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index ecad204..2df5fc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,9 @@ members = [ "crates/adapters/stdout-writer", "crates/adapters/toml-config", "crates/adapters/cargo-workspace", + "crates/adapters/python-project", + "crates/adapters/d2", + "crates/adapters/html-viewer", ] [workspace.dependencies] @@ -26,6 +29,10 @@ archlens-file-writer = { path = "crates/adapters/file-writer" } archlens-stdout-writer = { path = "crates/adapters/stdout-writer" } archlens-toml-config = { path = "crates/adapters/toml-config" } archlens-cargo-workspace = { path = "crates/adapters/cargo-workspace" } +archlens-python-project = { path = "crates/adapters/python-project" } +archlens-d2 = { path = "crates/adapters/d2" } +archlens-html = { path = "crates/adapters/html-viewer" } +serde_json = "1" # Error handling thiserror = "2" @@ -55,5 +62,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Parallelism rayon = "1" +# File watching +notify = { version = "7", features = ["serde"] } + # Testing tempfile = "3" diff --git a/README.md b/README.md index 0aaa377..5eeeb72 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Generate architecture diagrams from source code. Runs on CI to keep docs fresh. -Supports Rust and Python. Produces Mermaid or ASCII output. +Supports Rust, Python and C# (planned). Produces Mermaid, D2, ASCII, or interactive HTML output. ## Install @@ -28,6 +28,12 @@ archlens . --output docs/architecture.mmd # Split by module (one file per module + overview) archlens . --level type --split-by-module --output docs/arch/ +# D2 format +archlens . --format d2 --output docs/architecture.d2 + +# Interactive HTML viewer +archlens . --format html --output docs/architecture.html + # ASCII output to terminal archlens . --format ascii @@ -37,6 +43,18 @@ archlens . --scope src/domain # Exclude directories archlens . --exclude tests/ --exclude generated/ +# Exclude test files from diagrams (default: excluded) +archlens . --include-tests + +# Show/hide dependency weights on module arrows (default: shown) +archlens . --level module --no-weights + +# Watch for changes and regenerate automatically +archlens . --watch + +# Analyse only files changed since a git ref (useful in CI) +archlens . --since HEAD~1 + # Verbose logging archlens . -v # info archlens . -vv # debug @@ -50,7 +68,13 @@ Check if committed diagrams are up to date: archlens . --level project --check --output docs/architecture.mmd ``` -Exit code 1 if the diagram has changed. Use `--strict` to also fail on parse warnings. +Exit code 1 if the diagram has changed. Use `--strict` to also fail on parse warnings or boundary rule violations. + +Only re-analyse files changed since the last release tag: + +```bash +archlens . --since v1.2.0 --output docs/architecture.mmd +``` Compare current state against an existing file: @@ -80,23 +104,41 @@ level = "module" format = "mermaid" # path = "docs/architecture.mmd" split_by_module = false + +[rules] +# Allowed dependency directions — unlisted directions are violations +# allow = ["Application --> Domain", "Adapters --> Domain"] + +# Explicitly forbidden directions — always checked +# deny = ["Domain --> Adapters", "Domain --> Application"] ``` +Violations are printed to stderr. Pass `--strict` to exit with code 1 on any violation. + ## Diagram Levels | Level | What it shows | Source | |-------|--------------|--------| -| `project` | Crate/package dependencies | `Cargo.toml` | -| `module` | Module-level dependency graph | Imports + manifest deps | -| `type` | Class diagram with fields, methods, relationships | Source code (tree-sitter) | +| `project` | Crate/package dependencies | `Cargo.toml` or `pyproject.toml` | +| `module` | Module-level dependency graph with coupling weights | Imports + manifest deps | +| `type` | Class diagram with fields, methods, signatures, relationships | Source code (tree-sitter) | + +## Output Formats + +| Format | Flag | Extension | Notes | +|--------|------|-----------|-------| +| Mermaid | `--format mermaid` | `.mmd` | Default. Renders in GitHub, GitLab, Obsidian | +| D2 | `--format d2` | `.d2` | Better layout control, renders to SVG | +| ASCII | `--format ascii` | `.txt` | Terminal-friendly | +| HTML | `--format html` | `.html` | Self-contained interactive viewer, clickable nodes | ## Supported Languages -| Language | Types | Inheritance | Composition | Imports | -|----------|-------|-------------|-------------|---------| -| Rust | struct, enum, trait | `impl Trait for Type` | struct fields | `use`, `mod` | -| Python | class | `class Foo(Bar)` | `__init__` params, type annotations | `import`, `from ... import` | -| C# | planned | - | - | - | +| Language | Types | Inheritance | Composition | Imports | Method signatures | +|----------|-------|-------------|-------------|---------|-------------------| +| Rust | struct, enum, trait | `impl Trait for Type` | struct fields | `use`, `mod` | params + return type | +| Python | class | `class Foo(Bar)` | `__init__` params, type annotations | `import`, `from ... import` | typed params + return annotation | +| C# | planned | - | - | - | - | ## Architecture @@ -104,18 +146,21 @@ Built with hexagonal architecture (ports and adapters) + DDD. ``` crates/ - domain/ # Core model, zero external deps - application/ # Use cases, orchestration + domain/ # Core model, zero external deps + application/ # Use cases, orchestration adapters/ - tree-sitter/ # Source code parsing (Rust, Python) - cargo-workspace/ # Cargo.toml dependency extraction - walkdir/ # File discovery - mermaid/ # Mermaid diagram output - ascii/ # Terminal output - file-writer/ # Write to disk - stdout-writer/ # Write to stdout - toml-config/ # Config file parsing - presentation/ # CLI (clap), composition root + tree-sitter/ # Source code parsing (Rust, Python) + cargo-workspace/ # Cargo.toml dependency extraction + python-project/ # pyproject.toml dependency extraction + walkdir/ # File discovery + mermaid/ # Mermaid diagram output + d2/ # D2 diagram output + ascii/ # Terminal output + html-viewer/ # Interactive HTML output + file-writer/ # Write to disk + stdout-writer/ # Write to stdout + toml-config/ # Config file parsing + presentation/ # CLI (clap), composition root ``` ## License diff --git a/crates/adapters/d2/Cargo.toml b/crates/adapters/d2/Cargo.toml new file mode 100644 index 0000000..c49555f --- /dev/null +++ b/crates/adapters/d2/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "archlens-d2" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/d2/src/d2_renderer.rs b/crates/adapters/d2/src/d2_renderer.rs new file mode 100644 index 0000000..f07ad60 --- /dev/null +++ b/crates/adapters/d2/src/d2_renderer.rs @@ -0,0 +1,173 @@ +use archlens_domain::{ + CodeGraph, DiagramLevel, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer, +}; + +pub struct D2Renderer { + level: DiagramLevel, +} + +impl Default for D2Renderer { + fn default() -> Self { + Self::new() + } +} + +impl D2Renderer { + pub fn new() -> Self { + Self { + level: DiagramLevel::Type, + } + } + + pub fn with_level(level: DiagramLevel) -> Self { + Self { level } + } +} + +impl DiagramRenderer for D2Renderer { + fn render(&self, graph: &CodeGraph) -> Result { + let content = match self.level { + DiagramLevel::Type => render_type(graph), + DiagramLevel::Module => render_module(graph), + DiagramLevel::Project => render_project(graph), + }; + let file = RenderedFile::new("diagram.d2", &content)?; + Ok(RenderOutput::single(file)) + } +} + +fn sanitize(name: &str) -> String { + name.replace("::", "_").replace(['-', ' '], "_") +} + +fn render_type(graph: &CodeGraph) -> String { + let mut lines = Vec::new(); + let (by_module, ungrouped) = graph.elements_by_module(); + + // Grouped by module + for (module, elements) in &by_module { + let mod_id = sanitize(module); + lines.push(format!("{mod_id}: {{")); + for el in elements { + let el_id = sanitize(el.name()); + lines.push(format!(" {el_id}: {{")); + lines.push(" shape: class".to_string()); + for field in el.fields() { + lines.push(format!(" {field}")); + } + for method in el.methods() { + let method_display = method.trim_start_matches(['+', '-']); + lines.push(format!( + " {}()", + method_display.split('(').next().unwrap_or(method_display) + )); + } + lines.push(" }".to_string()); + } + lines.push("}".to_string()); + } + + // Ungrouped elements + for el in &ungrouped { + let el_id = sanitize(el.name()); + lines.push(format!("{el_id}: {{")); + lines.push(" shape: class".to_string()); + lines.push("}".to_string()); + } + + // Relationships + for rel in graph.relationships() { + use archlens_domain::RelationshipKind; + let src = sanitize(rel.source()); + let tgt = sanitize(rel.target()); + let arrow = match rel.kind() { + RelationshipKind::Inheritance => format!("{src} -> {tgt}: {{style.stroke-dash: 0}}"), + RelationshipKind::Composition => format!("{src} -> {tgt}"), + RelationshipKind::Import => continue, + }; + lines.push(arrow); + } + + lines.join("\n") +} + +fn render_module(graph: &CodeGraph) -> String { + use archlens_domain::RelationshipKind; + use std::collections::{HashMap, HashSet}; + + let mut lines = Vec::new(); + let mut modules: HashSet = HashSet::new(); + let mut name_to_module: HashMap<&str, &str> = HashMap::new(); + + for el in graph.elements() { + if let Some(m) = el.module() { + modules.insert(m.as_str().to_string()); + name_to_module.insert(el.qualified_name(), m.as_str()); + name_to_module.insert(el.name(), m.as_str()); + } + } + + for module in &modules { + let id = sanitize(module); + lines.push(format!("{id}: {module}")); + } + + let mut edges: HashSet<(String, String)> = HashSet::new(); + for rel in graph.relationships() { + if rel.kind() == RelationshipKind::Import { + continue; + } + let src_mod = name_to_module.get(rel.source()); + let tgt_mod = name_to_module.get(rel.target()); + if let (Some(s), Some(t)) = (src_mod, tgt_mod) + && s != t + { + edges.insert((s.to_string(), t.to_string())); + } + } + + for (src, tgt) in &edges { + lines.push(format!("{} -> {}", sanitize(src), sanitize(tgt))); + } + + lines.join("\n") +} + +fn render_project(graph: &CodeGraph) -> String { + use archlens_domain::RelationshipKind; + use std::collections::HashMap; + + let mut lines = Vec::new(); + let (by_module, ungrouped) = graph.elements_by_module(); + + for (module, elements) in &by_module { + let mod_id = sanitize(module); + lines.push(format!("{mod_id}: {{")); + for el in elements { + lines.push(format!(" {}: {}", sanitize(el.name()), el.name())); + } + lines.push("}".to_string()); + } + + for el in &ungrouped { + lines.push(format!("{}: {}", sanitize(el.name()), el.name())); + } + + let name_to_id: HashMap<&str, String> = graph + .elements() + .iter() + .map(|e| (e.name(), sanitize(e.name()))) + .collect(); + + for rel in graph.relationships() { + if rel.kind() == RelationshipKind::Import { + continue; + } + if let (Some(src), Some(tgt)) = (name_to_id.get(rel.source()), name_to_id.get(rel.target())) + { + lines.push(format!("{src} -> {tgt}")); + } + } + + lines.join("\n") +} diff --git a/crates/adapters/d2/src/lib.rs b/crates/adapters/d2/src/lib.rs new file mode 100644 index 0000000..3faa735 --- /dev/null +++ b/crates/adapters/d2/src/lib.rs @@ -0,0 +1,2 @@ +mod d2_renderer; +pub use d2_renderer::D2Renderer; diff --git a/crates/adapters/d2/tests/d2_renderer_tests.rs b/crates/adapters/d2/tests/d2_renderer_tests.rs new file mode 100644 index 0000000..86bf04c --- /dev/null +++ b/crates/adapters/d2/tests/d2_renderer_tests.rs @@ -0,0 +1,110 @@ +use archlens_d2::D2Renderer; +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, DiagramLevel, FilePath, ModuleName, Relationship, + RelationshipKind, ports::DiagramRenderer, +}; + +fn make_el(name: &str, module: Option<&str>) -> CodeElement { + let mut el = CodeElement::new( + name, + CodeElementKind::Class, + FilePath::new(&format!("src/{name}.rs")).unwrap(), + 1, + ) + .unwrap(); + if let Some(m) = module { + el = el.with_module(ModuleName::new(m).unwrap()); + } + el +} + +#[test] +fn type_level_emits_class_shapes() { + let mut graph = CodeGraph::new(); + graph.add_element(make_el("OrderService", Some("App"))); + graph.add_element(make_el("Order", Some("Domain"))); + graph.add_relationship( + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(), + ); + let graph = graph.qualify(); + + let renderer = D2Renderer::new(); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!( + content.contains("shape: class"), + "expected class shape: {content}" + ); + assert!( + content.contains("OrderService"), + "expected OrderService: {content}" + ); + assert!(content.contains("Order"), "expected Order: {content}"); +} + +#[test] +fn module_level_emits_module_nodes_and_edges() { + let mut graph = CodeGraph::new(); + graph.add_element(make_el("Service", Some("App"))); + graph.add_element(make_el("Order", Some("Domain"))); + graph.add_relationship( + Relationship::new("Service", "Order", RelationshipKind::Composition).unwrap(), + ); + let graph = graph.qualify(); + + let renderer = D2Renderer::with_level(DiagramLevel::Module); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("App"), "expected App module: {content}"); + assert!( + content.contains("Domain"), + "expected Domain module: {content}" + ); + assert!( + content.contains("->"), + "expected dependency arrow: {content}" + ); +} + +#[test] +fn project_level_groups_by_module() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "my-api", + CodeElementKind::Project, + FilePath::new("api/pyproject.toml").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Backend").unwrap()), + ); + graph.add_element( + CodeElement::new( + "my-commons", + CodeElementKind::Project, + FilePath::new("commons/pyproject.toml").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_relationship( + Relationship::new("my-api", "my-commons", RelationshipKind::Composition).unwrap(), + ); + + let renderer = D2Renderer::with_level(DiagramLevel::Project); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!( + content.contains("Backend"), + "expected Backend group: {content}" + ); + assert!( + content.contains("my-api") || content.contains("my_api"), + "expected my-api: {content}" + ); + assert!(content.contains("->"), "expected dep arrow: {content}"); +} diff --git a/crates/adapters/html-viewer/Cargo.toml b/crates/adapters/html-viewer/Cargo.toml new file mode 100644 index 0000000..b57bfef --- /dev/null +++ b/crates/adapters/html-viewer/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "archlens-html" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +serde.workspace = true +serde_json = "1" + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/html-viewer/src/html_renderer.rs b/crates/adapters/html-viewer/src/html_renderer.rs new file mode 100644 index 0000000..793ede9 --- /dev/null +++ b/crates/adapters/html-viewer/src/html_renderer.rs @@ -0,0 +1,240 @@ +use std::collections::HashMap; + +use serde::Serialize; + +use archlens_domain::{ + CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile, ports::DiagramRenderer, +}; + +pub struct HtmlRenderer; + +impl Default for HtmlRenderer { + fn default() -> Self { + Self::new() + } +} + +impl HtmlRenderer { + pub fn new() -> Self { + Self + } +} + +#[derive(Serialize)] +struct GraphData { + nodes: Vec, + edges: Vec, +} + +#[derive(Serialize)] +struct NodeData { + id: String, + label: String, + module: String, + kind: String, + fields: Vec, + methods: Vec, +} + +#[derive(Serialize)] +struct EdgeData { + source: String, + target: String, + kind: String, +} + +impl DiagramRenderer for HtmlRenderer { + fn render(&self, graph: &CodeGraph) -> Result { + // Build graph data + let mut id_map: HashMap = HashMap::new(); + let mut nodes = Vec::new(); + + for (i, el) in graph.elements().iter().enumerate() { + let id = format!("n{i}"); + id_map.insert(el.qualified_name().to_string(), id.clone()); + id_map.insert(el.name().to_string(), id.clone()); + nodes.push(NodeData { + id, + label: el.name().to_string(), + module: el + .module() + .map(|m| m.as_str().to_string()) + .unwrap_or_default(), + kind: format!("{:?}", el.kind()), + fields: el.fields().to_vec(), + methods: el.methods().to_vec(), + }); + } + + let edges = graph + .relationships() + .iter() + .filter(|r| r.kind() != RelationshipKind::Import) + .filter_map(|r| { + let src = id_map.get(r.source())?; + let tgt = id_map.get(r.target())?; + Some(EdgeData { + source: src.clone(), + target: tgt.clone(), + kind: format!("{:?}", r.kind()), + }) + }) + .collect(); + + let data = GraphData { nodes, edges }; + let json = + serde_json::to_string(&data).map_err(|e| DomainError::ConfigError(e.to_string()))?; + + let html = build_html(&json); + let file = RenderedFile::new("diagram.html", &html)?; + Ok(RenderOutput::single(file)) + } +} + +fn build_html(graph_json: &str) -> String { + format!( + r#" + + + + +Architecture Diagram + + + + +
+ + +"#, + graph_json = graph_json + ) +} diff --git a/crates/adapters/html-viewer/src/lib.rs b/crates/adapters/html-viewer/src/lib.rs new file mode 100644 index 0000000..15ea2d6 --- /dev/null +++ b/crates/adapters/html-viewer/src/lib.rs @@ -0,0 +1,2 @@ +mod html_renderer; +pub use html_renderer::HtmlRenderer; diff --git a/crates/adapters/html-viewer/tests/html_renderer_tests.rs b/crates/adapters/html-viewer/tests/html_renderer_tests.rs new file mode 100644 index 0000000..ae18d6e --- /dev/null +++ b/crates/adapters/html-viewer/tests/html_renderer_tests.rs @@ -0,0 +1,78 @@ +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, Relationship, RelationshipKind, + ports::DiagramRenderer, +}; +use archlens_html::HtmlRenderer; + +fn make_graph() -> CodeGraph { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/app/s.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("App").unwrap()), + ); + graph.add_element( + CodeElement::new( + "Order", + CodeElementKind::Class, + FilePath::new("src/domain/o.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Domain").unwrap()), + ); + graph.add_relationship( + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(), + ); + graph.qualify() +} + +#[test] +fn html_output_is_self_contained() { + let renderer = HtmlRenderer::new(); + let output = renderer.render(&make_graph()).unwrap(); + let content = output.files()[0].content(); + + assert!( + content.starts_with(""), + "should be full HTML doc" + ); + assert!(content.contains(""), "should be complete HTML"); + assert!( + !content.contains("src="), + "should not have external src= links" + ); +} + +#[test] +fn html_output_embeds_graph_data_as_json() { + let renderer = HtmlRenderer::new(); + let output = renderer.render(&make_graph()).unwrap(); + let content = output.files()[0].content(); + + assert!( + content.contains("OrderService"), + "graph data should contain node names" + ); + assert!( + content.contains("Domain"), + "graph data should contain module names" + ); +} + +#[test] +fn html_output_includes_interactive_js() { + let renderer = HtmlRenderer::new(); + let output = renderer.render(&make_graph()).unwrap(); + let content = output.files()[0].content(); + + assert!( + content.contains("cytoscape") || content.contains("graph") || content.contains("nodes"), + "should include graph visualization JS" + ); +} diff --git a/crates/adapters/mermaid/src/mermaid_renderer.rs b/crates/adapters/mermaid/src/mermaid_renderer.rs index 8b4e385..42b9a47 100644 --- a/crates/adapters/mermaid/src/mermaid_renderer.rs +++ b/crates/adapters/mermaid/src/mermaid_renderer.rs @@ -7,6 +7,7 @@ use archlens_domain::{ pub struct MermaidRenderer { level: DiagramLevel, + show_weights: bool, } impl Default for MermaidRenderer { @@ -19,11 +20,24 @@ impl MermaidRenderer { pub fn new() -> Self { Self { level: DiagramLevel::Type, + show_weights: true, } } pub fn with_level(level: DiagramLevel) -> Self { - Self { level } + Self { + level, + show_weights: true, + } + } + + pub fn with_weights(mut self, show: bool) -> Self { + self.show_weights = show; + self + } + + fn display_name(qualified: &str) -> &str { + qualified.split("::").last().unwrap_or(qualified) } fn format_element_name(element: &CodeElement) -> String { @@ -89,7 +103,9 @@ impl MermaidRenderer { RelationshipKind::Composition => "-->", RelationshipKind::Import => "..>", }; - let key = format!("{} {} {}", rel.source(), arrow, rel.target()); + let src = Self::display_name(rel.source()); + let tgt = Self::display_name(rel.target()); + let key = format!("{} {} {}", src, arrow, tgt); if rel_seen.insert(key.clone()) { lines.push(format!(" {key}")); } @@ -136,10 +152,15 @@ impl MermaidRenderer { for element in graph.elements() { if let Some(module) = element.module() { + // Index both bare name and qualified name for lookup name_to_modules .entry(element.name()) .or_default() .insert(module.as_str()); + name_to_modules + .entry(element.qualified_name()) + .or_default() + .insert(module.as_str()); modules.insert(module.as_str().to_string()); let file_stem = std::path::Path::new(element.file_path().as_str()) @@ -156,7 +177,7 @@ impl MermaidRenderer { lines.push(format!(" {module}[{module}]")); } - let mut module_edges: HashSet<(String, String)> = HashSet::new(); + let mut module_edges: HashMap<(String, String), usize> = HashMap::new(); for rel in graph.relationships() { match rel.kind() { RelationshipKind::Import => { @@ -168,7 +189,7 @@ impl MermaidRenderer { && modules.contains(&target_mod) && *src != target_mod { - module_edges.insert((src.clone(), target_mod)); + *module_edges.entry((src.clone(), target_mod)).or_insert(0) += 1; } } _ => { @@ -176,7 +197,9 @@ impl MermaidRenderer { && modules.contains(rel.target()) && rel.source() != rel.target() { - module_edges.insert((rel.source().to_string(), rel.target().to_string())); + *module_edges + .entry((rel.source().to_string(), rel.target().to_string())) + .or_insert(0) += 1; continue; } @@ -190,7 +213,9 @@ impl MermaidRenderer { } for tgt_mod in tgt_set { if src_mod != tgt_mod { - module_edges.insert((src_mod.to_string(), tgt_mod.to_string())); + *module_edges + .entry((src_mod.to_string(), tgt_mod.to_string())) + .or_insert(0) += 1; } } } @@ -199,8 +224,18 @@ impl MermaidRenderer { } } - for (source, target) in &module_edges { - lines.push(format!(" {source} --> {target}")); + for ((source, target), count) in &module_edges { + let arrow = if self.show_weights { + let label = if *count == 1 { + r#"|"1 dep"|"#.to_string() + } else { + format!(r#"|"{count} deps"|"#) + }; + format!("--{label}") + } else { + "-->".to_string() + }; + lines.push(format!(" {source} {arrow} {target}")); } lines.join("\n") @@ -249,4 +284,43 @@ impl DiagramRenderer for MermaidRenderer { let file = RenderedFile::new("diagram.mmd", &content)?; Ok(RenderOutput::single(file)) } + + fn append_cross_module_deps( + &self, + content: &str, + module: &ModuleName, + deps: &[(ModuleName, usize)], + ) -> String { + if deps.is_empty() { + return content.to_string(); + } + + let src_id = format!( + "{}_module", + module.as_str().to_lowercase().replace('-', "_") + ); + let mut extra = format!( + " class {src_id}[\"{}\"] {{\n <>\n }}\n", + module.as_str() + ); + + for (dep_mod, count) in deps { + let dep_id = format!( + "{}_module", + dep_mod.as_str().to_lowercase().replace('-', "_") + ); + extra.push_str(&format!( + " class {dep_id}[\"{}\"] {{\n <>\n }}\n", + dep_mod.as_str() + )); + let label = if *count == 1 { + "1 dep".to_string() + } else { + format!("{count} deps") + }; + extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n")); + } + + format!("{content}\n{extra}") + } } diff --git a/crates/adapters/mermaid/tests/mermaid_renderer_tests.rs b/crates/adapters/mermaid/tests/mermaid_renderer_tests.rs index f32b00a..e19779b 100644 --- a/crates/adapters/mermaid/tests/mermaid_renderer_tests.rs +++ b/crates/adapters/mermaid/tests/mermaid_renderer_tests.rs @@ -156,7 +156,7 @@ fn renders_module_level_flowchart() { assert!(content.contains("graph TD")); assert!(content.contains("Orders")); assert!(content.contains("Billing")); - assert!(content.contains("Orders --> Billing")); + assert!(content.contains("Orders --") && content.contains("Billing")); } #[test] @@ -320,9 +320,99 @@ fn module_level_aggregates_cross_module_deps_into_single_arrow() { let output = renderer.render(&graph).unwrap(); let content = output.files()[0].content(); - let arrow_count = content.matches("Orders --> Infra").count(); + let arrow_count = + content.matches("Orders --> Infra").count() + content.matches("Orders --|").count(); assert_eq!( arrow_count, 1, "should have exactly one aggregated arrow, got:\n{content}" ); } + +#[test] +fn module_level_shows_dep_count_as_edge_label() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "ServiceA", + CodeElementKind::Class, + FilePath::new("src/app/a.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("App").unwrap()), + ); + graph.add_element( + CodeElement::new( + "ServiceB", + CodeElementKind::Class, + FilePath::new("src/app/b.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("App").unwrap()), + ); + graph.add_element( + CodeElement::new( + "Order", + CodeElementKind::Class, + FilePath::new("src/domain/order.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Domain").unwrap()), + ); + graph.add_relationship( + Relationship::new("ServiceA", "Order", RelationshipKind::Composition).unwrap(), + ); + graph.add_relationship( + Relationship::new("ServiceB", "Order", RelationshipKind::Composition).unwrap(), + ); + let graph = graph.qualify(); + + let renderer = MermaidRenderer::with_level(DiagramLevel::Module); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!( + content.contains(r#"|"2 deps"|"#), + "expected dep count label in: {content}" + ); +} + +#[test] +fn module_level_single_dep_uses_singular_label() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "Service", + CodeElementKind::Class, + FilePath::new("src/app/s.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("App").unwrap()), + ); + graph.add_element( + CodeElement::new( + "Order", + CodeElementKind::Class, + FilePath::new("src/domain/o.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Domain").unwrap()), + ); + graph.add_relationship( + Relationship::new("Service", "Order", RelationshipKind::Composition).unwrap(), + ); + let graph = graph.qualify(); + + let renderer = MermaidRenderer::with_level(DiagramLevel::Module); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!( + content.contains(r#"|"1 dep"|"#), + "expected singular dep label in: {content}" + ); +} diff --git a/crates/adapters/python-project/Cargo.toml b/crates/adapters/python-project/Cargo.toml new file mode 100644 index 0000000..8b85965 --- /dev/null +++ b/crates/adapters/python-project/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "archlens-python-project" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +toml.workspace = true +serde.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/python-project/src/lib.rs b/crates/adapters/python-project/src/lib.rs new file mode 100644 index 0000000..829d4ac --- /dev/null +++ b/crates/adapters/python-project/src/lib.rs @@ -0,0 +1,3 @@ +mod python_project_analyzer; + +pub use python_project_analyzer::PythonProjectAnalyzer; diff --git a/crates/adapters/python-project/src/python_project_analyzer.rs b/crates/adapters/python-project/src/python_project_analyzer.rs new file mode 100644 index 0000000..3d85907 --- /dev/null +++ b/crates/adapters/python-project/src/python_project_analyzer.rs @@ -0,0 +1,151 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde::Deserialize; + +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, DomainError, FilePath, Relationship, RelationshipKind, + ports::ProjectAnalyzer, +}; + +pub struct PythonProjectAnalyzer; + +impl Default for PythonProjectAnalyzer { + fn default() -> Self { + Self::new() + } +} + +impl PythonProjectAnalyzer { + pub fn new() -> Self { + Self + } +} + +// PEP 621 format +#[derive(Deserialize, Default)] +struct ProjectSection { + name: Option, + #[serde(default)] + dependencies: Vec, +} + +// Poetry format +#[derive(Deserialize, Default)] +struct PoetrySection { + name: Option, + #[serde(default)] + dependencies: HashMap, +} + +#[derive(Deserialize, Default)] +struct ToolSection { + #[serde(default)] + poetry: PoetrySection, +} + +#[derive(Deserialize)] +struct PyprojectToml { + project: Option, + #[serde(default)] + tool: ToolSection, +} + +fn extract_dep_name(dep: &str) -> &str { + dep.split(&['>', '<', '=', '!', '[', ';', ' '][..]) + .next() + .unwrap_or(dep) + .trim() +} + +fn normalize(name: &str) -> String { + name.to_lowercase().replace(['-', '.'], "_") +} + +impl ProjectAnalyzer for PythonProjectAnalyzer { + fn analyze(&self, root: &Path) -> Result { + // 1. Scan immediate subdirectories for pyproject.toml + let entries = std::fs::read_dir(root).map_err(|e| DomainError::IoError(e.to_string()))?; + + let mut packages: Vec<(String, String, Vec)> = Vec::new(); // (dir, name, deps) + + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let pyproject = path.join("pyproject.toml"); + if !pyproject.exists() { + continue; + } + + let content = std::fs::read_to_string(&pyproject) + .map_err(|e| DomainError::IoError(e.to_string()))?; + let parsed: PyprojectToml = + toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?; + + let dir_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + // Try PEP 621 [project] first, then Poetry [tool.poetry] + let (name, deps) = if let Some(proj) = parsed.project { + let name = proj.name.unwrap_or_else(|| dir_name.clone()); + let deps: Vec = proj + .dependencies + .iter() + .map(|d| extract_dep_name(d).to_string()) + .collect(); + (name, deps) + } else if let Some(pname) = parsed.tool.poetry.name { + let deps: Vec = parsed + .tool + .poetry + .dependencies + .keys() + .filter(|k| k.as_str() != "python") + .cloned() + .collect(); + (pname, deps) + } else { + continue; + }; + + packages.push((dir_name, name, deps)); + } + + let known: HashSet = packages + .iter() + .map(|(_, name, _)| normalize(name)) + .collect(); + + let mut graph = CodeGraph::new(); + + for (dir, name, _) in &packages { + let file_path = FilePath::new(&format!("{}/pyproject.toml", dir)) + .map_err(|e| DomainError::IoError(e.to_string()))?; + let element = CodeElement::new(name, CodeElementKind::Project, file_path, 1)?; + graph.add_element(element); + } + + for (_, pkg_name, deps) in &packages { + for dep in deps { + let dep_norm = normalize(dep); + if known.contains(&dep_norm) && dep_norm != normalize(pkg_name) { + // find the canonical name (original casing) of the dep + if let Some((_, canonical, _)) = + packages.iter().find(|(_, n, _)| normalize(n) == dep_norm) + && let Ok(rel) = + Relationship::new(pkg_name, canonical, RelationshipKind::Composition) + { + graph.add_relationship(rel); + } + } + } + } + + Ok(graph) + } +} diff --git a/crates/adapters/python-project/tests/python_project_tests.rs b/crates/adapters/python-project/tests/python_project_tests.rs new file mode 100644 index 0000000..6d4dca7 --- /dev/null +++ b/crates/adapters/python-project/tests/python_project_tests.rs @@ -0,0 +1,165 @@ +use std::fs; + +use archlens_domain::{CodeElementKind, RelationshipKind, ports::ProjectAnalyzer}; +use archlens_python_project::PythonProjectAnalyzer; + +fn create_monorepo(dir: &std::path::Path) { + fs::create_dir_all(dir.join("api")).unwrap(); + fs::create_dir_all(dir.join("commons")).unwrap(); + fs::create_dir_all(dir.join("worker")).unwrap(); + + fs::write( + dir.join("api/pyproject.toml"), + r#" +[project] +name = "my-api" +dependencies = [ + "my-commons>=1.0", + "fastapi", +] +"#, + ) + .unwrap(); + + fs::write( + dir.join("commons/pyproject.toml"), + r#" +[project] +name = "my-commons" +dependencies = [] +"#, + ) + .unwrap(); + + fs::write( + dir.join("worker/pyproject.toml"), + r#" +[project] +name = "my-worker" +dependencies = [ + "my-commons>=1.0", + "celery", +] +"#, + ) + .unwrap(); +} + +#[test] +fn discovers_python_packages_as_project_elements() { + let dir = tempfile::tempdir().unwrap(); + create_monorepo(dir.path()); + + let analyzer = PythonProjectAnalyzer::new(); + let graph = analyzer.analyze(dir.path()).unwrap(); + + assert_eq!(graph.elements().len(), 3); + assert!( + graph + .elements() + .iter() + .all(|e| e.kind() == CodeElementKind::Project) + ); + + let names: Vec<&str> = graph.elements().iter().map(|e| e.name()).collect(); + assert!(names.contains(&"my-api")); + assert!(names.contains(&"my-commons")); + assert!(names.contains(&"my-worker")); +} + +#[test] +fn extracts_intra_project_dependencies_from_pep621() { + let dir = tempfile::tempdir().unwrap(); + create_monorepo(dir.path()); + + let analyzer = PythonProjectAnalyzer::new(); + let graph = analyzer.analyze(dir.path()).unwrap(); + + let deps: Vec<(&str, &str)> = graph + .relationships() + .iter() + .map(|r| (r.source(), r.target())) + .collect(); + + assert!( + deps.contains(&("my-api", "my-commons")), + "missing api->commons: {deps:?}" + ); + assert!( + deps.contains(&("my-worker", "my-commons")), + "missing worker->commons: {deps:?}" + ); + assert!( + graph + .relationships() + .iter() + .all(|r| r.kind() == RelationshipKind::Composition) + ); +} + +#[test] +fn excludes_external_dependencies() { + let dir = tempfile::tempdir().unwrap(); + create_monorepo(dir.path()); + + let analyzer = PythonProjectAnalyzer::new(); + let graph = analyzer.analyze(dir.path()).unwrap(); + + let targets: Vec<&str> = graph.relationships().iter().map(|r| r.target()).collect(); + assert!(!targets.contains(&"fastapi"), "fastapi should be excluded"); + assert!(!targets.contains(&"celery"), "celery should be excluded"); +} + +fn create_poetry_monorepo(dir: &std::path::Path) { + fs::create_dir_all(dir.join("api")).unwrap(); + fs::create_dir_all(dir.join("commons")).unwrap(); + + fs::write( + dir.join("api/pyproject.toml"), + r#" +[tool.poetry] +name = "my-api" + +[tool.poetry.dependencies] +python = "^3.11" +my-commons = {path = "../commons"} +httpx = "^0.27" +"#, + ) + .unwrap(); + + fs::write( + dir.join("commons/pyproject.toml"), + r#" +[tool.poetry] +name = "my-commons" + +[tool.poetry.dependencies] +python = "^3.11" +"#, + ) + .unwrap(); +} + +#[test] +fn extracts_intra_project_dependencies_from_poetry() { + let dir = tempfile::tempdir().unwrap(); + create_poetry_monorepo(dir.path()); + + let analyzer = PythonProjectAnalyzer::new(); + let graph = analyzer.analyze(dir.path()).unwrap(); + + assert_eq!(graph.elements().len(), 2); + let deps: Vec<(&str, &str)> = graph + .relationships() + .iter() + .map(|r| (r.source(), r.target())) + .collect(); + assert!( + deps.contains(&("my-api", "my-commons")), + "missing api->commons: {deps:?}" + ); + + let targets: Vec<&str> = graph.relationships().iter().map(|r| r.target()).collect(); + assert!(!targets.contains(&"httpx"), "httpx should be excluded"); +} diff --git a/crates/adapters/toml-config/src/toml_config_loader.rs b/crates/adapters/toml-config/src/toml_config_loader.rs index e8738ad..3eefa5b 100644 --- a/crates/adapters/toml-config/src/toml_config_loader.rs +++ b/crates/adapters/toml-config/src/toml_config_loader.rs @@ -7,6 +7,14 @@ use archlens_domain::{ AnalysisConfig, DiagramLevel, DomainError, OutputConfig, ports::ConfigLoader, }; +#[derive(Debug, Deserialize, Default)] +struct RawRules { + #[serde(default)] + allow: Vec, + #[serde(default)] + deny: Vec, +} + #[derive(Debug, Deserialize, Default)] struct RawConfig { #[serde(default)] @@ -15,6 +23,8 @@ struct RawConfig { output: RawOutput, #[serde(default)] modules: HashMap, + #[serde(default)] + rules: RawRules, } #[derive(Debug, Deserialize, Default)] @@ -68,6 +78,10 @@ impl ConfigLoader for TomlConfigLoader { Ok(config) } + fn load_rules(&self) -> (Vec, Vec) { + (self.raw.rules.allow.clone(), self.raw.rules.deny.clone()) + } + fn load_output_config(&self) -> Result { let mut config = OutputConfig::default().with_split_by_module(self.raw.output.split_by_module); diff --git a/crates/adapters/toml-config/tests/toml_config_tests.rs b/crates/adapters/toml-config/tests/toml_config_tests.rs index 0015abb..09280da 100644 --- a/crates/adapters/toml-config/tests/toml_config_tests.rs +++ b/crates/adapters/toml-config/tests/toml_config_tests.rs @@ -66,3 +66,27 @@ fn missing_file_returns_defaults() { assert!(!output.split_by_module()); assert!(output.output_path().is_none()); } + +#[test] +fn loads_boundary_rules_from_toml_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("archlens.toml"); + fs::write( + &config_path, + r#" +[rules] +allow = ["Application --> Domain", "Adapters --> Domain"] +deny = ["Domain --> Adapters"] +"#, + ) + .unwrap(); + + let loader = TomlConfigLoader::from_path(&config_path).unwrap(); + let (allow, deny) = loader.load_rules(); + + assert_eq!(allow.len(), 2); + assert_eq!(deny.len(), 1); + assert!(allow.iter().any(|r| r == "Application --> Domain")); + assert!(allow.iter().any(|r| r == "Adapters --> Domain")); + assert!(deny.iter().any(|r| r == "Domain --> Adapters")); +} diff --git a/crates/adapters/tree-sitter/src/python/mod.rs b/crates/adapters/tree-sitter/src/python/mod.rs index 8b9c37f..922c5d6 100644 --- a/crates/adapters/tree-sitter/src/python/mod.rs +++ b/crates/adapters/tree-sitter/src/python/mod.rs @@ -74,10 +74,15 @@ fn collect_classes( let name = &source[name_node.byte_range()]; let line = child.start_position().row + 1; + let methods = child + .child_by_field_name("body") + .map(|body| collect_methods(&body, source)) + .unwrap_or_default(); + match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) { Ok(element) => { type_names.insert(name.to_string()); - elements.push(element); + elements.push(element.with_methods(methods)); } Err(e) => { if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) { @@ -267,6 +272,73 @@ fn collect_imports( } } +fn collect_methods(body: &Node, source: &str) -> Vec { + let mut methods = Vec::new(); + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + if child.kind() != "function_definition" { + continue; + } + let Some(name_node) = child.child_by_field_name("name") else { + continue; + }; + let fn_name = &source[name_node.byte_range()]; + if fn_name.starts_with('_') { + continue; + } + + let params = child + .child_by_field_name("parameters") + .map(|p| extract_python_params(&p, source)) + .unwrap_or_default(); + + let ret = child + .child_by_field_name("return_type") + .map(|n| source[n.byte_range()].trim().to_string()) + .unwrap_or_default(); + + let sig = if ret.is_empty() { + format!("+{fn_name}({params})") + } else { + format!("+{fn_name}({params}) -> {ret}") + }; + methods.push(sig); + } + methods +} + +fn extract_python_params(params_node: &Node, source: &str) -> String { + let mut parts = Vec::new(); + let mut cursor = params_node.walk(); + for param in params_node.children(&mut cursor) { + match param.kind() { + "typed_parameter" => { + if let Some(type_node) = param.child_by_field_name("type") { + // name is the first identifier child (not a named field) + let mut inner = param.walk(); + let name = param + .children(&mut inner) + .find(|c| c.kind() == "identifier") + .map(|c| &source[c.byte_range()]) + .unwrap_or_default(); + if name != "self" && name != "cls" && !name.is_empty() { + let ty = source[type_node.byte_range()].trim(); + parts.push(format!("{name}: {ty}")); + } + } + } + "identifier" => { + let name = &source[param.byte_range()]; + if name != "self" && name != "cls" { + parts.push(name.to_string()); + } + } + _ => {} + } + } + parts.join(", ") +} + fn collect_constructor_params( body: &Node, source: &str, diff --git a/crates/adapters/tree-sitter/src/rust/mod.rs b/crates/adapters/tree-sitter/src/rust/mod.rs index fed838a..42c47df 100644 --- a/crates/adapters/tree-sitter/src/rust/mod.rs +++ b/crates/adapters/tree-sitter/src/rust/mod.rs @@ -270,7 +270,14 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec { } else { "-" }; - methods.push(format!("{vis}{fn_name}()")); + let params = extract_fn_params(&item, source); + let ret = extract_fn_return(&item, source); + let sig = if ret.is_empty() { + format!("{vis}{fn_name}({params})") + } else { + format!("{vis}{fn_name}({params}) -> {ret}") + }; + methods.push(sig); } } } @@ -278,6 +285,38 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec { methods } +fn extract_fn_params(fn_item: &Node, source: &str) -> String { + let Some(params_node) = fn_item.child_by_field_name("parameters") else { + return String::new(); + }; + let mut parts = Vec::new(); + let mut cursor = params_node.walk(); + for param in params_node.children(&mut cursor) { + match param.kind() { + "parameter" => { + if let (Some(pat), Some(ty)) = ( + param.child_by_field_name("pattern"), + param.child_by_field_name("type"), + ) { + let name = &source[pat.byte_range()]; + let ty_text = source[ty.byte_range()].trim(); + parts.push(format!("{name}: {ty_text}")); + } + } + "self_parameter" | "&self" | "self" => {} + _ => {} + } + } + parts.join(", ") +} + +fn extract_fn_return(fn_item: &Node, source: &str) -> String { + fn_item + .child_by_field_name("return_type") + .map(|n| source[n.byte_range()].trim().to_string()) + .unwrap_or_default() +} + fn collect_mod_declarations( node: &Node, source: &str, diff --git a/crates/adapters/tree-sitter/tests/python_analyzer_tests.rs b/crates/adapters/tree-sitter/tests/python_analyzer_tests.rs index 8956064..2db66cf 100644 --- a/crates/adapters/tree-sitter/tests/python_analyzer_tests.rs +++ b/crates/adapters/tree-sitter/tests/python_analyzer_tests.rs @@ -136,3 +136,74 @@ fn extracts_composition_from_class_level_annotations() { assert_eq!(composition[0].source(), "Definition"); assert_eq!(composition[0].target(), "Gad"); } + +#[test] +fn extracts_python_class_methods() { + let source = "class OrderService:\n def process(self):\n pass\n def cancel(self):\n pass\n"; + let result = analyze_python(source, "service.py"); + + let element = result + .elements() + .iter() + .find(|e| e.name() == "OrderService") + .unwrap(); + + assert!( + element.methods().iter().any(|m| m.contains("process")), + "expected 'process' method, got: {:?}", + element.methods() + ); + assert!( + element.methods().iter().any(|m| m.contains("cancel")), + "expected 'cancel' method, got: {:?}", + element.methods() + ); +} + +#[test] +fn extracts_python_method_typed_params() { + let source = "class OrderService:\n def process(self, order: Order, count: int) -> None:\n pass\n"; + let result = analyze_python(source, "service.py"); + + let element = result + .elements() + .iter() + .find(|e| e.name() == "OrderService") + .unwrap(); + + let method = element + .methods() + .iter() + .find(|m| m.contains("process")) + .unwrap(); + assert!( + method.contains("order: Order"), + "missing typed param: {method}" + ); + assert!( + method.contains("count: int"), + "missing typed param: {method}" + ); +} + +#[test] +fn extracts_python_method_return_annotation() { + let source = "class OrderService:\n def get(self) -> Order:\n pass\n"; + let result = analyze_python(source, "service.py"); + + let element = result + .elements() + .iter() + .find(|e| e.name() == "OrderService") + .unwrap(); + + let method = element + .methods() + .iter() + .find(|m| m.contains("get")) + .unwrap(); + assert!( + method.contains("-> Order"), + "expected return type, got: {method}" + ); +} diff --git a/crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs b/crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs index f941671..8522ed3 100644 --- a/crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs +++ b/crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs @@ -128,3 +128,145 @@ fn extracts_mod_declarations() { assert!(imports.iter().any(|r| r.target() == "crate::models")); assert!(imports.iter().any(|r| r.target() == "crate::services")); } + +#[test] +fn extracts_rust_method_with_typed_params() { + let source = r#" +pub struct OrderService; +impl OrderService { + pub fn process(&self, order: Order, count: u64) {} +} +"#; + let result = analyze_rust(source, "service.rs"); + + let element = result + .elements() + .iter() + .find(|e| e.name() == "OrderService") + .unwrap(); + + assert!( + element + .methods() + .iter() + .any(|m| m.contains("order: Order") && m.contains("count: u64")), + "expected typed params in method, got: {:?}", + element.methods() + ); +} + +#[test] +fn extracts_rust_method_return_type() { + let source = r#" +pub struct OrderService; +impl OrderService { + pub fn get(&self) -> Order {} +} +"#; + let result = analyze_rust(source, "service.rs"); + + let element = result + .elements() + .iter() + .find(|e| e.name() == "OrderService") + .unwrap(); + + assert!( + element.methods().iter().any(|m| m.contains("-> Order")), + "expected return type in method, got: {:?}", + element.methods() + ); +} + +#[test] +fn extracts_rust_method_params_and_return() { + let source = r#" +pub struct OrderService; +impl OrderService { + pub fn process(&self, order: Order) -> Result<(), Error> {} +} +"#; + let result = analyze_rust(source, "service.rs"); + + let element = result + .elements() + .iter() + .find(|e| e.name() == "OrderService") + .unwrap(); + + let method = element + .methods() + .iter() + .find(|m| m.contains("process")) + .unwrap(); + assert!(method.contains("order: Order"), "missing param: {method}"); + assert!(method.contains("->"), "missing return arrow: {method}"); +} + +#[test] +fn extracts_rust_static_method_params() { + let source = r#" +pub struct Finder; +impl Finder { + pub fn detect(path: &str, count: usize) -> bool { false } +} +"#; + let result = analyze_rust(source, "finder.rs"); + let element = result + .elements() + .iter() + .find(|e| e.name() == "Finder") + .unwrap(); + let method = element + .methods() + .iter() + .find(|m| m.contains("detect")) + .unwrap(); + assert!(method.contains("path"), "missing path param: {method}"); + assert!(method.contains("count"), "missing count param: {method}"); +} + +#[test] +fn extracts_rust_private_method_params() { + let source = r#" +pub struct WalkdirDiscovery; +impl WalkdirDiscovery { + fn detect_language(path: &std::path::Path) -> Option { None } +} +"#; + let result = analyze_rust(source, "discovery.rs"); + let element = result + .elements() + .iter() + .find(|e| e.name() == "WalkdirDiscovery") + .unwrap(); + let method = element + .methods() + .iter() + .find(|m| m.contains("detect_language")) + .unwrap(); + assert!(method.contains("path"), "missing path param: {method}"); +} + +#[test] +fn extracts_rust_method_reference_param() { + let source = r#" +use std::path::Path; +pub struct WalkdirDiscovery; +impl WalkdirDiscovery { + fn detect_language(path: &Path) -> Option { None } +} +"#; + let result = analyze_rust(source, "discovery.rs"); + let element = result + .elements() + .iter() + .find(|e| e.name() == "WalkdirDiscovery") + .unwrap(); + let method = element + .methods() + .iter() + .find(|m| m.contains("detect_language")) + .unwrap(); + assert!(method.contains("path"), "missing path param: {method}"); +} diff --git a/crates/adapters/walkdir/src/walkdir_discovery.rs b/crates/adapters/walkdir/src/walkdir_discovery.rs index 658536b..b26700a 100644 --- a/crates/adapters/walkdir/src/walkdir_discovery.rs +++ b/crates/adapters/walkdir/src/walkdir_discovery.rs @@ -42,6 +42,27 @@ impl WalkdirDiscovery { } } + fn is_test_file(path: &Path, language: Language) -> bool { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + let in_tests_dir = path + .parent() + .map(|p| p.components().any(|c| c.as_os_str() == "tests")) + .unwrap_or(false); + + if in_tests_dir { + return true; + } + + match language { + Language::Rust => stem.ends_with("_test") || stem.ends_with("_tests"), + Language::Python => stem.starts_with("test_") || stem.ends_with("_test"), + Language::CSharp => stem.ends_with("Tests") || stem.ends_with("Test"), + } + } + fn is_excluded(path: &Path, root: &Path, excludes: &[String]) -> bool { let relative = path.strip_prefix(root).unwrap_or(path); let relative_str = relative.to_string_lossy(); @@ -88,6 +109,18 @@ impl FileDiscovery for WalkdirDiscovery { } if let Some(language) = Self::detect_language(path) { + if !config.include_tests() && Self::is_test_file(path, language) { + continue; + } + if let Some(changed) = config.changed_files() { + let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy(); + if !changed + .iter() + .any(|c| relative.ends_with(c.as_str()) || c.ends_with(relative.as_ref())) + { + continue; + } + } let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let file_path = FilePath::new(&absolute.to_string_lossy()) .map_err(|e| DomainError::IoError(e.to_string()))?; diff --git a/crates/adapters/walkdir/tests/walkdir_discovery_tests.rs b/crates/adapters/walkdir/tests/walkdir_discovery_tests.rs index b00a798..295cf2c 100644 --- a/crates/adapters/walkdir/tests/walkdir_discovery_tests.rs +++ b/crates/adapters/walkdir/tests/walkdir_discovery_tests.rs @@ -58,6 +58,100 @@ fn respects_exclude_patterns() { assert!(!files.iter().any(|f| f.path().as_str().contains("billing"))); } +#[test] +fn excludes_python_test_prefix_files_by_default() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap(); + fs::write(dir.path().join("test_orders.py"), "class TestOrder: pass").unwrap(); + + let discovery = WalkdirDiscovery::new(); + let files = discovery + .discover(dir.path(), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(files.len(), 1); + assert!(files[0].path().as_str().ends_with("orders.py")); +} + +#[test] +fn excludes_python_test_suffix_files_by_default() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap(); + fs::write(dir.path().join("orders_test.py"), "class OrderTest: pass").unwrap(); + + let discovery = WalkdirDiscovery::new(); + let files = discovery + .discover(dir.path(), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(files.len(), 1); + assert!(files[0].path().as_str().ends_with("orders.py")); +} + +#[test] +fn excludes_files_in_tests_directory_by_default() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("tests")).unwrap(); + fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap(); + fs::write(dir.path().join("tests/helpers.py"), "class Helper: pass").unwrap(); + + let discovery = WalkdirDiscovery::new(); + let files = discovery + .discover(dir.path(), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(files.len(), 1); + assert!(files[0].path().as_str().ends_with("orders.py")); +} + +#[test] +fn excludes_rust_test_files_by_default() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("orders.rs"), "struct Order;").unwrap(); + fs::write(dir.path().join("orders_tests.rs"), "struct OrdersTests;").unwrap(); + + let discovery = WalkdirDiscovery::new(); + let files = discovery + .discover(dir.path(), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(files.len(), 1); + assert!(files[0].path().as_str().ends_with("orders.rs")); +} + +#[test] +fn excludes_rust_files_in_tests_directory_by_default() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("tests")).unwrap(); + fs::write(dir.path().join("lib.rs"), "struct Lib;").unwrap(); + fs::write( + dir.path().join("tests/integration.rs"), + "struct IntegrationTest;", + ) + .unwrap(); + + let discovery = WalkdirDiscovery::new(); + let files = discovery + .discover(dir.path(), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(files.len(), 1); + assert!(files[0].path().as_str().ends_with("lib.rs")); +} + +#[test] +fn include_tests_flag_re_enables_test_files() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("orders.py"), "class Order: pass").unwrap(); + fs::write(dir.path().join("test_orders.py"), "class TestOrder: pass").unwrap(); + + let config = AnalysisConfig::default().with_include_tests(true); + let discovery = WalkdirDiscovery::new(); + let files = discovery.discover(dir.path(), &config).unwrap(); + + assert_eq!(files.len(), 2); +} + #[test] fn empty_directory_returns_no_files() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/application/src/queries/analyze_codebase.rs b/crates/application/src/queries/analyze_codebase.rs index ef39657..cfde853 100644 --- a/crates/application/src/queries/analyze_codebase.rs +++ b/crates/application/src/queries/analyze_codebase.rs @@ -95,6 +95,7 @@ where .collect(); let graph = graph + .qualify() .resolve_relationships() .filter_external_imports(&known_dirs); diff --git a/crates/domain/src/aggregates/code_graph.rs b/crates/domain/src/aggregates/code_graph.rs index 7c7e5cc..d34d6fb 100644 --- a/crates/domain/src/aggregates/code_graph.rs +++ b/crates/domain/src/aggregates/code_graph.rs @@ -72,20 +72,11 @@ impl CodeGraph { } pub fn resolve_relationships(self) -> CodeGraph { - let mut file_types: HashMap> = HashMap::new(); - let mut name_modules: HashMap<&str, HashSet>> = HashMap::new(); - let all_type_names: HashSet<&str> = self.elements.iter().map(|e| e.name()).collect(); + let qualified_names: HashSet<&str> = + self.elements.iter().map(|e| e.qualified_name()).collect(); - for element in &self.elements { - file_types - .entry(element.file_path().as_str().to_string()) - .or_default() - .insert(element.name().to_string()); - name_modules - .entry(element.name()) - .or_default() - .insert(element.module().map(|m| m.as_str())); - } + // Also keep bare name lookup for import relationships and unqualified fallback + let all_bare_names: HashSet<&str> = self.elements.iter().map(|e| e.name()).collect(); let mut resolved = CodeGraph::new(); for element in &self.elements { @@ -97,24 +88,11 @@ impl CodeGraph { resolved.add_relationship(rel.clone()); } _ => { - if !all_type_names.contains(rel.source()) - || !all_type_names.contains(rel.target()) - { - continue; - } - - if let Some(src_file) = rel.source_file() { - let file_key = src_file.as_str().to_string(); - if let Some(types_in_file) = file_types.get(&file_key) - && types_in_file.contains(rel.target()) - { - resolved.add_relationship(rel.clone()); - continue; - } - } - - let tgt_modules = &name_modules[rel.target()]; - if tgt_modules.len() == 1 { + let src_ok = qualified_names.contains(rel.source()) + || all_bare_names.contains(rel.source()); + let tgt_ok = qualified_names.contains(rel.target()) + || all_bare_names.contains(rel.target()); + if src_ok && tgt_ok { resolved.add_relationship(rel.clone()); } } @@ -152,6 +130,129 @@ impl CodeGraph { filtered } + pub fn qualify(self) -> CodeGraph { + // Build lookup: bare name -> Vec (for disambiguation) + let mut name_to_qualified: HashMap<&str, Vec> = HashMap::new(); + for element in &self.elements { + let qn = match element.module() { + Some(m) => format!("{}::{}", m.as_str(), element.name()), + None => element.name().to_string(), + }; + name_to_qualified + .entry(element.name()) + .or_default() + .push(qn); + } + + // Build lookup: file_path -> qualified source names in that file + let mut file_to_qualified: HashMap<&str, Vec> = HashMap::new(); + for element in &self.elements { + let qn = match element.module() { + Some(m) => format!("{}::{}", m.as_str(), element.name()), + None => element.name().to_string(), + }; + file_to_qualified + .entry(element.file_path().as_str()) + .or_default() + .push(qn); + } + + let mut qualified = CodeGraph::new(); + + // 1. Qualify element names + for element in &self.elements { + let qn = match element.module() { + Some(m) => format!("{}::{}", m.as_str(), element.name()), + None => element.name().to_string(), + }; + qualified.add_element(element.clone().with_qualified_name(qn)); + } + + // 2. Rewrite relationship source/target + for rel in &self.relationships { + // Qualify source: find the qualified name of the source in its file + let src_qualified = rel + .source_file() + .and_then(|f| file_to_qualified.get(f.as_str())) + .and_then(|qns| { + qns.iter().find(|qn| { + qn.ends_with(&format!("::{}", rel.source())) || *qn == rel.source() + }) + }) + .cloned() + .unwrap_or_else(|| { + // Fall back: unambiguous lookup + name_to_qualified + .get(rel.source()) + .filter(|v| v.len() == 1) + .and_then(|v| v.first()) + .cloned() + .unwrap_or_else(|| rel.source().to_string()) + }); + + // Qualify target: prefer same module as source when ambiguous + let src_module = src_qualified.split("::").next().unwrap_or(""); + let tgt_qualified = match name_to_qualified.get(rel.target()) { + Some(candidates) if candidates.len() == 1 => candidates[0].clone(), + Some(candidates) => { + // Prefer same module as source + candidates + .iter() + .find(|qn| qn.starts_with(&format!("{}::", src_module))) + .cloned() + .unwrap_or_else(|| rel.target().to_string()) + } + None => rel.target().to_string(), + }; + + let new_rel = Relationship::new(&src_qualified, &tgt_qualified, rel.kind()) + .unwrap_or_else(|_| rel.clone()); + let new_rel = if let Some(f) = rel.source_file() { + new_rel.with_source_file(f.clone()) + } else { + new_rel + }; + qualified.add_relationship(new_rel); + } + + qualified + } + + pub fn cross_module_deps_for(&self, module: &ModuleName) -> Vec<(ModuleName, usize)> { + let module_element_qnames: HashSet<&str> = self + .elements + .iter() + .filter(|e| e.module().is_some_and(|m| m == module)) + .map(|e| e.qualified_name()) + .collect(); + + let target_module_of: HashMap<&str, Option<&ModuleName>> = self + .elements + .iter() + .map(|e| (e.qualified_name(), e.module())) + .collect(); + + let mut counts: HashMap<&str, usize> = HashMap::new(); + for rel in &self.relationships { + if !module_element_qnames.contains(rel.source()) { + continue; + } + if module_element_qnames.contains(rel.target()) { + continue; + } + if let Some(Some(target_mod)) = target_module_of.get(rel.target()) { + *counts.entry(target_mod.as_str()).or_insert(0) += 1; + } + } + + let mut result: Vec<(ModuleName, usize)> = counts + .into_iter() + .filter_map(|(name, count)| ModuleName::new(name).ok().map(|m| (m, count))) + .collect(); + result.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str())); + result + } + pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph { let filtered_elements: Vec = self .elements @@ -160,12 +261,15 @@ impl CodeGraph { .cloned() .collect(); - let element_names: HashSet<&str> = filtered_elements.iter().map(|e| e.name()).collect(); + let element_qnames: HashSet<&str> = filtered_elements + .iter() + .map(|e| e.qualified_name()) + .collect(); let filtered_relationships: Vec = self .relationships .iter() - .filter(|r| element_names.contains(r.source()) && element_names.contains(r.target())) + .filter(|r| element_qnames.contains(r.source()) && element_qnames.contains(r.target())) .cloned() .collect(); diff --git a/crates/domain/src/entities/code_element.rs b/crates/domain/src/entities/code_element.rs index abfae2a..c86a894 100644 --- a/crates/domain/src/entities/code_element.rs +++ b/crates/domain/src/entities/code_element.rs @@ -3,6 +3,7 @@ use crate::{CodeElementKind, DomainError, FilePath, ModuleName, Visibility}; #[derive(Debug, Clone)] pub struct CodeElement { name: String, + qualified_name: Option, kind: CodeElementKind, file_path: FilePath, line: usize, @@ -27,6 +28,7 @@ impl CodeElement { } Ok(Self { name: trimmed.to_string(), + qualified_name: None, kind, file_path, line, @@ -59,10 +61,19 @@ impl CodeElement { self } + pub fn with_qualified_name(mut self, qn: String) -> Self { + self.qualified_name = Some(qn); + self + } + pub fn name(&self) -> &str { &self.name } + pub fn qualified_name(&self) -> &str { + self.qualified_name.as_deref().unwrap_or(&self.name) + } + pub fn kind(&self) -> CodeElementKind { self.kind } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 2f74a34..13681e2 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -11,4 +11,5 @@ pub use error::DomainError; pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning}; pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility}; pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile}; +pub use value_objects::rules::{BoundaryRule, RuleKind, RuleViolation, check_boundary_rules}; pub use value_objects::source::{FilePath, Language, ModuleName, SourceFile}; diff --git a/crates/domain/src/ports/config_loader.rs b/crates/domain/src/ports/config_loader.rs index 55820b8..fa45f7c 100644 --- a/crates/domain/src/ports/config_loader.rs +++ b/crates/domain/src/ports/config_loader.rs @@ -3,4 +3,8 @@ use crate::{AnalysisConfig, DomainError, OutputConfig}; pub trait ConfigLoader { fn load_analysis_config(&self) -> Result; fn load_output_config(&self) -> Result; + + fn load_rules(&self) -> (Vec, Vec) { + (Vec::new(), Vec::new()) + } } diff --git a/crates/domain/src/ports/diagram_renderer.rs b/crates/domain/src/ports/diagram_renderer.rs index 2df6dec..fc13d9a 100644 --- a/crates/domain/src/ports/diagram_renderer.rs +++ b/crates/domain/src/ports/diagram_renderer.rs @@ -1,5 +1,15 @@ -use crate::{CodeGraph, DomainError, RenderOutput}; +use crate::{CodeGraph, DomainError, ModuleName, RenderOutput}; pub trait DiagramRenderer { fn render(&self, graph: &CodeGraph) -> Result; + + fn append_cross_module_deps( + &self, + content: &str, + module: &ModuleName, + deps: &[(ModuleName, usize)], + ) -> String { + let _ = (module, deps); + content.to_string() + } } diff --git a/crates/domain/src/value_objects/analysis/analysis_config.rs b/crates/domain/src/value_objects/analysis/analysis_config.rs index 9d35769..a78569a 100644 --- a/crates/domain/src/value_objects/analysis/analysis_config.rs +++ b/crates/domain/src/value_objects/analysis/analysis_config.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crate::DiagramLevel; @@ -8,6 +8,8 @@ pub struct AnalysisConfig { level: DiagramLevel, module_mappings: HashMap, scope: Option, + include_tests: bool, + changed_files: Option>, } impl AnalysisConfig { @@ -46,6 +48,24 @@ impl AnalysisConfig { pub fn scope(&self) -> Option<&str> { self.scope.as_deref() } + + pub fn with_include_tests(mut self, include: bool) -> Self { + self.include_tests = include; + self + } + + pub fn include_tests(&self) -> bool { + self.include_tests + } + + pub fn with_changed_files(mut self, files: HashSet) -> Self { + self.changed_files = Some(files); + self + } + + pub fn changed_files(&self) -> Option<&HashSet> { + self.changed_files.as_ref() + } } impl Default for AnalysisConfig { @@ -55,6 +75,8 @@ impl Default for AnalysisConfig { level: DiagramLevel::Module, module_mappings: HashMap::new(), scope: None, + include_tests: false, + changed_files: None, } } } diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs index 3d3cc00..2e2d9e0 100644 --- a/crates/domain/src/value_objects/mod.rs +++ b/crates/domain/src/value_objects/mod.rs @@ -1,4 +1,5 @@ pub mod analysis; pub mod graph; pub mod output; +pub mod rules; pub mod source; diff --git a/crates/domain/src/value_objects/rules/boundary_rule.rs b/crates/domain/src/value_objects/rules/boundary_rule.rs new file mode 100644 index 0000000..4153ec6 --- /dev/null +++ b/crates/domain/src/value_objects/rules/boundary_rule.rs @@ -0,0 +1,28 @@ +pub struct BoundaryRule { + source: String, + target: String, +} + +impl BoundaryRule { + pub fn parse(s: &str) -> Option { + let (src, tgt) = s.split_once("-->")?; + let source = src.trim().to_string(); + let target = tgt.trim().to_string(); + if source.is_empty() || target.is_empty() { + return None; + } + Some(Self { source, target }) + } + + pub fn source(&self) -> &str { + &self.source + } + + pub fn target(&self) -> &str { + &self.target + } + + pub fn matches(&self, src_module: &str, tgt_module: &str) -> bool { + self.source == src_module && self.target == tgt_module + } +} diff --git a/crates/domain/src/value_objects/rules/mod.rs b/crates/domain/src/value_objects/rules/mod.rs new file mode 100644 index 0000000..b00cde5 --- /dev/null +++ b/crates/domain/src/value_objects/rules/mod.rs @@ -0,0 +1,52 @@ +mod boundary_rule; +mod rule_violation; + +pub use boundary_rule::BoundaryRule; +pub use rule_violation::{RuleKind, RuleViolation}; + +use std::collections::HashSet; + +use crate::{CodeGraph, RelationshipKind}; + +pub fn check_boundary_rules( + graph: &CodeGraph, + allow: &[BoundaryRule], + deny: &[BoundaryRule], +) -> Vec { + if allow.is_empty() && deny.is_empty() { + return Vec::new(); + } + + // Build qualified-name → module lookup + let qname_to_module: std::collections::HashMap<&str, &str> = graph + .elements() + .iter() + .filter_map(|e| e.module().map(|m| (e.qualified_name(), m.as_str()))) + .collect(); + + // Collect unique cross-module edges + let mut edges: HashSet<(String, String)> = HashSet::new(); + for rel in graph.relationships() { + if rel.kind() == RelationshipKind::Import { + continue; + } + let src_mod = qname_to_module.get(rel.source()).copied(); + let tgt_mod = qname_to_module.get(rel.target()).copied(); + if let (Some(s), Some(t)) = (src_mod, tgt_mod) + && s != t + { + edges.insert((s.to_string(), t.to_string())); + } + } + + let mut violations = Vec::new(); + for (src, tgt) in &edges { + if deny.iter().any(|r| r.matches(src, tgt)) { + violations.push(RuleViolation::new(src, tgt, RuleKind::Denied)); + } else if !allow.is_empty() && !allow.iter().any(|r| r.matches(src, tgt)) { + violations.push(RuleViolation::new(src, tgt, RuleKind::NotAllowed)); + } + } + + violations +} diff --git a/crates/domain/src/value_objects/rules/rule_violation.rs b/crates/domain/src/value_objects/rules/rule_violation.rs new file mode 100644 index 0000000..202f521 --- /dev/null +++ b/crates/domain/src/value_objects/rules/rule_violation.rs @@ -0,0 +1,45 @@ +pub enum RuleKind { + Denied, + NotAllowed, +} + +pub struct RuleViolation { + source_module: String, + target_module: String, + kind: RuleKind, +} + +impl RuleViolation { + pub fn new(source_module: &str, target_module: &str, kind: RuleKind) -> Self { + Self { + source_module: source_module.to_string(), + target_module: target_module.to_string(), + kind, + } + } + + pub fn source_module(&self) -> &str { + &self.source_module + } + + pub fn target_module(&self) -> &str { + &self.target_module + } + + pub fn kind(&self) -> &RuleKind { + &self.kind + } + + pub fn message(&self) -> String { + match self.kind { + RuleKind::Denied => format!( + "Denied dependency: {} --> {}", + self.source_module, self.target_module + ), + RuleKind::NotAllowed => format!( + "Dependency not in allow list: {} --> {}", + self.source_module, self.target_module + ), + } + } +} diff --git a/crates/domain/tests/boundary_rule_tests.rs b/crates/domain/tests/boundary_rule_tests.rs new file mode 100644 index 0000000..6dae49a --- /dev/null +++ b/crates/domain/tests/boundary_rule_tests.rs @@ -0,0 +1,80 @@ +use archlens_domain::{ + BoundaryRule, CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, Relationship, + RelationshipKind, RuleViolation, check_boundary_rules, +}; + +fn make_element(name: &str, module: &str) -> CodeElement { + CodeElement::new( + name, + CodeElementKind::Class, + FilePath::new(&format!("src/{name}.rs")).unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new(module).unwrap()) +} + +fn graph_with_edge( + src_name: &str, + src_module: &str, + tgt_name: &str, + tgt_module: &str, +) -> CodeGraph { + let mut graph = CodeGraph::new(); + graph.add_element(make_element(src_name, src_module)); + graph.add_element(make_element(tgt_name, tgt_module)); + graph.add_relationship( + Relationship::new(src_name, tgt_name, RelationshipKind::Composition).unwrap(), + ); + graph.qualify() +} + +#[test] +fn boundary_rule_parses_source_and_target() { + let rule = BoundaryRule::parse("Application --> Domain").unwrap(); + assert_eq!(rule.source(), "Application"); + assert_eq!(rule.target(), "Domain"); +} + +#[test] +fn check_returns_denied_violation_when_deny_rule_matches_edge() { + let graph = graph_with_edge("Service", "Domain", "Adapter", "Adapters"); + + let deny = vec![BoundaryRule::parse("Domain --> Adapters").unwrap()]; + let violations = check_boundary_rules(&graph, &[], &deny); + + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].source_module(), "Domain"); + assert_eq!(violations[0].target_module(), "Adapters"); + assert!(matches!( + violations[0].kind(), + archlens_domain::RuleKind::Denied + )); +} + +#[test] +fn check_returns_no_violation_when_edge_matches_allow_rule() { + let graph = graph_with_edge("Service", "Application", "Order", "Domain"); + + let allow = vec![BoundaryRule::parse("Application --> Domain").unwrap()]; + let violations = check_boundary_rules(&graph, &allow, &[]); + + assert!( + violations.is_empty(), + "expected no violations, got: {}", + violations.len() + ); +} + +#[test] +fn check_returns_not_allowed_when_edge_absent_from_allow_list() { + let graph = graph_with_edge("Repo", "Adapters", "Order", "Domain"); + + // Only Application --> Domain is allowed; Adapters --> Domain is not in the list + let allow = vec![BoundaryRule::parse("Application --> Domain").unwrap()]; + let violations = check_boundary_rules(&graph, &allow, &[]); + + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].source_module(), "Adapters"); + assert_eq!(violations[0].target_module(), "Domain"); +} diff --git a/crates/domain/tests/code_graph_tests.rs b/crates/domain/tests/code_graph_tests.rs index 64cc35c..5480264 100644 --- a/crates/domain/tests/code_graph_tests.rs +++ b/crates/domain/tests/code_graph_tests.rs @@ -115,6 +115,180 @@ fn subgraph_of_nonexistent_module_is_empty() { assert!(subgraph.relationships().is_empty()); } +#[test] +fn qualify_sets_qualified_name_on_elements_with_modules() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("DtoBaseModel", Some("Commons"))); + graph.add_element(make_element("DtoBaseModel", Some("Api"))); + graph.add_element(make_element("Orphan", None)); + + let graph = graph.qualify(); + + let commons_dto = graph + .elements() + .iter() + .find(|e| e.module().map(|m| m.as_str()) == Some("Commons")) + .unwrap(); + assert_eq!(commons_dto.qualified_name(), "Commons::DtoBaseModel"); + + let api_dto = graph + .elements() + .iter() + .find(|e| e.module().map(|m| m.as_str()) == Some("Api")) + .unwrap(); + assert_eq!(api_dto.qualified_name(), "Api::DtoBaseModel"); + + let orphan = graph + .elements() + .iter() + .find(|e| e.name() == "Orphan") + .unwrap(); + assert_eq!(orphan.qualified_name(), "Orphan"); +} + +#[test] +fn qualify_rewrites_unambiguous_relationship_target() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", Some("App"))); + graph.add_element(make_element("Order", Some("Domain"))); + graph.add_relationship( + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(), + ); + + let graph = graph.qualify(); + + let rel = &graph.relationships()[0]; + assert_eq!(rel.source(), "App::OrderService"); + assert_eq!(rel.target(), "Domain::Order"); +} + +#[test] +fn qualify_disambiguates_target_by_source_module() { + let mut graph = CodeGraph::new(); + // DtoBaseModel exists in both Commons and Api + graph.add_element(make_element("DtoBaseModel", Some("Commons"))); + graph.add_element(make_element("DtoBaseModel", Some("Api"))); + // GlobalAudienceDefinition inherits DtoBaseModel, and is in Commons + graph.add_element(make_element("GlobalAudienceDefinition", Some("Commons"))); + + let mut rel = Relationship::new( + "GlobalAudienceDefinition", + "DtoBaseModel", + RelationshipKind::Inheritance, + ) + .unwrap(); + // source_file is in the Commons module path + rel = rel.with_source_file( + archlens_domain::FilePath::new("src/commons/global_audience.rs").unwrap(), + ); + // Make GlobalAudienceDefinition's element file match + let mut gad = make_element("GlobalAudienceDefinition", Some("Commons")); + // rebuild with matching file_path + gad = CodeElement::new( + "GlobalAudienceDefinition", + archlens_domain::CodeElementKind::Class, + archlens_domain::FilePath::new("src/commons/global_audience.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(archlens_domain::ModuleName::new("Commons").unwrap()); + + let mut graph = CodeGraph::new(); + graph.add_element(make_element("DtoBaseModel", Some("Commons"))); + graph.add_element(make_element("DtoBaseModel", Some("Api"))); + graph.add_element(gad); + graph.add_relationship(rel); + + let graph = graph.qualify(); + + let rel = &graph.relationships()[0]; + assert_eq!(rel.source(), "Commons::GlobalAudienceDefinition"); + assert_eq!(rel.target(), "Commons::DtoBaseModel"); +} + +#[test] +fn resolve_preserves_relationship_when_both_qualified_names_exist() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("GlobalAudienceDefinition", Some("Commons"))); + graph.add_element(make_element("DtoBaseModel", Some("Commons"))); + graph.add_element(make_element("DtoBaseModel", Some("Api"))); + + graph.add_relationship( + Relationship::new( + "GlobalAudienceDefinition", + "DtoBaseModel", + RelationshipKind::Inheritance, + ) + .unwrap(), + ); + + let graph = graph.qualify().resolve_relationships(); + + // The relationship should survive — Commons::GlobalAudienceDefinition --> Commons::DtoBaseModel + assert_eq!(graph.relationships().len(), 1); + assert_eq!( + graph.relationships()[0].source(), + "Commons::GlobalAudienceDefinition" + ); + assert_eq!(graph.relationships()[0].target(), "Commons::DtoBaseModel"); +} + +#[test] +fn cross_module_deps_for_returns_target_module_with_count() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("WidgetJobData", Some("aiss_worker"))); + graph.add_element(make_element("WidgetType", Some("commons"))); + + graph.add_relationship( + Relationship::new("WidgetJobData", "WidgetType", RelationshipKind::Composition).unwrap(), + ); + + let module = ModuleName::new("aiss_worker").unwrap(); + let deps = graph.cross_module_deps_for(&module); + + assert_eq!(deps.len(), 1); + assert_eq!(deps[0].0.as_str(), "commons"); + assert_eq!(deps[0].1, 1); +} + +#[test] +fn cross_module_deps_for_returns_empty_for_intra_module_only() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", Some("Orders"))); + graph.add_element(make_element("Order", Some("Orders"))); + graph.add_relationship( + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(), + ); + + let module = ModuleName::new("Orders").unwrap(); + let deps = graph.cross_module_deps_for(&module); + + assert!(deps.is_empty()); +} + +#[test] +fn cross_module_deps_for_aggregates_multiple_relationships_to_same_module() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("ServiceA", Some("app"))); + graph.add_element(make_element("ServiceB", Some("app"))); + graph.add_element(make_element("DomainType1", Some("domain"))); + graph.add_element(make_element("DomainType2", Some("domain"))); + + graph.add_relationship( + Relationship::new("ServiceA", "DomainType1", RelationshipKind::Composition).unwrap(), + ); + graph.add_relationship( + Relationship::new("ServiceB", "DomainType2", RelationshipKind::Composition).unwrap(), + ); + + let module = ModuleName::new("app").unwrap(); + let deps = graph.cross_module_deps_for(&module); + + assert_eq!(deps.len(), 1); + assert_eq!(deps[0].0.as_str(), "domain"); + assert_eq!(deps[0].1, 2); +} + #[test] fn graph_lists_unique_modules() { let mut graph = CodeGraph::new(); diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index df01a81..d06d57c 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -19,7 +19,11 @@ archlens-file-writer.workspace = true archlens-stdout-writer.workspace = true archlens-toml-config.workspace = true archlens-cargo-workspace.workspace = true +archlens-python-project.workspace = true +archlens-d2.workspace = true +archlens-html.workspace = true anyhow.workspace = true +notify.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/presentation/src/cli.rs b/crates/presentation/src/cli.rs index 0b56f99..ca8d4b2 100644 --- a/crates/presentation/src/cli.rs +++ b/crates/presentation/src/cli.rs @@ -32,6 +32,18 @@ pub struct Cli { #[arg(long)] pub exclude: Vec, + #[arg(long)] + pub include_tests: bool, + + #[arg(long)] + pub no_weights: bool, + + #[arg(long)] + pub watch: bool, + + #[arg(long, value_name = "REF")] + pub since: Option, + #[arg(long)] pub split_by_module: bool, diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index b52e214..22bd390 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -7,12 +7,15 @@ use anyhow::{Result, bail}; use archlens_application::queries::AnalyzeCodebase; use archlens_ascii::AsciiRenderer; use archlens_cargo_workspace::CargoWorkspaceAnalyzer; +use archlens_d2::D2Renderer; use archlens_domain::{ - CodeGraph, DiagramLevel, ModuleName, + BoundaryRule, CodeGraph, DiagramLevel, ModuleName, check_boundary_rules, ports::{ConfigLoader, OutputWriter, ProjectAnalyzer}, }; use archlens_file_writer::FileOutputWriter; +use archlens_html::HtmlRenderer; use archlens_mermaid::MermaidRenderer; +use archlens_python_project::PythonProjectAnalyzer; use archlens_stdout_writer::StdoutOutputWriter; use archlens_toml_config::TomlConfigLoader; use archlens_tree_sitter::TreeSitterAnalyzer; @@ -30,15 +33,43 @@ pub fn run(args: Cli) -> Result<()> { } init_tracing(args.verbose); + if args.watch { + return run_watch(args); + } + let level = parse_level(&args.level); + let config_loader = load_config(&args)?; let graph = build_graph(&args, level)?; - let renderer = create_renderer(&args.format, level)?; + let renderer = create_renderer(&args.format, level, !args.no_weights)?; let ext = format_extension(&args.format); if args.check { return check_freshness(&args.output, &graph, &*renderer); } + // Boundary rule checking + let (raw_allow, raw_deny) = config_loader.load_rules(); + let allow: Vec = raw_allow + .iter() + .filter_map(|s| BoundaryRule::parse(s)) + .collect(); + let deny: Vec = raw_deny + .iter() + .filter_map(|s| BoundaryRule::parse(s)) + .collect(); + if !allow.is_empty() || !deny.is_empty() { + let violations = check_boundary_rules(&graph, &allow, &deny); + for v in &violations { + eprintln!("RULE VIOLATION: {}", v.message()); + } + if args.strict && !violations.is_empty() { + bail!( + "{} boundary rule violation(s) in strict mode", + violations.len() + ); + } + } + if args.split_by_module { write_split(&graph, &*renderer, &args.output, ext)?; } else { @@ -75,9 +106,20 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result { excludes.extend(args.exclude.iter().cloned()); analysis_config = analysis_config.with_excludes(excludes); } + if args.include_tests { + analysis_config = analysis_config.with_include_tests(true); + } + if let Some(ref git_ref) = args.since { + let changed = get_changed_files(&args.path, git_ref)?; + analysis_config = analysis_config.with_changed_files(changed); + } if level == DiagramLevel::Project { - return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?); + let cargo_toml = args.path.join("Cargo.toml"); + if cargo_toml.exists() { + return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?); + } + return Ok(PythonProjectAnalyzer::new().analyze(&args.path)?); } let discovery = WalkdirDiscovery::new(); @@ -106,10 +148,13 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result { if level == DiagramLevel::Module { let workspace_toml = args.path.join("Cargo.toml"); - if workspace_toml.exists() - && let Ok(project_graph) = CargoWorkspaceAnalyzer::new().analyze(&args.path) - { - merge_project_deps_as_module_edges(&mut graph, &project_graph); + let project_graph = if workspace_toml.exists() { + CargoWorkspaceAnalyzer::new().analyze(&args.path).ok() + } else { + PythonProjectAnalyzer::new().analyze(&args.path).ok() + }; + if let Some(pg) = project_graph { + merge_project_deps_as_module_edges(&mut graph, &pg); } } @@ -119,10 +164,15 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result { fn create_renderer( format: &str, level: DiagramLevel, + show_weights: bool, ) -> Result> { match format { - "mermaid" => Ok(Box::new(MermaidRenderer::with_level(level))), + "mermaid" => Ok(Box::new( + MermaidRenderer::with_level(level).with_weights(show_weights), + )), "ascii" => Ok(Box::new(AsciiRenderer::new())), + "d2" => Ok(Box::new(D2Renderer::with_level(level))), + "html" => Ok(Box::new(HtmlRenderer::new())), fmt => bail!("unknown format: {fmt}"), } } @@ -130,6 +180,8 @@ fn create_renderer( fn format_extension(format: &str) -> &str { match format { "mermaid" => "mmd", + "d2" => "d2", + "html" => "html", _ => "txt", } } @@ -174,14 +226,17 @@ fn write_split( for module in graph.modules() { let subgraph = graph.subgraph_by_module(&module); + let cross_deps = graph.cross_module_deps_for(&module); let module_output = renderer.render(&subgraph)?; + let raw = module_output + .files() + .first() + .map(|f| f.content()) + .unwrap_or(""); + let content = renderer.append_cross_module_deps(raw, &module, &cross_deps); let module_file = archlens_domain::RenderedFile::new( &format!("{}.{ext}", module.as_str().to_lowercase()), - module_output - .files() - .first() - .map(|f| f.content()) - .unwrap_or(""), + &content, )?; writer.write(&archlens_domain::RenderOutput::single(module_file))?; } @@ -258,7 +313,7 @@ fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> { let level = parse_level(&args.level); let graph = build_graph(args, level)?; - let renderer = create_renderer(&args.format, level)?; + let renderer = create_renderer(&args.format, level, !args.no_weights)?; let output = renderer.render(&graph)?; let current = output.files().first().map(|f| f.content()).unwrap_or(""); @@ -331,6 +386,13 @@ format = "mermaid" # Generate separate files per module split_by_module = false + +[rules] +# Allowed dependency directions between modules (if set, unlisted directions are violations) +# allow = ["Application --> Domain", "Adapters --> Domain"] + +# Explicitly forbidden dependency directions (always checked) +# deny = ["Domain --> Adapters", "Domain --> Application"] "#; std::fs::write(&config_path, content)?; @@ -362,3 +424,104 @@ fn init_tracing(verbosity: u8) { .try_init() .ok(); } + +fn get_changed_files( + root: &std::path::Path, + git_ref: &str, +) -> Result> { + let output = std::process::Command::new("git") + .args(["diff", "--name-only", git_ref]) + .current_dir(root) + .output() + .map_err(|e| anyhow::anyhow!("git not found: {e}"))?; + + if !output.status.success() { + bail!( + "git diff failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let files = String::from_utf8_lossy(&output.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + Ok(files) +} + +fn run_watch(args: Cli) -> Result<()> { + use notify::{RecursiveMode, Watcher, recommended_watcher}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + let level = parse_level(&args.level); + let ext = format_extension(&args.format); + let debounce = Duration::from_millis(500); + + let run_once = |args: &Cli| -> Result<()> { + let config_loader = load_config(args)?; + let graph = build_graph(args, level)?; + let renderer = create_renderer(&args.format, level, !args.no_weights)?; + if args.split_by_module { + write_split(&graph, &*renderer, &args.output, ext)?; + } else { + write_single(&graph, &*renderer, &args.output)?; + } + let (raw_allow, raw_deny) = config_loader.load_rules(); + let allow: Vec = raw_allow + .iter() + .filter_map(|s| BoundaryRule::parse(s)) + .collect(); + let deny: Vec = raw_deny + .iter() + .filter_map(|s| BoundaryRule::parse(s)) + .collect(); + if !allow.is_empty() || !deny.is_empty() { + let violations = check_boundary_rules(&graph, &allow, &deny); + for v in &violations { + eprintln!("RULE VIOLATION: {}", v.message()); + } + } + Ok(()) + }; + + eprintln!( + "Watching {} for changes (Ctrl+C to stop)...", + args.path.display() + ); + if let Err(e) = run_once(&args) { + eprintln!("Error: {e}"); + } else { + eprintln!("Diagram generated."); + } + + let (tx, rx) = mpsc::channel(); + let mut watcher = recommended_watcher(move |res| { + let _ = tx.send(res); + })?; + watcher.watch(&args.path, RecursiveMode::Recursive)?; + + let mut last_run = Instant::now(); + loop { + match rx.recv() { + Ok(_) => { + if last_run.elapsed() < debounce { + continue; + } + last_run = Instant::now(); + eprintln!("Change detected, regenerating..."); + if let Err(e) = run_once(&args) { + eprintln!("Error: {e}"); + } else { + eprintln!("Diagram updated."); + } + } + Err(e) => { + eprintln!("Watch error: {e}"); + break; + } + } + } + Ok(()) +} diff --git a/crates/presentation/tests/end_to_end_tests.rs b/crates/presentation/tests/end_to_end_tests.rs index 043eb96..7c36f13 100644 --- a/crates/presentation/tests/end_to_end_tests.rs +++ b/crates/presentation/tests/end_to_end_tests.rs @@ -53,6 +53,10 @@ fn analyzes_rust_project_and_writes_mermaid_to_file() { config: None, scope: None, exclude: vec![], + include_tests: false, + no_weights: false, + watch: false, + since: None, split_by_module: false, strict: false, check: false, @@ -83,6 +87,10 @@ fn works_without_config_file() { config: None, scope: None, exclude: vec![], + include_tests: false, + no_weights: false, + watch: false, + since: None, split_by_module: false, strict: false, check: false, @@ -108,6 +116,10 @@ fn split_by_module_writes_overview_and_per_module_files() { config: None, scope: None, exclude: vec![], + include_tests: false, + no_weights: false, + watch: false, + since: None, split_by_module: true, strict: false, check: false, @@ -131,3 +143,59 @@ fn split_by_module_writes_overview_and_per_module_files() { "should have overview + at least one module file" ); } + +fn create_cross_module_project(dir: &std::path::Path) { + fs::create_dir_all(dir.join("src/app")).unwrap(); + fs::create_dir_all(dir.join("src/domain")).unwrap(); + fs::write( + dir.join("src/domain/order.rs"), + "pub struct Order { pub id: u64 }\n", + ) + .unwrap(); + fs::write( + dir.join("src/app/service.rs"), + "use crate::domain::Order;\npub struct OrderService { order: Order }\n", + ) + .unwrap(); +} + +#[test] +fn per_module_file_shows_cross_module_dependency_arrows() { + let project = tempfile::tempdir().unwrap(); + create_cross_module_project(project.path()); + + let output_dir = tempfile::tempdir().unwrap(); + + run(archlens::CliArgs { + command: None, + path: project.path().to_path_buf(), + level: "type".to_string(), + format: "mermaid".to_string(), + output: Some(output_dir.path().to_str().unwrap().to_string()), + config: None, + scope: None, + exclude: vec![], + include_tests: false, + no_weights: false, + watch: false, + since: None, + split_by_module: true, + strict: false, + check: false, + verbose: 0, + }) + .unwrap(); + + let app_file = output_dir.path().join("app.mmd"); + assert!(app_file.exists(), "app.mmd should exist"); + + let content = fs::read_to_string(&app_file).unwrap(); + assert!( + content.contains("<>"), + "per-module file should contain external module placeholder: {content}" + ); + assert!( + content.contains("domain"), + "per-module file should reference the domain module: {content}" + ); +}