diff --git a/.dprint.json b/.dprint.json new file mode 100644 index 0000000..66b8468 --- /dev/null +++ b/.dprint.json @@ -0,0 +1,16 @@ +{ + "toml": { + "lineWidth": 100, + "indentWidth": 4 + }, + "markdown": { + "lineWidth": 100, + "unorderedListKind": "asterisks", + "textWrap": "always" + }, + "excludes": [], + "plugins": [ + "https://plugins.dprint.dev/toml-0.7.0.wasm", + "https://plugins.dprint.dev/markdown-0.21.1.wasm" + ] +} diff --git a/.github/get-release-notes.sh b/.github/get-release-notes.sh new file mode 100755 index 0000000..44a764d --- /dev/null +++ b/.github/get-release-notes.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -o errexit +set -o pipefail +set -o nounset + +VERSION="${1:?Usage: $0 }" +DESCRIPTION="${2:?Usage: $0 }" + +CHANGELOG="$(git rev-parse --show-toplevel)/CHANGELOG.md" + +# Output the version header +if ! grep "^# Deptangle - $VERSION -" "$CHANGELOG"; then + echo "ERROR: Could not find version '$VERSION' in '$CHANGELOG'" >&2 + exit 1 +fi + +# Add the project description +echo "$DESCRIPTION" + +# Extract everything between this version's header and the next +# 0,/pat1/d deletes from line 1 to pat1 inclusive +# /pat2/Q exits without printing on the first line to match pat2 +sed "0,/^# Deptangle - $VERSION -/d;/^# Deptangle - /Q" "$CHANGELOG" diff --git a/.github/parse-manifest-key.sh b/.github/parse-manifest-key.sh new file mode 100755 index 0000000..c5eb35f --- /dev/null +++ b/.github/parse-manifest-key.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -o errexit +set -o pipefail +set -o nounset +set -o noclobber + +CARGO_MANIFEST="${1:-Cargo.toml}" +MANIFEST_KEY="${2:-version}" + +cargo metadata \ + --format-version=1 \ + --manifest-path "$CARGO_MANIFEST" \ + --no-deps | + jq ".packages[0].$MANIFEST_KEY" | + tr -d '"' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..0cce1ca --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,82 @@ +name: Lint +on: push +env: + CARGO_BUILD_WARNINGS: deny + CARGO_TERM_COLOR: always + +jobs: + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + components: rustfmt + - name: Run rustfmt + run: cargo fmt -- --check --config group_imports=StdExternalCrate,imports_granularity=Module + + dprint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Setup dprint + uses: dprint/check@v2.2 + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + components: clippy + - name: Setup Rust cache + uses: swatinem/rust-cache@v2 + - name: Build + run: cargo build --all-targets --all-features + - name: Clippy + run: cargo clippy --no-deps --all-targets --all-features + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + components: llvm-tools-preview + - name: Setup Rust cache + uses: swatinem/rust-cache@v2 + - name: Setup nextest + uses: taiki-e/install-action@v2 + with: + tool: cargo-nextest,cargo-llvm-cov + - name: Test + run: | + cargo llvm-cov --no-report nextest --all-features + cargo llvm-cov report --cobertura --output-path coverage.xml + head coverage.xml + RATE="$(grep -o -m 1 -P '(?<=line-rate=").*?(?=")' coverage.xml | head -1)" + echo "RATE=$RATE" + PERCENT="$(echo "($RATE * 100)/1" | bc)" + echo "PERCENT=$PERCENT" + echo "COVERAGE_PERCENT=$PERCENT" >> $GITHUB_ENV + - name: Update coverage badge + uses: schneegans/dynamic-badges-action@v1.7.0 + if: github.ref_name == github.event.repository.default_branch + with: + # https://github.com/Notgnoshi/deptangle/settings/secrets/actions + # https://github.com/settings/personal-access-tokens + # https://github.com/marketplace/actions/dynamic-badges + auth: ${{ secrets.DEPTANGLE_COVERAGE_GIST_TOKEN }} + gistID: 0e15edf83d41c5b3cace2ff71f4d2f53 + filename: deptangle-coverage.json + label: Code Coverage + message: ${{ env.COVERAGE_PERCENT }} + valColorRange: ${{ env.COVERAGE_PERCENT }} + minColorRange: 60 + maxColorRange: 95 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f454f1d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release + +# This workflow is not ordered after the 'Lint' workflow. And doing so is difficult, because +# workflow_run: only applies on the default branch, and I EXPRESSLY WANT this workflow to run in PRs +# too, because it validates some preconditions for making the release. +# +# So make the assumption that if the PR pipeline passed, and this workflow runs on the default +# branch, it's okay to make a release without waiting for the build and test +on: push + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Extract Release Metadata + shell: bash + run: | + VERSION="$(.github/parse-manifest-key.sh Cargo.toml version)" + DESCRIPTION="${{ github.event.repository.description }}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "DESCRIPTION=$DESCRIPTION" >> "$GITHUB_ENV" + + # Always generate release notes for the current version. If the version from the Cargo + # manifest isn't found in the CHANGELOG.md, then fail the pipeline (even in PRs) + - name: Generate Release Notes + shell: bash + run: .github/get-release-notes.sh "$VERSION" "$DESCRIPTION" | tee release.md + + - name: Create Release + shell: bash + if: github.ref_name == github.event.repository.default_branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh release view "v$VERSION" &>/dev/null; then + echo "Release v$VERSION already exists. Skipping ..." + else + echo "Creating release v$VERSION ..." + PRERELEASE_FLAG="" + if [[ "$VERSION" == *-rc* ]]; then + PRERELEASE_FLAG="--prerelease" + fi + gh release create "v$VERSION" \ + --title "v$VERSION" \ + --notes-file release.md \ + $PRERELEASE_FLAG + fi diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..f216078 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1 @@ +edition = "2024" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..91d9040 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Entries from this changelog are copied verbatim into the generated GitHub release notes, so **keep +the focus on user impact** rather than the mechanics of the change. + +# Deptangle - Unreleased - (YYYY-MM-DD) + + + +# Deptangle - 0.1.0 - (2026-08-29) + +Migrate project from diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..95b5815 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1537 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "assert_approx_eq" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c07dab4369547dbe5114677b33fbbf724971019f3818172d59a97a61c774ffd" + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstream", + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "assert_unordered" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74323b7881323eb351134e08ee5331594826789557afef8e309baf481b2264" +dependencies = [ + "ansi_term", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "deptangle-cli" +version = "0.1.0" +dependencies = [ + "clap", + "color-eyre", + "deptangle-graph", + "deptangle-io", + "deptangle-minpath", + "deptangle-ops", + "deptangle-test", + "eyre", + "globset", + "pretty_assertions", + "tempfile", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "deptangle-graph" +version = "0.1.0" +dependencies = [ + "indexmap", + "petgraph", +] + +[[package]] +name = "deptangle-io" +version = "0.1.0" +dependencies = [ + "cargo_metadata", + "clap", + "deptangle-graph", + "eyre", + "graphviz-rust", + "indexmap", + "mermaid-rs-renderer", + "pretty_assertions", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "deptangle-minpath" +version = "0.1.0" +dependencies = [ + "eyre", + "globset", + "indexmap", + "pathdiff", + "pretty_assertions", + "tracing", +] + +[[package]] +name = "deptangle-ops" +version = "0.1.0" +dependencies = [ + "clap", + "deptangle-graph", + "deptangle-minpath", + "eyre", + "globset", + "graphrs", + "indexmap", + "petgraph", + "pretty_assertions", + "rand 0.10.2", + "regex", +] + +[[package]] +name = "deptangle-test" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "ctor", + "eyre", + "tempfile", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + +[[package]] +name = "dot-generator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aaac7ada45f71873ebce336491d1c1bc4a7c8042c7cea978168ad59e805b871" +dependencies = [ + "dot-structures", +] + +[[package]] +name = "dot-structures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cfcded997a93eb31edd639361fa33fd229a8784e953b37d71035fe3890b7b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "eyre" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" +dependencies = [ + "autocfg", + "indenter", + "once_cell", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "graphrs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7204b0ec6796defd776f2449b6083d485c21dc1b5ef260e6df56482faf38bd79" +dependencies = [ + "assert_approx_eq", + "assert_unordered", + "doc-comment", + "itertools", + "nohash", + "quick-xml", + "rand 0.8.8", + "rand_chacha 0.3.1", + "rayon", + "serde", + "sorted-vec", +] + +[[package]] +name = "graphviz-rust" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee83cefff83c5dd5f34c603145f4e8d478e70cc17873049b6a36eeaf37b250a" +dependencies = [ + "dot-generator", + "dot-structures", + "into-attr", + "into-attr-derive", + "pest", + "pest_derive", + "rand 0.9.5", + "tempfile", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "into-attr" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18b48c537e49a709e678caec3753a7dba6854661a1eaa27675024283b3f8b376" +dependencies = [ + "dot-structures", +] + +[[package]] +name = "into-attr-derive" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecac7c1ae6cd2c6a3a64d1061a8bdc7f52ff62c26a831a2301e54c1b5d70d5b1" +dependencies = [ + "dot-generator", + "dot-structures", + "into-attr", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "json5" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c" +dependencies = [ + "serde", + "ucd-trie", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "link-section" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "mermaid-rs-renderer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb5b469222ce6b9896edf0a969cf68464f5a028ef394fd6643f00bd943df117" +dependencies = [ + "anyhow", + "fontdb", + "json5", + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror", + "ttf-parser", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "nohash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0f889fb66f7acdf83442c35775764b51fed3c606ab9cee51500dbde2cf528ca" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "sorted-vec" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238653bedf881fede50103cb7838b029e2b73d3d3d8e70d36f0a96be32a65c7b" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..39dc8ff --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,39 @@ +[workspace] +members = ["crates/*"] +resolver = "3" + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" + +[workspace.dependencies] +# Internal crates +deptangle-graph = { path = "crates/deptangle-graph" } +deptangle-io = { path = "crates/deptangle-io" } +deptangle-minpath = { path = "crates/deptangle-minpath" } +deptangle-ops = { path = "crates/deptangle-ops" } +deptangle-test = { path = "crates/deptangle-test" } + +# External dependencies +assert_cmd = { version = "2.2.2", features = ["color-auto"] } +cargo_metadata = "0.23.1" +clap = { version = "4.6.6", features = ["derive"] } +color-eyre = "0.6.5" +ctor = "1.0.13" +eyre = "0.6.14" +globset = "0.4.20" +graphrs = "0.12.0" +graphviz-rust = { version = "0.9.8", default-features = false } +indexmap = "2.14.1" +mermaid-rs-renderer = { version = "0.3.1", default-features = false } +pathdiff = "0.2.3" +petgraph = "0.8.3" +pretty_assertions = "1.4.1" +rand = "0.10.2" +regex = "1.13.1" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +tempfile = "3.27.0" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/README.md b/README.md index e977be7..c5f17da 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,285 @@ # deptangle + +![lint workflow](https://github.com/Notgnoshi/deptangle/actions/workflows/lint.yml/badge.svg?event=push) +![code coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/Notgnoshi/0e15edf83d41c5b3cace2ff71f4d2f53/raw/deptangle-coverage.json) + Tools to interrogate and detangle dependency graphs + +## Table of contents + +* [depconv](#depconv) - convert dependency graphs between formats +* [depfilter](#depfilter) - filter or select subsets of dependency graphs +* [deptransform](#deptransform) - transform dependency graphs +* [depquery](#depquery) - query properties of dependency graphs +* [depcluster](#depcluster) - cluster dependency graphs using community detection +* [graphdiff](#graphdiff) - compare two dependency graphs +* [minpath](#minpath) - shorten file paths to minimal unique suffixes +* [bbclasses](#bbclasses) - generate BitBake recipe inheritance diagrams + +# Philosophy + +Rather than build an infinitely flexible, does-everything-and-more graph toolkit, these are targeted +tools to solve specific dependency graph problems I frequently encounter. + +All tools operate on stdin/stdout in addition to files, and are designed to be chained together with +pipes. Any ancillary output is emitted on stderr. + +# How to use + +You can install the tools with + +```sh +./install --prefix ~/.local/ +./install --uninstall --prefix ~/.local/ +``` + +You can also just experiment with the tools by + +```sh +cargo run --release --bin depconv -- ... +``` + +You likely want a release build for large graphs. + +# Tools + +## depconv + +Convert dependency graphs between formats. Input and output formats are auto-detected from file +extensions or by probing the file content when `--input-format`/`--output-format` are not specified. +Defaults to DOT output when no output format can be inferred. + +```sh +$ echo -e "a Node A\nb Node B\n#\na b depends on" | depconv --output-format dot +digraph { + a [label="Node A"]; + b [label="Node B"]; + a -> b [label="depends on"]; +} + +$ cargo tree --depth 1 | depconv --output-format tgf +deptangle v0.1.0 +clap v4.6.6 +... +# +deptangle v0.1.0 clap v4.6.6 +... +``` + +### Supported formats + +| Format | `--input-format` | `--output-format` | Description | +| -------------- | :--------------: | :---------------: | ------------------------------------------------------------------------------- | +| DOT (GraphViz) | yes | yes | `digraph` / `graph` syntax. Parses cmake, ninja, bitbake, and ad-hoc DOT output | +| Mermaid | yes | yes | `flowchart` / `graph` graph types | +| TGF | yes | yes | Trivial Graph Format | +| Depfile | yes | yes | Makefile `.d` depfile | +| Tree | yes | yes | `tree` CLI output, both ascii and unicode output formats | +| Pathlist | yes | yes | One path per line; hierarchy inferred from `/` separators | +| Cargo tree | yes | no | `cargo tree` output | +| Cargo metadata | yes | no | `cargo metadata --format-version=1` JSON | + +### What's preserved across formats + +Not every format can represent the same information. The table below shows what each format +preserves when parsing (P) and emitting (E): + +| Format | Labels | Node type | Attrs | Edge labels | Subgraphs | +| -------------- | :----: | :-------: | :-----: | :---------: | :-------: | +| DOT | P+E | P+E | P+E | P+E | P+E | +| Mermaid | P+E | partial | partial | P+E | P+E | +| TGF | P+E | -- | -- | P+E | -- | +| Depfile | -- | -- | -- | -- | -- | +| Tree | P+E | -- | -- | -- | -- | +| Pathlist | P+E | -- | -- | -- | -- | +| Cargo tree | P | P | P | -- | -- | +| Cargo metadata | P | P | P | -- | -- | + +Converting from a rich format (DOT, cargo metadata) to a simpler one (TGF, depfile) silently drops +unsupported attributes. Converting in the other direction preserves graph topology but cannot +recover lost metadata. + +## depfilter + +Filter or select subsets of dependency graphs. Works on the same graph formats as `depconv`, and is +designed to be chained with pipes. + +* `depfilter select` keeps nodes matching `--include` patterns and/or removes `--exclude` patterns +* `depfilter between` select nodes connecting multiple sets of query nodes +* `depfilter cycles` select any cycles in the graph +* `depfilter slice` cut edges between subgraphs, isolating each subgraph + +Each subcommand has extra options to tune its behavior. + +```sh +# From a cargo dependency tree, select the subtree rooted at "clap", excluding all the proc-macro crates: +$ cargo tree --depth 10 \ + | depfilter select -g "clap*" --deps -x "*derive*" -x "*proc*" -I cargo-tree -O dot +digraph { + clap [label="v4.6.6 clap"]; + clap_builder [label="v4.6.6 clap_builder"]; + anstream [label="v0.6.21 anstream"]; + ... +} +``` + +## deptransform + +Structural transformations on dependency graphs. Works on the same formats as `depconv`, and is +designed to be chained with pipes. + +The `deptransform` tool supports the following subcommands: + +* `deptransform reverse` - reverse the direction of all edges in the graph +* `deptransform simplify` - remove redundant edges (e.g. if A->B and B->C, then A->C is redundant) +* `deptransform shorten` - shorten node IDs that look like paths (`minpath`, but for node IDs) +* `deptransform sub` - `sed`, but for node IDs and node / edge attributes +* `deptransform merge` - merge multiple graphs into one +* `deptransform flatten` - recursively flatten subgraphs into the parent graph + +```sh +# Collapse bitbake task-level nodes IDs (acl-native.do_* -> acl-native), then remove the +# now-misleading node labels +$ cat data/depconv/bitbake.curl.task-depends.dot | + deptransform sub --key=id 's/\.do_.*//' | + deptransform sub --key=node:label 's/.*//' +``` + +## depquery + +Query properties of dependency graphs. + +```sh +# Show the 5 crates with the most dependencies: +$ cargo metadata --format-version=1 | + depquery nodes --sort out-degree --limit 5 +deptangle-io 12 +deptangle-cli 11 +deptangle-ops 11 +graphrs 11 +tracing-subscriber 10 +``` + +The `depquery` tool supports outputting `nodes`, `edges`, and `metrics`. The output is intended to +be machine-readable, and is tab-separated. + +## depcluster + +Run community detection on a dependency graph to identify clusters of related nodes. Each cluster +becomes a subgraph in the output, with cross-cluster edges at the top level. Supports Louvain +(default), Leiden, and Label Propagation algorithms. + +```sh +$ echo -e "a\nb\nc\nd\ne\nf\n#\na b\na c\nb c\nd e\nd f\ne f" | + depcluster -I tgf -O mermaid +``` + +```mermaid +flowchart LR + subgraph cluster_0 + a["a"] + b["b"] + c["c"] + a --> b + a --> c + b --> c + end + subgraph cluster_1 + d["d"] + e["e"] + f["f"] + d --> e + d --> f + e --> f + end +``` + +## graphdiff + +Compare two dependency graphs and report what changed. Nodes are matched by ID, and edges by their +endpoints. + +`graphdiff` supports several subcommands: + +* `graphdiff annotate` - output the combined graph with changes highlighted (added, removed, changed + nodes/edges get distinct attributes) +* `graphdiff list` - tab-delimited list of changes (`+` added, `-` removed, `~` changed, `>` moved) +* `graphdiff summary` - tab-delimited counts of each change type +* `graphdiff subtract` - set difference: nodes and edges only in the first graph + +```sh +$ cat before.tgf +a Alpha +b Bravo +# +a b + +$ cat after.tgf +b Bravo +c Charlie +# +b c + +$ graphdiff annotate before.tgf after.tgf -O mermaid +``` + +```mermaid +flowchart LR + b["Bravo"] + c["+ Charlie"] + a["- Alpha"] + b --> c + a --> b +``` + +## minpath + +Shorten file paths to the minimal unique suffix. Useful for displaying lists of files in a compact +way while keeping them distinguishable. + +```sh +$ minpath <curl.dot +``` + +```mermaid +flowchart LR + subgraph meta[meta] + poky/meta/classes-global/debian.bbclass{{"poky/meta/classes-global/debian.bbclass"}} + poky/meta/classes-global/package.bbclass{{"poky/meta/classes-global/package.bbclass"}} + poky/meta/classes-recipe/autotools.bbclass{{"poky/meta/classes-recipe/autotools.bbclass"}} + poky/meta/classes-recipe/ptest.bbclass{{"poky/meta/classes-recipe/ptest.bbclass"}} + poky/meta/conf/distro/include/ptest-packagelists.inc[["poky/meta/conf/distro/include/ptest-packagelists.inc"]] + poky/meta/recipes-support/curl/curl_8.7.1.bb["poky/meta/recipes-support/curl/curl_8.7.1.bb"] + end + subgraph meta-oem[meta-oem] + meta-oem/classes/dynamic-packagearch.bbclass{{"meta-oem/classes/dynamic-packagearch.bbclass"}} + end + meta-work/recipes-support/curl/curl__.bbappend(["meta-work/recipes-support/curl/curl_%.bbappend"]) + meta-oem/classes/dynamic-packagearch.bbclass -->|"INHERIT"| poky/meta/recipes-support/curl/curl_8.7.1.bb + poky/meta/classes-global/debian.bbclass -->|"INHERIT"| poky/meta/recipes-support/curl/curl_8.7.1.bb + poky/meta/classes-global/package.bbclass -->|"inherit"| poky/meta/classes-global/debian.bbclass + poky/meta/classes-recipe/autotools.bbclass -->|"inherit"| poky/meta/recipes-support/curl/curl_8.7.1.bb + poky/meta/classes-recipe/ptest.bbclass -->|"inherit"| poky/meta/recipes-support/curl/curl_8.7.1.bb + poky/meta/conf/distro/include/ptest-packagelists.inc -->|"require"| poky/meta/classes-recipe/ptest.bbclass + poky/meta/recipes-support/curl/curl_8.7.1.bb -->|"appends"| meta-work/recipes-support/curl/curl__.bbappend +``` diff --git a/crates/deptangle-cli/Cargo.toml b/crates/deptangle-cli/Cargo.toml new file mode 100644 index 0000000..5930ad4 --- /dev/null +++ b/crates/deptangle-cli/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "deptangle-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Tools to interrogate and detangle dependency graphs" + +[dependencies] +clap.workspace = true +color-eyre.workspace = true +deptangle-graph.workspace = true +deptangle-io.workspace = true +deptangle-minpath.workspace = true +deptangle-ops.workspace = true +eyre.workspace = true +globset.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +deptangle-test.workspace = true +pretty_assertions.workspace = true +tempfile.workspace = true diff --git a/crates/deptangle-cli/src/bin/depcluster.rs b/crates/deptangle-cli/src/bin/depcluster.rs new file mode 100644 index 0000000..9983a67 --- /dev/null +++ b/crates/deptangle-cli/src/bin/depcluster.rs @@ -0,0 +1,119 @@ +use std::io::Read; +use std::path::PathBuf; + +use clap::Parser; +use deptangle_cli::stdio::{get_input_reader, get_output_writer}; +use deptangle_io::emit::OutputFormat; +use deptangle_io::parse::InputFormat; +use deptangle_ops::cluster::{graphrs_bridge, lpa}; + +/// Cluster nodes in a dependency graph using community detection algorithms. +/// +/// Runs a community detection algorithm on the input graph and outputs the result +/// with one subgraph per cluster. Cross-cluster edges appear at the top level. +#[derive(Debug, Parser)] +#[clap(version, verbatim_doc_comment)] +struct Args { + /// Logging level + #[clap(long, default_value_t = tracing::Level::INFO)] + log_level: tracing::Level, + + /// Input file (stdin if '-' or omitted) + #[clap(short, long)] + input: Option, + + /// Input format (auto-detected from extension/content if omitted) + #[clap(short = 'I', long)] + input_format: Option, + + /// Output file (stdout if '-' or omitted) + #[clap(short, long)] + output: Option, + + /// Output format (auto-detected from extension, defaults to DOT) + #[clap(short = 'O', long)] + output_format: Option, + + /// Clustering algorithm + #[clap(short, long, default_value_t, value_enum)] + algorithm: Algorithm, + + /// Use directed edges only (default: undirected/bidirectional) + #[clap(long)] + directed: bool, + + /// Maximum iterations (LPA only) + #[clap(long, default_value_t = 100)] + max_iter: usize, + + /// Random seed (LPA: shuffle order; Louvain: reproducibility) + #[clap(long)] + seed: Option, + + /// Resolution parameter; higher = more clusters (Louvain/Leiden only) + #[clap(long, default_value_t = 1.0)] + resolution: f64, +} + +#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)] +enum Algorithm { + Lpa, + #[default] + Louvain, + Leiden, +} + +impl std::fmt::Display for Algorithm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Algorithm::Lpa => write!(f, "lpa"), + Algorithm::Louvain => write!(f, "louvain"), + Algorithm::Leiden => write!(f, "leiden"), + } + } +} + +fn main() -> eyre::Result<()> { + let args = Args::parse(); + deptangle_cli::init(args.log_level)?; + + // Normalize Some("-") to None so we can use the filepath for auto-detecting graph format below + let is_stdio = |p: &PathBuf| p.as_os_str() == "-"; + let input_path = args.input.filter(|p| !is_stdio(p)); + let output_path = args.output.filter(|p| !is_stdio(p)); + + let mut input = get_input_reader(&input_path)?; + let mut input_text = String::new(); + input.read_to_string(&mut input_text)?; + + let input_format = deptangle_io::parse::resolve_input_format( + args.input_format, + input_path.as_deref(), + &input_text, + )?; + let output_format = + deptangle_io::emit::resolve_output_format(args.output_format, output_path.as_deref())?; + + let graph = deptangle_io::parse::parse(input_format, &input_text)?; + tracing::info!( + "Parsed graph with {} nodes, {} edges, and {} subgraphs", + graph.all_nodes().len(), + graph.all_edges().len(), + graph.subgraphs.len() + ); + + let graph = match args.algorithm { + Algorithm::Lpa => lpa::lpa(&graph, args.directed, args.max_iter, args.seed), + Algorithm::Louvain => { + graphrs_bridge::louvain_clustering(&graph, args.directed, args.resolution, args.seed)? + } + Algorithm::Leiden => { + graphrs_bridge::leiden_clustering(&graph, args.directed, args.resolution)? + } + }; + + let mut output = get_output_writer(&output_path)?; + deptangle_io::emit::emit(output_format, &graph, &mut output)?; + + Ok(()) +} diff --git a/crates/deptangle-cli/src/bin/depconv.rs b/crates/deptangle-cli/src/bin/depconv.rs new file mode 100644 index 0000000..5ae5c47 --- /dev/null +++ b/crates/deptangle-cli/src/bin/depconv.rs @@ -0,0 +1,78 @@ +use std::io::Read; +use std::path::PathBuf; + +use clap::Parser; +use deptangle_cli::stdio::{get_input_reader, get_output_writer}; +use deptangle_io::emit::OutputFormat; +use deptangle_io::parse::InputFormat; + +/// Dependency graph format converter. +/// +/// Formats are auto-detected from file extensions or content when --input-format/--output-format are not specified. +#[derive(Debug, Parser)] +#[clap(version, verbatim_doc_comment)] +struct Args { + #[clap(short, long, default_value_t = tracing::Level::INFO)] + log_level: tracing::Level, + + /// Print the detected input format and exit + #[clap(long)] + detect: bool, + + /// Path to the input. stdin if '-' or omitted + #[clap(short, long)] + input: Option, + + /// Input format (auto-detected from extension or content if omitted) + #[clap(short = 'I', long)] + input_format: Option, + + /// Path to the output. stdout if '-' or omitted + #[clap(short, long)] + output: Option, + + /// Output format (auto-detected from output extension if omitted, defaults to DOT) + #[clap(short = 'O', long)] + output_format: Option, +} + +fn main() -> eyre::Result<()> { + let args = Args::parse(); + deptangle_cli::init(args.log_level)?; + + // Normalize Some("-") to None so we can use the filepath for auto-detecting graph format below + let is_stdio = |p: &PathBuf| p.as_os_str() == "-"; + let input_path = args.input.filter(|p| !is_stdio(p)); + let output_path = args.output.filter(|p| !is_stdio(p)); + + let mut input = get_input_reader(&input_path)?; + let mut input_text = String::new(); + input.read_to_string(&mut input_text)?; + + let input_format = deptangle_io::parse::resolve_input_format( + args.input_format, + input_path.as_deref(), + &input_text, + )?; + + if args.detect { + println!("{input_format}"); + return Ok(()); + } + + let output_format = + deptangle_io::emit::resolve_output_format(args.output_format, output_path.as_deref())?; + + let graph = deptangle_io::parse::parse(input_format, &input_text)?; + tracing::info!( + "Parsed graph with {} nodes, {} edges, and {} subgraphs", + graph.all_nodes().len(), + graph.all_edges().len(), + graph.subgraphs.len() + ); + + let mut output = get_output_writer(&output_path)?; + deptangle_io::emit::emit(output_format, &graph, &mut output)?; + + Ok(()) +} diff --git a/crates/deptangle-cli/src/bin/depfilter.rs b/crates/deptangle-cli/src/bin/depfilter.rs new file mode 100644 index 0000000..e069afb --- /dev/null +++ b/crates/deptangle-cli/src/bin/depfilter.rs @@ -0,0 +1,97 @@ +use std::io::Read; +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use deptangle_cli::stdio::{get_input_reader, get_output_writer}; +use deptangle_io::emit::OutputFormat; +use deptangle_io::parse::InputFormat; +use deptangle_ops::select; +use deptangle_ops::select::between::BetweenArgs; +use deptangle_ops::select::cycles::CyclesArgs; +use deptangle_ops::select::select::SelectArgs; +use deptangle_ops::select::slice::SliceArgs; + +/// Select or exclude nodes from dependency graphs. +/// +/// Operations are performed via select, between, slice, or cycles subcommands. +/// Chain operations by piping: depfilter ... | depfilter ... +#[derive(Debug, Parser)] +#[clap(version, verbatim_doc_comment)] +struct Args { + /// Logging level + #[clap(long, default_value_t = tracing::Level::INFO)] + log_level: tracing::Level, + + /// Input file (stdin if '-' or omitted) + #[clap(short, long, global = true)] + input: Option, + + /// Input format (auto-detected from extension/content if omitted) + #[clap(short = 'I', long, global = true)] + input_format: Option, + + /// Output file (stdout if '-' or omitted) + #[clap(short, long, global = true)] + output: Option, + + /// Output format (auto-detected from extension, defaults to DOT) + #[clap(short = 'O', long, global = true)] + output_format: Option, + + #[clap(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Select nodes matching patterns and optionally their deps/rdeps + Select(SelectArgs), + /// Extract the subgraph of all directed paths between matched query nodes + Between(BetweenArgs), + /// Detect cycles (strongly connected components) and output each as a subgraph + Cycles(CyclesArgs), + /// Cut edges between subgraphs, isolating each subgraph + Slice(SliceArgs), +} + +fn main() -> eyre::Result<()> { + let args = Args::parse(); + deptangle_cli::init(args.log_level)?; + + // Normalize Some("-") to None so we can use the filepath for auto-detecting graph format below + let is_stdio = |p: &PathBuf| p.as_os_str() == "-"; + let input_path = args.input.filter(|p| !is_stdio(p)); + let output_path = args.output.filter(|p| !is_stdio(p)); + + let mut input = get_input_reader(&input_path)?; + let mut input_text = String::new(); + input.read_to_string(&mut input_text)?; + + let input_format = deptangle_io::parse::resolve_input_format( + args.input_format, + input_path.as_deref(), + &input_text, + )?; + let output_format = + deptangle_io::emit::resolve_output_format(args.output_format, output_path.as_deref())?; + + let graph = deptangle_io::parse::parse(input_format, &input_text)?; + tracing::info!( + "Parsed graph with {} nodes, {} edges, and {} subgraphs", + graph.all_nodes().len(), + graph.all_edges().len(), + graph.subgraphs.len() + ); + + let graph = match &args.command { + Command::Select(select_args) => select::select::select(&graph, select_args)?, + Command::Between(between_args) => select::between::between(&graph, between_args)?, + Command::Cycles(cycles_args) => select::cycles::cycles(&graph, cycles_args)?, + Command::Slice(slice_args) => select::slice::slice(&graph, slice_args)?, + }; + + let mut output = get_output_writer(&output_path)?; + deptangle_io::emit::emit(output_format, &graph, &mut output)?; + + Ok(()) +} diff --git a/crates/deptangle-cli/src/bin/depquery.rs b/crates/deptangle-cli/src/bin/depquery.rs new file mode 100644 index 0000000..1eec501 --- /dev/null +++ b/crates/deptangle-cli/src/bin/depquery.rs @@ -0,0 +1,103 @@ +use std::io::{Read, Write}; +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use deptangle_cli::stdio::get_input_reader; +use deptangle_io::parse::InputFormat; +use deptangle_ops::query::edges::EdgesArgs; +use deptangle_ops::query::nodes::NodesArgs; +use deptangle_ops::query::{OutputFields, metrics}; + +/// Query properties of dependency graphs. +/// +/// Produces plain text output (not graph output) answering +/// "what's in this graph?" -- listing nodes, edges, and computing metrics. +#[derive(Debug, Parser)] +#[clap(version, verbatim_doc_comment)] +struct Args { + /// Logging level + #[clap(long, default_value_t = tracing::Level::INFO)] + log_level: tracing::Level, + + /// Input file (stdin if '-' or omitted) + #[clap(short, long, global = true)] + input: Option, + + /// Input format (auto-detected from extension/content if omitted) + #[clap(short = 'I', long, global = true)] + input_format: Option, + + #[clap(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// List nodes with optional filtering and sorting + Nodes(NodesArgs), + /// List edges with optional filtering and sorting + Edges(EdgesArgs), + /// Compute and display graph metrics + Metrics, +} + +fn main() -> eyre::Result<()> { + let args = Args::parse(); + deptangle_cli::init(args.log_level)?; + + // Normalize Some("-") to None so we can use the filepath for auto-detecting graph format below + let is_stdio = |p: &PathBuf| p.as_os_str() == "-"; + let input_path = args.input.filter(|p| !is_stdio(p)); + + let mut input = get_input_reader(&input_path)?; + let mut input_text = String::new(); + input.read_to_string(&mut input_text)?; + + let input_format = deptangle_io::parse::resolve_input_format( + args.input_format, + input_path.as_deref(), + &input_text, + )?; + + let graph = deptangle_io::parse::parse(input_format, &input_text)?; + tracing::info!( + "Parsed graph with {} nodes, {} edges, and {} subgraphs", + graph.all_nodes().len(), + graph.all_edges().len(), + graph.subgraphs.len() + ); + + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + + match &args.command { + Command::Nodes(nodes_args) => { + let result = deptangle_ops::query::nodes::nodes(&graph, nodes_args)?; + for (id, label, count) in &result { + let field = match nodes_args.format { + OutputFields::Id => id.as_str(), + OutputFields::Label => label.as_str(), + }; + match count { + Some(n) => writeln!(out, "{field}\t{n}")?, + None => writeln!(out, "{field}")?, + } + } + } + Command::Edges(edges_args) => { + let result = deptangle_ops::query::edges::edges(&graph, edges_args)?; + for (source, target, label) in &result { + match label { + Some(l) if !l.is_empty() => writeln!(out, "{source}\t{target}\t{l}")?, + _ => writeln!(out, "{source}\t{target}")?, + } + } + } + Command::Metrics => { + let m = metrics::metrics(&graph); + write!(out, "{m}")?; + } + } + + Ok(()) +} diff --git a/crates/deptangle-cli/src/bin/deptransform.rs b/crates/deptangle-cli/src/bin/deptransform.rs new file mode 100644 index 0000000..8ad5f35 --- /dev/null +++ b/crates/deptangle-cli/src/bin/deptransform.rs @@ -0,0 +1,164 @@ +use std::io::Read; +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use deptangle_cli::stdio::{get_input_reader, get_output_writer}; +use deptangle_graph::DepGraph; +use deptangle_io::emit::OutputFormat; +use deptangle_io::parse::InputFormat; +use deptangle_ops::transform; +use deptangle_ops::transform::shorten::ShortenArgs; +use deptangle_ops::transform::sub::{SubKey, Substitution}; + +/// Arguments for the `sub` subcommand. +#[derive(Debug, clap::Parser)] +struct SubArgs { + /// Sed-style substitution: s/pattern/replacement/ + /// + /// Uses Rust regex syntax: (...) for capture groups, $1/${name} in replacement. + /// Supports alternate delimiters: s|...|...|, s#...#...#, etc. + expr: String, + + /// Field to apply substitution to: id, node:NAME, or edge:NAME + #[clap(long, default_value = "id")] + key: String, +} + +/// Arguments for the `merge` subcommand. +#[derive(Debug, clap::Parser)] +struct MergeArgs { + /// Input files to merge (use '-' for stdin, at most once). + /// The global --input/-i flag, if set, is included as an additional file. + #[clap(required = true)] + files: Vec, +} + +/// Structural transformations on dependency graphs. +/// +/// Operations are performed via subcommands. +/// Chain operations by piping: deptransform ... | deptransform ... +#[derive(Debug, Parser)] +#[clap(version, verbatim_doc_comment)] +struct Args { + /// Logging level + #[clap(long, default_value_t = tracing::Level::INFO)] + log_level: tracing::Level, + + /// Input file (stdin if '-' or omitted) + #[clap(short, long, global = true)] + input: Option, + + /// Input format (auto-detected from extension/content if omitted) + #[clap(short = 'I', long, global = true)] + input_format: Option, + + /// Output file (stdout if '-' or omitted) + #[clap(short, long, global = true)] + output: Option, + + /// Output format (auto-detected from extension, defaults to DOT) + #[clap(short = 'O', long, global = true)] + output_format: Option, + + #[clap(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Reverse the direction of all edges + Reverse, + /// Remove redundant edges via transitive reduction + Simplify, + /// Shorten node IDs and/or labels using path transforms + Shorten(ShortenArgs), + /// Apply sed-style regex substitution to graph fields + /// + /// Uses Rust regex syntax: (...) for capture groups, $1/${name} in replacement. + /// When applied to node IDs, nodes that map to the same ID are merged. + Sub(SubArgs), + /// Merge multiple graphs into one + /// + /// Nodes are unioned by ID (later files overwrite on collision). + /// Edges are deduplicated by (from, to); first label wins, attributes are merged. + /// The global --input/-i flag, if set, is included as the first file. + Merge(MergeArgs), + /// Flatten subgraphs into a single top-level graph + Flatten, +} + +fn main() -> eyre::Result<()> { + let args = Args::parse(); + deptangle_cli::init(args.log_level)?; + + // Normalize Some("-") to None so we can use the filepath for auto-detecting graph format below + let is_stdio = |p: &PathBuf| p.as_os_str() == "-"; + let output_path = args.output.filter(|p| !is_stdio(p)); + let output_format = + deptangle_io::emit::resolve_output_format(args.output_format, output_path.as_deref())?; + + let graph = match &args.command { + // Merge can't handle the same input handling as the rest of the commands + Command::Merge(merge_args) => { + let mut files = Vec::new(); + if let Some(input) = &args.input { + files.push(input); + } + files.extend(&merge_args.files); + if files.len() < 2 { + eyre::bail!("merge requires at least 2 input files"); + } + let mut graphs = Vec::new(); + for file in &files { + graphs.push(read_graph(Some(file), args.input_format)?); + } + transform::merge::merge(&graphs) + } + command => { + let graph = read_graph(args.input.as_ref(), args.input_format)?; + tracing::info!( + "Parsed graph with {} nodes, {} edges, and {} subgraphs", + graph.all_nodes().len(), + graph.all_edges().len(), + graph.subgraphs.len() + ); + + match command { + Command::Reverse => transform::reverse::reverse(&graph), + Command::Simplify => transform::simplify::simplify(&graph)?, + Command::Shorten(shorten_args) => { + let transforms = transform::shorten::build_transforms(shorten_args); + transform::shorten::shorten( + &graph, + &shorten_args.separator, + shorten_args.key, + &transforms, + ) + } + Command::Sub(sub_args) => { + let substitution = Substitution::parse(&sub_args.expr)?; + let key = SubKey::parse(&sub_args.key)?; + transform::sub::sub(&graph, &substitution, &key) + } + Command::Flatten => transform::flatten::flatten(&graph), + Command::Merge(_) => unreachable!(), + } + } + }; + + let mut output = get_output_writer(&output_path)?; + deptangle_io::emit::emit(output_format, &graph, &mut output)?; + + Ok(()) +} + +/// Read and parse a graph from a file path (or stdin if None / "-"). +fn read_graph(path: Option<&PathBuf>, input_format: Option) -> eyre::Result { + let is_stdio = |p: &PathBuf| p.as_os_str() == "-"; + let file_path: Option = path.filter(|p| !is_stdio(p)).cloned(); + let mut reader = get_input_reader(&file_path)?; + let mut text = String::new(); + reader.read_to_string(&mut text)?; + let fmt = deptangle_io::parse::resolve_input_format(input_format, file_path.as_deref(), &text)?; + deptangle_io::parse::parse(fmt, &text) +} diff --git a/crates/deptangle-cli/src/bin/graphdiff.rs b/crates/deptangle-cli/src/bin/graphdiff.rs new file mode 100644 index 0000000..7e20d57 --- /dev/null +++ b/crates/deptangle-cli/src/bin/graphdiff.rs @@ -0,0 +1,155 @@ +use std::io::Read; +use std::path::{Path, PathBuf}; + +use clap::{Parser, Subcommand}; +use deptangle_cli::stdio::{get_input_reader, get_output_writer}; +use deptangle_graph::DepGraph; +use deptangle_io::emit::OutputFormat; +use deptangle_io::parse::InputFormat; +use deptangle_ops::diff; + +/// Compute differences between two dependency graphs. +/// +/// Takes two graph files (before and after) and produces various +/// diff representations via subcommands. +#[derive(Debug, Parser)] +#[clap(version, verbatim_doc_comment)] +struct Args { + /// Logging level + #[clap(long, default_value_t = tracing::Level::INFO)] + log_level: tracing::Level, + + /// Input format (auto-detected from extension/content if omitted) + #[clap(short = 'I', long, global = true)] + input_format: Option, + + /// Output file (stdout if '-' or omitted) + #[clap(short, long, global = true)] + output: Option, + + /// Output format (auto-detected from extension, defaults to DOT) + #[clap(short = 'O', long, global = true)] + output_format: Option, + + /// Exit with code 1 if the graphs differ + #[clap(long, global = true)] + check: bool, + + #[clap(subcommand)] + command: Command, +} + +/// Shared positional arguments for the two input graphs. +#[derive(Debug, clap::Args)] +struct Inputs { + /// The "before" graph file (use '-' for stdin) + before: PathBuf, + /// The "after" graph file (use '-' for stdin) + after: PathBuf, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Annotated graph highlighting added/removed/changed/moved nodes and edges + Annotate { + #[command(flatten)] + inputs: Inputs, + /// Group added/removed nodes into DOT cluster subgraphs + #[clap(long)] + cluster: bool, + }, + /// Tab-delimited listing of changed nodes and edges + List { + #[command(flatten)] + inputs: Inputs, + }, + /// Set difference: nodes only in the "before" graph + Subtract { + #[command(flatten)] + inputs: Inputs, + }, + /// Tab-delimited summary counts of changes + Summary { + #[command(flatten)] + inputs: Inputs, + }, +} + +fn main() -> eyre::Result<()> { + let args = Args::parse(); + deptangle_cli::init(args.log_level)?; + + let inputs = match &args.command { + Command::Annotate { inputs, .. } => inputs, + Command::List { inputs } => inputs, + Command::Subtract { inputs } => inputs, + Command::Summary { inputs } => inputs, + }; + + // Normalize Some("-") to None so we can use the filepath for auto-detecting graph format below + let is_stdio = |p: &PathBuf| p.as_os_str() == "-"; + if is_stdio(&inputs.before) && is_stdio(&inputs.after) { + eyre::bail!("at most one input can be '-' (stdin)"); + } + + let before = read_graph(&inputs.before, args.input_format)?; + let after = read_graph(&inputs.after, args.input_format)?; + + tracing::info!( + "Before: {} nodes, {} edges; After: {} nodes, {} edges", + before.all_nodes().len(), + before.all_edges().len(), + after.all_nodes().len(), + after.all_edges().len() + ); + + let graph_diff = diff::diff(&before, &after); + + let output_path = args.output.filter(|p| !is_stdio(p)); + let mut output = get_output_writer(&output_path)?; + + match &args.command { + Command::Annotate { cluster, .. } => { + let graph = diff::annotate_graph(&graph_diff, &after, *cluster); + let output_format = deptangle_io::emit::resolve_output_format( + args.output_format, + output_path.as_deref(), + )?; + deptangle_io::emit::emit(output_format, &graph, &mut output)?; + } + Command::List { .. } => { + diff::write_list(&graph_diff, &mut output)?; + } + Command::Subtract { .. } => { + let graph = diff::subtract_graph(&graph_diff, &before); + let output_format = deptangle_io::emit::resolve_output_format( + args.output_format, + output_path.as_deref(), + )?; + deptangle_io::emit::emit(output_format, &graph, &mut output)?; + } + Command::Summary { .. } => { + diff::write_summary(&graph_diff, &mut output)?; + } + } + + if args.check && graph_diff.has_changes() { + std::process::exit(1); + } + + Ok(()) +} + +/// Read and parse a graph from a file path (or stdin if "-"). +fn read_graph(path: &Path, input_format: Option) -> eyre::Result { + let file_path = if path.as_os_str() == "-" { + None + } else { + Some(path.to_path_buf()) + }; + let mut reader = get_input_reader(&file_path)?; + let mut text = String::new(); + reader.read_to_string(&mut text)?; + let fmt = deptangle_io::parse::resolve_input_format(input_format, file_path.as_deref(), &text)?; + deptangle_io::parse::parse(fmt, &text) +} diff --git a/crates/deptangle-cli/src/bin/minpath.rs b/crates/deptangle-cli/src/bin/minpath.rs new file mode 100644 index 0000000..a6d418f --- /dev/null +++ b/crates/deptangle-cli/src/bin/minpath.rs @@ -0,0 +1,147 @@ +use std::path::{Path, PathBuf}; + +use clap::Parser; +use deptangle_minpath::ShortenedPaths; + +/// Given a list of file paths, shrink each of them to the shortest unique path +/// +/// The given paths do not need to exist on the filesystem. +#[derive(Debug, Parser)] +#[clap(version, verbatim_doc_comment)] +struct Args { + #[clap(short, long, default_value_t = tracing::Level::INFO)] + log_level: tracing::Level, + + /// Do not replace `/home/` paths with `~` + #[clap(short = 'T', long)] + no_tilde: bool, + + /// Do not try to resolve relative paths + /// + /// The filesystem won't be accessed, so not all relative paths can be resolved. + #[clap(short = 'R', long)] + no_resolve_relative: bool, + + /// Do not strip path prefix until a unique suffix is found + #[clap(short = 'M', long)] + no_minimal_suffix: bool, + + /// Shorten directory path components to single-letter abbreviations + #[clap(short = 's', long)] + single_letter: bool, + + /// Abbreviate source -> src, Documents -> docs, etcs + #[clap(short = 'a', long)] + smart_abbreviate: bool, + + /// Remove the given prefix if found; may be specified multiple times + #[clap(short = 'p', long)] + prefix: Vec, + + /// Make paths relative to the specified ancestor + #[clap(short, long)] + relative_to: Option, + + /// Sort and uniquify the output paths + /// + /// By default the output paths will be in the same order as the input paths, and are allowed + /// to contain duplicates. + #[clap(long)] + sort: bool, + + /// Only output the shortened paths for the given patterns + /// + /// If not given, all input paths will be shortened and output. + /// + /// May be given multiple times. Supports `**`, `*`, `?`, `{glob1,glob2}`, `[az]` glob + /// patterns. Patterns are matched against the full-length input paths before any + /// transformations are applied. + #[clap(long)] + select: Vec, + + /// Exclude the given patterns from the output + /// + /// If not given, all input paths matching the --select patterns will be shortnened and output. + /// + /// May be given multiple times. Supports `**`, `*`, `?`, `{glob1,glob2}`, `[az]` glob + /// patterns. Patterns are matched against the full-length input paths before any + /// transformations are applied. + #[clap(short = 'x', long)] + exclude: Vec, + + /// Input paths; if not given, read from stdin + input: Vec, +} + +fn sort_and_filter<'a>( + shortened: &'a ShortenedPaths, + sort: bool, + select: &'a globset::GlobSet, + exclude: &'a globset::GlobSet, +) -> Vec<&'a Path> { + let mut pairs: Vec<_> = shortened.iter().collect(); + + if sort { + // Sort and dedup by the shortened path + pairs.sort_unstable_by(|a, b| a.1.cmp(b.1)); + pairs.dedup_by(|a, b| a.1 == b.1); + } + + pairs + .into_iter() + .filter_map(|(original, short)| { + // An empty GlobSet matches nothing + if select.is_empty() || select.is_match(original) { + if exclude.is_match(original) { + return None; + } + Some(short) + } else { + None + } + }) + .collect() +} + +fn main() -> eyre::Result<()> { + let args = Args::parse(); + deptangle_cli::init(args.log_level)?; + + let reader = std::io::BufReader::new(std::io::stdin().lock()); + // Read once and held in memory; none of the transforms will modify the inputs since we likely + // need to iterate over them multiple times during different transformations. + let inputs = deptangle_cli::stdio::read_inputs(&args.input, reader)?; + + // User-specified prefixes are removed first, before any other transforms, so they are applied + // to the untransformed paths rather than hidden intermediate transforms. + let transforms = deptangle_minpath::PathTransforms::new() + .strip_prefix(args.prefix.clone()) + .home_dir(!args.no_tilde) + .resolve_relative(!args.no_resolve_relative) + .relative_to(args.relative_to.as_ref()) + .smart_abbreviate(args.smart_abbreviate) + .strip_common_prefix(true) + .minimal_unique_suffix(!args.no_minimal_suffix) + .single_letter(args.single_letter); + + let shortened = transforms.build(&inputs); + + let mut selector = globset::GlobSet::builder(); + for pattern in &args.select { + selector.add(globset::Glob::new(pattern)?); + } + let selector = selector.build()?; + + let mut excluder = globset::GlobSetBuilder::new(); + for pattern in &args.exclude { + excluder.add(globset::Glob::new(pattern)?); + } + let excluder = excluder.build()?; + + let filtered = sort_and_filter(&shortened, args.sort, &selector, &excluder); + for path in filtered { + println!("{}", path.display()); + } + + Ok(()) +} diff --git a/crates/deptangle-cli/src/lib.rs b/crates/deptangle-cli/src/lib.rs new file mode 100644 index 0000000..722f306 --- /dev/null +++ b/crates/deptangle-cli/src/lib.rs @@ -0,0 +1,26 @@ +use std::io::IsTerminal; + +pub mod stdio; + +/// Install the color-eyre error report handler and initialize stderr logging. +/// +/// The default log level filter can be overridden with the `DEPTANGLE_LOG` environment variable. +/// Color is enabled only when stderr is a terminal. +pub fn init(log_level: tracing::Level) -> eyre::Result<()> { + let use_color = std::io::stderr().is_terminal(); + if use_color { + color_eyre::install()?; + } + + let filter = tracing_subscriber::EnvFilter::builder() + .with_default_directive(log_level.into()) + .with_env_var("DEPTANGLE_LOG") + .from_env_lossy(); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_ansi(use_color) + .with_writer(std::io::stderr) + .init(); + + Ok(()) +} diff --git a/crates/deptangle-cli/src/stdio.rs b/crates/deptangle-cli/src/stdio.rs new file mode 100644 index 0000000..ed0c498 --- /dev/null +++ b/crates/deptangle-cli/src/stdio.rs @@ -0,0 +1,124 @@ +use std::fs::File; +use std::io::{BufRead, Read, Write}; +use std::path::PathBuf; + +use eyre::WrapErr; + +/// Get a writer for the given path. +/// +/// If `-` or if `None`, use stdout, otherwise use the given file +/// +/// The generated writer is _not_ buffered, because `csv::Writer` is buffered +pub fn get_output_writer(output: &Option) -> eyre::Result> { + match output { + None => Ok(Box::new(std::io::stdout())), + Some(path) if path.as_os_str() == "-" => Ok(Box::new(std::io::stdout())), + Some(path) => { + let file = + File::create(path).wrap_err(format!("Failed to create output file: {path:?}"))?; + Ok(Box::new(file)) + } + } +} + +/// Get a reader for the given path. +/// +/// If `-` or if `None`, use stdin, otherwise use the given file +pub fn get_input_reader(input: &Option) -> eyre::Result> { + match input { + None => Ok(Box::new(std::io::stdin())), + Some(path) if path.as_os_str() == "-" => Ok(Box::new(std::io::stdin())), + Some(path) => { + let file = File::open(path).wrap_err(format!("Failed to open input file: {path:?}"))?; + Ok(Box::new(file)) + } + } +} + +/// Read paths from a reader, one per line, trimming whitespace and skipping empty lines +pub fn read_paths_from_reader(reader: R) -> eyre::Result> { + let mut paths = Vec::new(); + for line in reader.lines() { + let line = line?; + let line = line.trim(); + if !line.is_empty() { + paths.push(PathBuf::from(line)); + } + } + Ok(paths) +} + +/// Read paths from a slice of input paths and/or a reader +/// +/// If the inputs slice is empty, read from the reader. Otherwise, for each input: +/// - If the input is "-", read paths from the reader at that position +/// - Otherwise, use the input path as-is +/// +/// This allows interleaving stdin with explicit paths, e.g. `file1.rs - file2.rs` +pub fn read_inputs( + inputs: &[PathBuf], + mut stdin_reader: R, +) -> eyre::Result> { + let mut paths = Vec::with_capacity(inputs.len()); + + if !inputs.is_empty() { + for input in inputs { + if input.as_os_str() == "-" { + paths.extend(read_paths_from_reader(&mut stdin_reader)?); + } else { + paths.push(input.clone()); + } + } + } else { + paths.extend(read_paths_from_reader(stdin_reader)?); + } + Ok(paths) +} + +#[cfg(test)] +mod tests { + use std::io::BufReader; + + use super::*; + + #[test] + fn test_read_inputs_empty_args_reads_stdin() { + let input = "/foo/bar.rs\n/baz/qux.rs\n"; + let reader = BufReader::new(input.as_bytes()); + let paths = read_inputs(&[], reader).unwrap(); + assert_eq!( + paths, + vec![PathBuf::from("/foo/bar.rs"), PathBuf::from("/baz/qux.rs")] + ); + } + + #[test] + fn test_read_inputs_with_args_only() { + let input = "from_stdin.rs\n"; + let reader = BufReader::new(input.as_bytes()); + let args = vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")]; + let paths = read_inputs(&args, reader).unwrap(); + // from_stdin.rs was ignored + assert_eq!(paths, vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")]); + } + + #[test] + fn test_read_inputs_interleaving() { + let input = "/from/stdin.rs\n"; + let reader = BufReader::new(input.as_bytes()); + let args = vec![ + PathBuf::from("a.rs"), + PathBuf::from("-"), + PathBuf::from("b.rs"), + ]; + let paths = read_inputs(&args, reader).unwrap(); + assert_eq!( + paths, + vec![ + PathBuf::from("a.rs"), + PathBuf::from("/from/stdin.rs"), + PathBuf::from("b.rs") + ] + ); + } +} diff --git a/crates/deptangle-cli/tests/depcluster.rs b/crates/deptangle-cli/tests/depcluster.rs new file mode 100644 index 0000000..19862f1 --- /dev/null +++ b/crates/deptangle-cli/tests/depcluster.rs @@ -0,0 +1,253 @@ +use deptangle_test::prelude::*; +use pretty_assertions::assert_eq; + +/// Two disconnected components: a-b and c-d. +const TWO_COMPONENTS: &str = "1\ta\n2\tb\n3\tc\n4\td\n#\n1\t2\n3\t4\n"; + +/// Single clique: a -> b -> c -> a (all connected). +const SINGLE_CLIQUE: &str = "1\ta\n2\tb\n3\tc\n#\n1\t2\n2\t3\n3\t1\n"; + +#[test] +fn lpa_two_disconnected_components() { + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "lpa"]) + .write_stdin(TWO_COMPONENTS) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_0 { + \"1\" [label=\"a\"]; + \"2\" [label=\"b\"]; + \"1\" -> \"2\"; + } + subgraph cluster_1 { + \"3\" [label=\"c\"]; + \"4\" [label=\"d\"]; + \"3\" -> \"4\"; + } +} +" + ); +} + +#[test] +fn lpa_single_clique() { + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "lpa"]) + .write_stdin(SINGLE_CLIQUE) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_0 { + \"1\" [label=\"a\"]; + \"2\" [label=\"b\"]; + \"3\" [label=\"c\"]; + \"1\" -> \"2\"; + \"2\" -> \"3\"; + \"3\" -> \"1\"; + } +} +" + ); +} + +#[test] +fn lpa_empty_graph() { + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "lpa"]) + .write_stdin("") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "digraph {\n}\n"); +} + +#[test] +fn lpa_directed_flag() { + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "lpa", "--directed"]) + .write_stdin(TWO_COMPONENTS) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // With --directed, should still find two components (no cross edges) + assert!(stdout.contains("cluster_0")); + assert!(stdout.contains("cluster_1")); +} + +#[test] +fn lpa_seed_flag() { + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "lpa", "--seed", "42"]) + .write_stdin(TWO_COMPONENTS) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Two disconnected components should still produce two clusters + assert!(stdout.contains("cluster_0")); + assert!(stdout.contains("cluster_1")); +} + +#[test] +fn lpa_max_iter_flag() { + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "lpa", "--max-iter", "1"]) + .write_stdin(TWO_COMPONENTS) + .captured_output(); + assert!(output.status.success()); +} + +#[test] +fn louvain_two_disconnected_components() { + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "louvain"]) + .write_stdin(TWO_COMPONENTS) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Should produce two clusters with all four nodes and no cross edges + assert!(stdout.contains("cluster_0")); + assert!(stdout.contains("cluster_1")); + assert!(!stdout.contains("cluster_2")); + for node in &["\"1\"", "\"2\"", "\"3\"", "\"4\""] { + assert!(stdout.contains(node), "missing node {node}"); + } +} + +#[test] +fn louvain_resolution_flag() { + let output = tool!("depcluster") + .args([ + "--input-format", + "tgf", + "-a", + "louvain", + "--resolution", + "0.5", + ]) + .write_stdin(TWO_COMPONENTS) + .captured_output(); + assert!(output.status.success()); +} + +#[test] +fn louvain_seed_flag() { + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "louvain", "--seed", "123"]) + .write_stdin(TWO_COMPONENTS) + .captured_output(); + assert!(output.status.success()); +} + +#[test] +fn louvain_empty_graph() { + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "louvain"]) + .write_stdin("") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "digraph {\n}\n"); +} + +#[test] +fn leiden_accepts_input() { + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "leiden"]) + .write_stdin(TWO_COMPONENTS) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // All nodes should appear somewhere in the output + for node in &["\"1\"", "\"2\"", "\"3\"", "\"4\""] { + assert!(stdout.contains(node), "missing node {node}"); + } +} + +#[test] +fn leiden_resolution_flag() { + let output = tool!("depcluster") + .args([ + "--input-format", + "tgf", + "-a", + "leiden", + "--resolution", + "0.1", + ]) + .write_stdin(TWO_COMPONENTS) + .captured_output(); + assert!(output.status.success()); +} + +#[test] +fn leiden_empty_graph() { + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "leiden"]) + .write_stdin("") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "digraph {\n}\n"); +} + +#[test] +fn cross_cluster_edges_at_top_level() { + // Two cliques connected by one directed edge: a<->b connected to c<->d via b->c. + // In directed mode, each node only has 1 outgoing neighbor within its pair, + // so the single cross-edge b->c doesn't merge the clusters. + let input = "1\ta\n2\tb\n3\tc\n4\td\n#\n1\t2\n2\t1\n3\t4\n4\t3\n2\t3\n"; + let output = tool!("depcluster") + .args(["--input-format", "tgf", "-a", "lpa", "--directed"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_0 { + \"1\" [label=\"a\"]; + \"2\" [label=\"b\"]; + \"1\" -> \"2\"; + \"2\" -> \"1\"; + } + subgraph cluster_1 { + \"3\" [label=\"c\"]; + \"4\" [label=\"d\"]; + \"3\" -> \"4\"; + \"4\" -> \"3\"; + } + \"2\" -> \"3\"; +} +" + ); +} + +#[test] +fn output_format_tgf() { + let output = tool!("depcluster") + .args([ + "--input-format", + "tgf", + "--output-format", + "tgf", + "-a", + "lpa", + ]) + .write_stdin(TWO_COMPONENTS) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // TGF doesn't support subgraphs, but the output should still be valid + assert!(stdout.contains("#")); +} diff --git a/crates/deptangle-cli/tests/depconv.rs b/crates/deptangle-cli/tests/depconv.rs new file mode 100644 index 0000000..a862ec8 --- /dev/null +++ b/crates/deptangle-cli/tests/depconv.rs @@ -0,0 +1,884 @@ +use deptangle_test::prelude::*; +use pretty_assertions::assert_eq; + +/// Normalize whitespace for comparison: split each line into tokens, rejoin with single spaces. +fn normalize_whitespace(s: &str) -> String { + s.lines() + .map(|line| { + let tokens: Vec<&str> = line.split_whitespace().collect(); + tokens.join(" ") + }) + .collect::>() + .join("\n") +} + +#[test] +fn tgf_to_dot() { + let input = include_str!("../../../data/depconv/small.tgf"); + let output = tool!("depconv") + .args(["--input-format", "tgf", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + \"1\" [label=\"libfoo\"]; + \"2\" [label=\"libbar\"]; + \"3\" [label=\"myapp\"]; + \"3\" -> \"1\"; + \"3\" -> \"2\"; + \"1\" -> \"2\"; +} +" + ); +} + +#[test] +fn dot_to_tgf() { + let input = include_str!("../../../data/depconv/small.dot"); + let output = tool!("depconv") + .args(["--input-format", "dot", "--output-format", "tgf"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "libbar\nlibfoo\nmyapp\tMy Application\n#\nmyapp\tlibfoo\nmyapp\tlibbar\nlibfoo\tlibbar\n" + ); +} + +#[test] +fn tgf_to_dot_to_tgf_roundtrip() { + let input = "a\tAlpha\nb\tBravo\n#\na\tb\tuses\n"; + // TGF → DOT + let dot_output = tool!("depconv") + .args(["--input-format", "tgf", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(dot_output.status.success()); + let dot = String::from_utf8_lossy(&dot_output.stdout); + // DOT → TGF + let tgf_output = tool!("depconv") + .args(["--input-format", "dot", "--output-format", "tgf"]) + .write_stdin(dot.as_ref()) + .captured_output(); + assert!(tgf_output.status.success()); + let tgf = String::from_utf8_lossy(&tgf_output.stdout); + assert_eq!(tgf, input); +} + +#[test] +fn depfile_to_dot() { + let input = "main.o: main.c config.h\n"; + let output = tool!("depconv") + .args(["--input-format", "depfile", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + \"main.o\"; + \"main.c\"; + \"config.h\"; + \"main.o\" -> \"main.c\"; + \"main.o\" -> \"config.h\"; +} +" + ); +} + +#[test] +fn depfile_to_tgf() { + let input = include_str!("../../../data/depconv/small.d"); + let output = tool!("depconv") + .args(["--input-format", "depfile", "--output-format", "tgf"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + normalize_whitespace(&stdout), + normalize_whitespace( + "main.o\nmain.c\nconfig.h\nutils.h\nutils.c\nconfig.o\nconfig.c\nutils.o\n\ + #\n\ + main.o main.c\nmain.o config.h\nmain.o utils.h\nmain.o utils.c\n\ + config.o config.c\nconfig.o config.h\nutils.o utils.c\nutils.o utils.h\n" + ) + ); +} + +#[test] +fn depfile_auto_detect_content() { + let input = "main.o: main.c config.h\n"; + let output = tool!("depconv") + .args(["--output-format", "tgf"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + normalize_whitespace(&stdout), + normalize_whitespace("main.o\nmain.c\nconfig.h\n#\nmain.o main.c\nmain.o config.h\n") + ); +} + +#[test] +fn depfile_auto_detect_extension() { + // Path relative to test CWD + let fixture = "../../data/depconv/small.d"; + let output = tool!("depconv") + .args(["--output-format", "tgf", "-i", fixture]) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + normalize_whitespace(&stdout), + normalize_whitespace( + "main.o\nmain.c\nconfig.h\nutils.h\nutils.c\nconfig.o\nconfig.c\nutils.o\n\ + #\n\ + main.o main.c\nmain.o config.h\nmain.o utils.h\nmain.o utils.c\n\ + config.o config.c\nconfig.o config.h\nutils.o utils.c\nutils.o utils.h\n" + ) + ); +} + +#[test] +fn depfile_multi_target_fixture() { + let input = include_str!("../../../data/depconv/multi-target.d"); + let output = tool!("depconv") + .args(["--input-format", "depfile", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + \"src/main.o\"; + \"src/main.c\"; + \"include/config.h\"; + \"include/utils.h\"; + \"src/config.o\"; + \"src/config.c\"; + \"src/utils.o\"; + \"src/utils.c\"; + \"src/main.o\" -> \"src/main.c\"; + \"src/main.o\" -> \"include/config.h\"; + \"src/main.o\" -> \"include/utils.h\"; + \"src/config.o\" -> \"src/config.c\"; + \"src/config.o\" -> \"include/config.h\"; + \"src/utils.o\" -> \"src/utils.c\"; + \"src/utils.o\" -> \"include/utils.h\"; + \"src/utils.o\" -> \"include/config.h\"; +} +" + ); +} + +#[test] +fn depfile_roundtrip() { + let input = "main.o: main.c config.h\nutils.o: utils.c utils.h\n"; + let output = tool!("depconv") + .args(["--input-format", "depfile", "--output-format", "depfile"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, input); +} + +#[test] +fn tgf_to_depfile() { + let input = "3\tmyapp\n1\tlibfoo\n2\tlibbar\n#\n3\t1\n3\t2\n1\t2\n"; + let output = tool!("depconv") + .args(["--input-format", "tgf", "--output-format", "depfile"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "3: 1 2\n1: 2\n"); +} + +#[test] +fn pathlist_to_dot() { + let input = "src/a.rs\nsrc/b.rs\nREADME.md\n"; + let output = tool!("depconv") + .args(["--input-format", "pathlist", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + src; + \"src/a.rs\" [label=\"a.rs\"]; + \"src/b.rs\" [label=\"b.rs\"]; + \"README.md\"; + src -> \"src/a.rs\"; + src -> \"src/b.rs\"; +} +" + ); +} + +#[test] +fn pathlist_auto_detect_content() { + let input = "src/main.rs\nsrc/lib.rs\n"; + let output = tool!("depconv") + .args(["--output-format", "tgf"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "src\nsrc/main.rs\tmain.rs\nsrc/lib.rs\tlib.rs\n#\nsrc\tsrc/main.rs\nsrc\tsrc/lib.rs\n" + ); +} + +#[test] +fn tree_to_dot() { + let input = "root\n├── a\n│ └── b\n└── c\n"; + let output = tool!("depconv") + .args(["--input-format", "tree", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + root; + \"root/a\" [label=\"a\"]; + \"root/a/b\" [label=\"b\"]; + \"root/c\" [label=\"c\"]; + root -> \"root/a\"; + \"root/a\" -> \"root/a/b\"; + root -> \"root/c\"; +} +" + ); +} + +#[test] +fn tree_auto_detect_content() { + let input = "root\n├── child\n"; + let output = tool!("depconv") + .args(["--output-format", "tgf"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "root\nroot/child\tchild\n#\nroot\troot/child\n"); +} + +#[test] +fn tgf_to_tree() { + // a -> b -> c, a -> d (diamond-like with branching at root) + let input = "a\tAlpha\nb\tBravo\nc\tCharlie\nd\tDelta\n#\na\tb\na\td\nb\tc\n"; + let output = tool!("depconv") + .args(["--input-format", "tgf", "--output-format", "tree"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +Alpha +├── Bravo +│ └── Charlie +└── Delta +" + ); +} + +#[test] +fn pathlist_roundtrip() { + let input = "src/a.rs\nsrc/b.rs\nREADME.md\n"; + let output = tool!("depconv") + .args(["--input-format", "pathlist", "--output-format", "pathlist"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, input); +} + +#[test] +fn tgf_to_pathlist() { + // a -> b -> c, a -> c (diamond: c is shared) + let input = "a\tAlpha\nb\tBravo\nc\tCharlie\n#\na\tb\na\tc\nb\tc\n"; + let output = tool!("depconv") + .args(["--input-format", "tgf", "--output-format", "pathlist"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // DFS: Alpha -> Bravo -> Charlie (leaf), Alpha -> Charlie (leaf again, no subtree suppressed) + assert_eq!(stdout, "Alpha/Bravo/Charlie\nAlpha/Charlie\n"); +} + +#[test] +fn pathlist_to_pathlist_fixture() { + let input = include_str!("../../../data/depconv/gitfiles.txt"); + let output = tool!("depconv") + .args(["--input-format", "pathlist", "--output-format", "pathlist"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, input); +} + +#[test] +fn dot_to_dot() { + let input = include_str!("../../../data/depconv/small.dot"); + let output = tool!("depconv") + .args(["--input-format", "dot", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph deps { + rankdir=\"LR\"; + libbar; + libfoo; + myapp [label=\"My Application\", shape=\"box\"]; + myapp -> libfoo; + myapp -> libbar; + libfoo -> libbar; +} +" + ); +} + +#[test] +fn dot_subgraph_to_depfile() { + let input = "\ +digraph { + top -> a; + subgraph cluster0 { + a -> b; + b -> c; + } +} +"; + let output = tool!("depconv") + .args(["--input-format", "dot", "--output-format", "depfile"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "top: a\na: b\nb: c\n"); +} + +#[test] +fn cmake_dot_preserves_subgraph() { + let input = include_str!("../../../data/depconv/cmake.geos.dot"); + + // Parse -> emit -> re-parse. + let output1 = tool!("depconv") + .args(["--input-format", "dot", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output1.status.success()); + let dot1 = String::from_utf8_lossy(&output1.stdout); + + let output2 = tool!("depconv") + .args(["--input-format", "dot", "--output-format", "dot"]) + .write_stdin(dot1.as_ref()) + .captured_output(); + assert!(output2.status.success()); + let dot2 = String::from_utf8_lossy(&output2.stdout); + + // Round-trip should be stable: emit(parse(emit(parse(input)))) == emit(parse(input)). + assert_eq!(dot1, dot2); + + // Structural checks on the output: subgraph present, legend nodes inside. + assert!( + dot1.contains("subgraph clusterLegend {"), + "output should contain subgraph header" + ); + assert!( + dot1.contains("legendNode0"), + "legend nodes should be in output" + ); + + // Legend attrs should be inside the subgraph, not at top level. + // Find the subgraph block and verify label is inside it. + let sg_start = dot1.find("subgraph clusterLegend {").unwrap(); + let sg_end = dot1[sg_start..].find('}').unwrap() + sg_start; + let sg_block = &dot1[sg_start..=sg_end]; + assert!( + sg_block.contains("label=\"Legend\""), + "legend label should be inside subgraph block" + ); + + // Top-level graph name preserved. + assert!( + dot1.starts_with("digraph GEOS {"), + "graph name GEOS should be preserved" + ); +} + +#[test] +fn cargo_tree_to_dot() { + let input = "\ +myapp v1.0.0 +├── libfoo v0.2.1 +│ └── shared v1.0.0 +└── libbar v0.1.0 (proc-macro) + └── shared v1.0.0 (*) +"; + let output = tool!("depconv") + .args(["--input-format", "cargo-tree", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + \"myapp v1.0.0\" [label=\"myapp\", version=\"v1.0.0\"]; + \"libfoo v0.2.1\" [label=\"libfoo\", version=\"v0.2.1\"]; + \"shared v1.0.0\" [label=\"shared\", version=\"v1.0.0\"]; + \"libbar v0.1.0\" [label=\"libbar\", type=\"proc-macro\", version=\"v0.1.0\", shape=\"diamond\"]; + \"myapp v1.0.0\" -> \"libfoo v0.2.1\"; + \"libfoo v0.2.1\" -> \"shared v1.0.0\"; + \"myapp v1.0.0\" -> \"libbar v0.1.0\"; + \"libbar v0.1.0\" -> \"shared v1.0.0\"; +} +" + ); +} + +#[test] +fn cargo_tree_auto_detect() { + let input = include_str!("../../../data/depconv/cargo-tree.txt"); + let output = tool!("depconv") + .args(["--output-format", "tgf"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + + // Split TGF into node and edge sections + let sections: Vec<&str> = stdout.splitn(2, "\n#\n").collect(); + assert_eq!( + sections.len(), + 2, + "TGF output should have node and edge sections" + ); + let node_lines: Vec<&str> = sections[0].lines().collect(); + let edge_lines: Vec<&str> = sections[1].lines().filter(|l| !l.is_empty()).collect(); + + // Verify node and edge counts + assert_eq!(node_lines.len(), 69); + assert_eq!(edge_lines.len(), 111); + + // Root node should be first (spaces in IDs become underscores in TGF) + assert!( + node_lines[0].starts_with("deptangle-depgraph_v0.5.0\t"), + "root node should be first, got: {}", + node_lines[0] + ); + + // A known dependency should appear as a node + assert!( + node_lines.iter().any(|l| l.starts_with("clap_v4.5.57\t")), + "clap should be in node list" + ); + + // A known edge should exist + assert!( + edge_lines.contains(&"deptangle-depgraph_v0.5.0\tclap_v4.5.57"), + "root -> clap edge should exist" + ); +} + +#[test] +fn cargo_metadata_to_dot() { + // Test with the real cargo-metadata.json fixture + let input = include_str!("../../../data/depconv/cargo-metadata.json"); + let output = tool!("depconv") + .args(["--input-format", "cargo-metadata", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + + // Count nodes and edges for structural verification + let node_count = stdout.lines().filter(|l| l.contains("[label=")).count(); + let edge_count = stdout.lines().filter(|l| l.contains("->")).count(); + assert_eq!(node_count, 143); + assert_eq!(edge_count, 292); + + // Verify DOT wrapper + assert!(stdout.starts_with("digraph {\n")); + assert!(stdout.ends_with("}\n")); + + // Verify deptangle-depgraph node with exact attribute line + assert_eq!( + stdout + .lines() + .find(|l| l.contains("deptangle-depgraph 0.5.0") && l.contains("[label=")) + .unwrap() + .trim(), + "\"deptangle-depgraph 0.5.0\" [label=\"deptangle-depgraph\", type=\"lib\", \ + version=\"0.5.0\", features=\"default,dot\", shape=\"ellipse\"];" + ); + + // Verify optional dependencies have exact edge attributes + assert_eq!( + stdout + .lines() + .find(|l| l.contains("dot-parser 0.6.1") && l.contains("->")) + .unwrap() + .trim(), + "\"deptangle-depgraph 0.5.0\" -> \"dot-parser 0.6.1\" [kind=\"normal\", optional=\"dot\"];" + ); + + // Verify proc-macro node with exact attribute line + assert_eq!( + stdout + .lines() + .find(|l| l.contains("clap_derive 4.5.55") && l.contains("[label=")) + .unwrap() + .trim(), + "\"clap_derive 4.5.55\" [label=\"clap_derive\", type=\"proc-macro\", \ + version=\"4.5.55\", features=\"default\", shape=\"diamond\"];" + ); + + // Verify dev dependency edge has styling + assert_eq!( + stdout + .lines() + .find(|l| l.contains("deptangle-depgraph") + && l.contains("deptangle-test") + && l.contains("->")) + .unwrap() + .trim(), + "\"deptangle-depgraph 0.5.0\" -> \"deptangle-test 0.5.0\" \ + [kind=\"dev\", style=\"dashed\", color=\"gray60\"];" + ); + + // Verify regular dependency edge (no optional, no styling) + assert_eq!( + stdout + .lines() + .find(|l| l.contains("deptangle-depgraph") && l.contains("-> \"eyre")) + .unwrap() + .trim(), + "\"deptangle-depgraph 0.5.0\" -> \"eyre 0.6.12\" [kind=\"normal\"];" + ); +} + +#[test] +fn dot_roundtrip_with_type() { + let input = r#"digraph { + a [label="A", type="lib"]; + b [label="B", type="proc-macro"]; + a -> b; +} +"#; + + let expected = "\ +digraph { + a [label=\"A\", type=\"lib\", shape=\"ellipse\"]; + b [label=\"B\", type=\"proc-macro\", shape=\"diamond\"]; + a -> b; +} +"; + + // Parse DOT -> emit DOT + let output1 = tool!("depconv") + .args(["--input-format", "dot", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output1.status.success()); + let stdout1 = String::from_utf8_lossy(&output1.stdout); + assert_eq!(stdout1, expected); + + // Parse again to verify round-trip stability + let output2 = tool!("depconv") + .args(["--input-format", "dot", "--output-format", "dot"]) + .write_stdin(stdout1.as_ref()) + .captured_output(); + assert!(output2.status.success()); + let stdout2 = String::from_utf8_lossy(&output2.stdout); + assert_eq!(stdout2, expected); +} + +#[test] +fn tgf_to_mermaid() { + let input = "a\talpha\nb\tbravo\nc\n#\na\tb\tdepends\nb\tc\na\tc\n"; + let output = tool!("depconv") + .args(["--input-format", "tgf", "--output-format", "mermaid"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +flowchart LR + a[\"alpha\"] + b[\"bravo\"] + c[\"c\"] + a -->|\"depends\"| b + b --> c + a --> c +" + ); +} + +#[test] +fn mermaid_node_types() { + let input = r#"digraph { + lib1 [label="Library", type="lib"]; + bin1 [label="Binary", type="bin"]; + pm1 [label="Proc Macro", type="proc-macro"]; + bs1 [label="Build Script", type="build-script"]; + test1 [label="Test", type="test"]; + lib1 -> bin1; +} +"#; + let output = tool!("depconv") + .args(["--input-format", "dot", "--output-format", "mermaid"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +flowchart LR + bin1[\"Binary\"] + bs1[/\"Build Script\"/] + lib1([\"Library\"]) + pm1{\"Proc Macro\"} + test1{{\"Test\"}} + lib1 --> bin1 +" + ); +} + +#[test] +fn dot_to_mermaid_with_subgraphs() { + let input = r#"digraph deps { + rankdir=TB; + subgraph cluster_backend { + label="Backend"; + api [label="API Server"]; + db [label="Database"]; + } + subgraph cluster_frontend { + label="Frontend"; + web [label="Web App"]; + mobile [label="Mobile App"]; + } + web -> api; + mobile -> api; + api -> db [label="queries"]; +} +"#; + let output = tool!("depconv") + .args(["--input-format", "dot", "--output-format", "mermaid"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Verify flowchart direction from rankdir + assert!(stdout.starts_with("flowchart TB\n")); + // Verify subgraphs are preserved + assert!(stdout.contains("subgraph cluster_backend")); + assert!(stdout.contains("subgraph cluster_frontend")); + // Verify nodes are in subgraphs + assert!(stdout.contains("api[\"API Server\"]")); + assert!(stdout.contains("db[\"Database\"]")); + assert!(stdout.contains("web[\"Web App\"]")); + assert!(stdout.contains("mobile[\"Mobile App\"]")); + // Verify edge labels + assert!(stdout.contains("api -->|\"queries\"| db")); +} + +#[test] +fn mermaid_special_chars() { + let input = "a\tLabel [with] \"quotes\"\nb\tOther{label}\n#\na\tb\tuses|pipes\n"; + let output = tool!("depconv") + .args(["--input-format", "tgf", "--output-format", "mermaid"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +flowchart LR + a[\"Label [with] "quotes"\"] + b[\"Other{label}\"] + a -->|\"uses|pipes\"| b +" + ); +} + +#[test] +fn depfile_to_mermaid() { + let input = "main.o: main.c config.h\n"; + let output = tool!("depconv") + .args(["--input-format", "depfile", "--output-format", "mermaid"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +flowchart LR + main.o[\"main.o\"] + main.c[\"main.c\"] + config.h[\"config.h\"] + main.o --> main.c + main.o --> config.h +" + ); +} + +#[test] +fn mermaid_to_tgf() { + let input = "flowchart LR\n A[myapp] --> B[libfoo]\n A --> C[libbar]\n B --> C\n"; + let output = tool!("depconv") + .args(["--input-format", "mermaid", "--output-format", "tgf"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "A\tmyapp\nB\tlibfoo\nC\tlibbar\n#\nA\tB\nA\tC\nB\tC\n" + ); +} + +#[test] +fn mermaid_to_dot() { + let input = "flowchart LR\n A[myapp] --> B[libfoo]\n A --> C[libbar]\n B --> C\n"; + let output = tool!("depconv") + .args(["--input-format", "mermaid", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + direction=\"LR\"; + A [label=\"myapp\"]; + B [label=\"libfoo\"]; + C [label=\"libbar\"]; + A -> B; + A -> C; + B -> C; +} +" + ); +} + +#[test] +fn mermaid_auto_detect() { + let input = "flowchart LR\n A[myapp] --> B[libfoo]\n"; + let output = tool!("depconv") + .args(["--output-format", "tgf"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "A\tmyapp\nB\tlibfoo\n#\nA\tB\n"); +} + +#[test] +fn mermaid_roundtrip() { + let input = "flowchart LR\n A[myapp] --> B[libfoo]\n A --> C[libbar]\n B --> C\n"; + // parse mermaid -> emit mermaid + let output1 = tool!("depconv") + .args(["--input-format", "mermaid", "--output-format", "mermaid"]) + .write_stdin(input) + .captured_output(); + assert!(output1.status.success()); + let mmd1 = String::from_utf8_lossy(&output1.stdout); + + // parse again -> emit again, should be stable + let output2 = tool!("depconv") + .args(["--input-format", "mermaid", "--output-format", "mermaid"]) + .write_stdin(mmd1.as_ref()) + .captured_output(); + assert!(output2.status.success()); + let mmd2 = String::from_utf8_lossy(&output2.stdout); + + assert_eq!(mmd1, mmd2); +} + +#[test] +fn mermaid_subgraph_to_dot() { + let input = include_str!("../../../data/depconv/subgraph.mmd"); + let output = tool!("depconv") + .args(["--input-format", "mermaid", "--output-format", "dot"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + direction=\"TD\"; + subgraph cluster_backend { + label=\"backend\"; + api [label=\"API Server\"]; + db [label=\"Database\"]; + cache [label=\"Redis Cache\"]; + } + subgraph cluster_frontend { + label=\"frontend\"; + web [label=\"Web App\"]; + mobile [label=\"Mobile App\"]; + } + web -> api; + mobile -> api; + api -> db; + api -> cache; +} +" + ); +} + +#[test] +fn mermaid_edge_labels_to_tgf() { + let input = include_str!("../../../data/depconv/flowchart.mmd"); + let output = tool!("depconv") + .args(["--input-format", "mermaid", "--output-format", "tgf"]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "A\tmyapp\nB\tlibfoo\nC\tlibbar\n#\nA\tB\tstatic\nA\tC\tdynamic\nB\tC\n" + ); +} diff --git a/crates/deptangle-cli/tests/depfilter.rs b/crates/deptangle-cli/tests/depfilter.rs new file mode 100644 index 0000000..5161d62 --- /dev/null +++ b/crates/deptangle-cli/tests/depfilter.rs @@ -0,0 +1,868 @@ +use deptangle_test::prelude::*; +use pretty_assertions::assert_eq; + +// Test graph: myapp -> libfoo -> libbar +// myapp -> libbar +const SIMPLE_GRAPH: &str = "1\tlibfoo\n2\tlibbar\n3\tmyapp\n#\n3\t1\n3\t2\n1\t2\n"; + +#[test] +fn select_single_pattern() { + let output = tool!("depfilter") + .args([ + "select", + "--include", + "lib*", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "1\tlibfoo\n2\tlibbar\n#\n1\t2\n"); +} + +#[test] +fn select_by_id() { + let output = tool!("depfilter") + .args([ + "select", + "--include", + "1", + "--key", + "id", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "1\tlibfoo\n#\n"); +} + +#[test] +fn select_with_deps() { + // Select myapp and include all its dependencies + let output = tool!("depfilter") + .args([ + "select", + "--include", + "myapp", + "--deps", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Should include myapp, libfoo, and libbar with all edges + assert_eq!(stdout, SIMPLE_GRAPH); +} + +#[test] +fn select_with_rdeps() { + // Select libbar and include all nodes that depend on it + let output = tool!("depfilter") + .args([ + "select", + "--include", + "libbar", + "--rdeps", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Should include libbar, libfoo (depends on libbar), and myapp (depends on libbar) + assert_eq!(stdout, SIMPLE_GRAPH); +} + +#[test] +fn select_with_depth() { + // Create a deeper graph for depth testing + // a -> b -> c -> d + let deep_graph = "a\nb\nc\nd\n#\na\tb\nb\tc\nc\td\n"; + let output = tool!("depfilter") + .args([ + "select", + "--include", + "a", + "--deps", + "--depth", + "1", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(deep_graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Should include only a and b (1 level deep) + assert_eq!(stdout, "a\nb\n#\na\tb\n"); +} + +#[test] +fn select_depth_from_roots() { + // a -> b -> c -> d: no pattern, depth 1 seeds from roots (a), keeps a and b + let deep_graph = "a\nb\nc\nd\n#\na\tb\nb\tc\nc\td\n"; + let output = tool!("depfilter") + .args([ + "select", + "--depth", + "1", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(deep_graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\n#\na\tb\n"); +} + +#[test] +fn select_multiple_patterns_and() { + // Graph with nodes that match multiple criteria + let graph = "libfoo-alpha\nlibfoo-beta\nlibbar-alpha\n#\n"; + let output = tool!("depfilter") + .args([ + "select", + "--include", + "libfoo*", + "--include", + "*alpha", + "--and", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Should include only libfoo-alpha (matches both patterns) + assert_eq!(stdout, "libfoo-alpha\n#\n"); +} + +#[test] +fn select_with_deps_and_rdeps() { + // a -> b -> c -> d: select b with both directions gets everything + let graph = "a\nb\nc\nd\n#\na\tb\nb\tc\nc\td\n"; + let output = tool!("depfilter") + .args([ + "select", + "--include", + "b", + "--deps", + "--rdeps", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\nd\n#\na\tb\nb\tc\nc\td\n"); +} + +#[test] +fn exclude_single_pattern() { + let output = tool!("depfilter") + .args([ + "select", + "--exclude", + "libfoo", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "2\tlibbar\n3\tmyapp\n#\n3\t2\n"); +} + +#[test] +fn exclude_by_id() { + let output = tool!("depfilter") + .args([ + "select", + "--exclude", + "1", + "--key", + "id", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "2\tlibbar\n3\tmyapp\n#\n3\t2\n"); +} + +#[test] +fn exclude_with_preserve_connectivity() { + let chain_graph = "a\nb\nc\n#\na\tb\nb\tc\n"; + let output = tool!("depfilter") + .args([ + "select", + "--exclude", + "b", + "--preserve-connectivity", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(chain_graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nc\n#\na\tc\n"); +} + +#[test] +fn exclude_multiple_patterns() { + let graph = "a\nb\nc\nd\n#\na\tb\nb\tc\nc\td\n"; + let output = tool!("depfilter") + .args([ + "select", + "--exclude", + "b", + "--exclude", + "c", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nd\n#\n"); +} + +#[test] +fn select_dot_output() { + let output = tool!("depfilter") + .args([ + "select", + "--include", + "lib*", + "--input-format", + "tgf", + "--output-format", + "dot", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + \"1\" [label=\"libfoo\"]; + \"2\" [label=\"libbar\"]; + \"1\" -> \"2\"; +} +" + ); +} + +#[test] +fn exclude_preserve_connectivity_subgraph() { + // subgraph { a -> b -> c }: exclude b, bypass a -> c stays in subgraph + let dot_input = "\ +digraph { + subgraph cluster_0 { + a; + b; + c; + a -> b; + b -> c; + } +} +"; + let output = tool!("depfilter") + .args([ + "select", + "--exclude", + "b", + "--preserve-connectivity", + "--input-format", + "dot", + "--output-format", + "dot", + ]) + .write_stdin(dot_input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_0 { + a; + c; + a -> c; + } +} +" + ); +} + +#[test] +fn exclude_dot_input() { + let dot_input = "\ +digraph { + \"1\" [label=\"libfoo\"]; + \"2\" [label=\"libbar\"]; + \"3\" [label=\"myapp\"]; + \"3\" -> \"1\"; + \"3\" -> \"2\"; + \"1\" -> \"2\"; +} +"; + let output = tool!("depfilter") + .args([ + "select", + "--exclude", + "libfoo", + "--input-format", + "dot", + "--output-format", + "tgf", + ]) + .write_stdin(dot_input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "2\tlibbar\n3\tmyapp\n#\n3\t2\n"); +} + +#[test] +fn include_with_exclude() { + // a -> b -> c -> d: include a with deps, exclude c + let graph = "a\nb\nc\nd\n#\na\tb\nb\tc\nc\td\n"; + let output = tool!("depfilter") + .args([ + "select", + "-g", + "a", + "--deps", + "-x", + "c", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nd\n#\na\tb\n"); +} + +#[test] +fn between_two_nodes() { + // a -> b -> c: between a and c includes intermediate b + let graph = "a\nb\nc\n#\na\tb\nb\tc\n"; + let output = tool!("depfilter") + .args([ + "between", + "-g", + "a", + "-g", + "c", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\n#\na\tb\nb\tc\n"); +} + +#[test] +fn between_by_id() { + let output = tool!("depfilter") + .args([ + "between", + "-g", + "1", + "-g", + "2", + "--key", + "id", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "1\tlibfoo\n2\tlibbar\n#\n1\t2\n"); +} + +#[test] +fn between_glob_multiple_nodes() { + // a -> b -> c: glob "?" matches all three, paths exist between all pairs + let graph = "a\nb\nc\n#\na\tb\nb\tc\n"; + let output = tool!("depfilter") + .args([ + "between", + "-g", + "?", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\n#\na\tb\nb\tc\n"); +} + +#[test] +fn between_no_matching_patterns() { + let graph = "a\nb\n#\na\tb\n"; + let output = tool!("depfilter") + .args([ + "between", + "-g", + "nonexistent", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "#\n"); +} + +#[test] +fn between_no_path() { + // a -> b, c -> d: no path between a and c + let graph = "a\nb\nc\nd\n#\na\tb\nc\td\n"; + let output = tool!("depfilter") + .args([ + "between", + "-g", + "a", + "-g", + "c", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "#\n"); +} + +#[test] +fn between_cargo_metadata_fixture() { + // Use the real cargo-metadata.json fixture to test between on a non-trivial graph. + // deptangle-depgraph depends on clap both directly and via deptangle-utils, + // so deptangle-utils is an intermediate node on a path to clap. + // clap in turn depends on clap_builder, clap_derive, and clap_builder -> clap_lex. + let input = include_str!("../../../data/depconv/cargo-metadata.json"); + let output = tool!("depfilter") + .args([ + "between", + "-g", + "deptangle-depgraph", + "-g", + "clap*", + "--input-format", + "cargo-metadata", + "--output-format", + "tgf", + ]) + .write_stdin(input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +clap_4.5.57\tclap +clap_builder_4.5.57\tclap_builder +clap_derive_4.5.55\tclap_derive +clap_lex_0.7.7\tclap_lex +deptangle-depgraph_0.5.0\tdeptangle-depgraph +deptangle-utils_0.5.0\tdeptangle-utils +# +clap_4.5.57\tclap_builder_4.5.57 +clap_4.5.57\tclap_derive_4.5.55 +clap_builder_4.5.57\tclap_lex_0.7.7 +deptangle-depgraph_0.5.0\tclap_4.5.57 +deptangle-depgraph_0.5.0\tdeptangle-utils_0.5.0 +deptangle-utils_0.5.0\tclap_4.5.57 +" + ); +} + +#[test] +fn cycles_dag_no_cycles() { + // a -> b -> c: no cycles, empty output + let graph = "a\nb\nc\n#\na\tb\nb\tc\n"; + let output = tool!("depfilter") + .args(["cycles", "--input-format", "tgf", "--output-format", "tgf"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "#\n"); +} + +#[test] +fn cycles_simple_three_node() { + // a -> b -> c -> a: single cycle with all three nodes + let graph = "a\nb\nc\n#\na\tb\nb\tc\nc\ta\n"; + let output = tool!("depfilter") + .args(["cycles", "--input-format", "tgf", "--output-format", "dot"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_cycle_0 { + label=\"cycle_0\"; + a; + b; + c; + a -> b; + b -> c; + c -> a; + } +} +" + ); +} + +#[test] +fn cycles_multiple_disjoint() { + // cycle1: a <-> b, cycle2: c <-> d (no edges between them) + let graph = "a\nb\nc\nd\n#\na\tb\nb\ta\nc\td\nd\tc\n"; + let output = tool!("depfilter") + .args(["cycles", "--input-format", "tgf", "--output-format", "dot"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Both cycles appear as separate subgraphs, no cross-cycle edges. + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_cycle_0 { + label=\"cycle_0\"; + a; + b; + a -> b; + b -> a; + } + subgraph cluster_cycle_1 { + label=\"cycle_1\"; + c; + d; + c -> d; + d -> c; + } +} +" + ); +} + +#[test] +fn cycles_mixed_graph_excludes_acyclic() { + // x -> a <-> b -> y: only a and b form a cycle; x and y excluded + let graph = "x\na\nb\ny\n#\nx\ta\na\tb\nb\ta\nb\ty\n"; + let output = tool!("depfilter") + .args(["cycles", "--input-format", "tgf", "--output-format", "dot"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_cycle_0 { + label=\"cycle_0\"; + a; + b; + a -> b; + b -> a; + } +} +" + ); +} + +#[test] +fn cycles_cross_cycle_edges_at_top_level() { + // cycle1: a <-> b, cycle2: c <-> d, cross-edge: b -> c + let graph = "a\nb\nc\nd\n#\na\tb\nb\ta\nc\td\nd\tc\nb\tc\n"; + let output = tool!("depfilter") + .args(["cycles", "--input-format", "tgf", "--output-format", "dot"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // tarjan_scc returns SCCs in reverse topological order: c,d before a,b + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_cycle_0 { + label=\"cycle_0\"; + c; + d; + c -> d; + d -> c; + } + subgraph cluster_cycle_1 { + label=\"cycle_1\"; + a; + b; + a -> b; + b -> a; + } + b -> c; +} +" + ); +} + +#[test] +fn cycles_self_loop_ignored() { + // a -> a: self-loop is not a cycle (SCC size 1) + let graph = "a\n#\na\ta\n"; + let output = tool!("depfilter") + .args(["cycles", "--input-format", "tgf", "--output-format", "tgf"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "#\n"); +} + +#[test] +fn slice_removes_cross_subgraph_edges() { + let dot_input = "\ +digraph { + subgraph cluster_0 { + a; + } + subgraph cluster_1 { + b; + } + a -> b; +} +"; + let output = tool!("depfilter") + .args(["slice", "--input-format", "dot", "--output-format", "dot"]) + .write_stdin(dot_input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_0 { + a; + } + subgraph cluster_1 { + b; + } +} +" + ); +} + +#[test] +fn slice_preserves_root_nodes_by_default() { + let dot_input = "\ +digraph { + subgraph cluster_0 { + a; + } + orphan; + orphan -> a; +} +"; + let output = tool!("depfilter") + .args(["slice", "--input-format", "dot", "--output-format", "dot"]) + .write_stdin(dot_input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_0 { + a; + } + orphan; +} +" + ); +} + +#[test] +fn slice_drop_orphans() { + let dot_input = "\ +digraph { + subgraph cluster_0 { + a; + } + orphan; + orphan -> a; +} +"; + let output = tool!("depfilter") + .args([ + "slice", + "--drop-orphans", + "--input-format", + "dot", + "--output-format", + "dot", + ]) + .write_stdin(dot_input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_0 { + a; + } +} +" + ); +} + +#[test] +fn slice_no_subgraphs() { + let graph = "a\nb\n#\na\tb\n"; + let output = tool!("depfilter") + .args(["slice", "--input-format", "tgf", "--output-format", "tgf"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\n#\na\tb\n"); +} + +#[test] +fn slice_no_subgraphs_drop_orphans() { + let graph = "a\nb\n#\na\tb\n"; + let output = tool!("depfilter") + .args([ + "slice", + "--drop-orphans", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "#\n"); +} + +#[test] +fn slice_recursive() { + // Outer subgraph has node a and a nested inner subgraph with node b. + // Edge a -> b crosses the inner boundary; recursive mode should cut it. + let dot_input = "\ +digraph { + subgraph cluster_outer { + subgraph cluster_inner { + b; + } + a; + a -> b; + } +} +"; + let output = tool!("depfilter") + .args([ + "slice", + "--recursive", + "--input-format", + "dot", + "--output-format", + "dot", + ]) + .write_stdin(dot_input) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_outer { + subgraph cluster_inner { + b; + } + a; + } +} +" + ); +} diff --git a/crates/deptangle-cli/tests/depquery.rs b/crates/deptangle-cli/tests/depquery.rs new file mode 100644 index 0000000..b1b8521 --- /dev/null +++ b/crates/deptangle-cli/tests/depquery.rs @@ -0,0 +1,434 @@ +use deptangle_test::prelude::*; +use pretty_assertions::assert_eq; + +// Test graph: a -> b -> c, a -> c +const SIMPLE_GRAPH: &str = "1\talpha\n2\tbeta\n3\tgamma\n#\n1\t2\n2\t3\n1\t3\n"; + +// Chain: a -> b -> c -> d +const CHAIN_GRAPH: &str = "a\nb\nc\nd\n#\na\tb\nb\tc\nc\td\n"; + +// Diamond: a -> b, a -> c, b -> d, c -> d +const DIAMOND_GRAPH: &str = "a\nb\nc\nd\n#\na\tb\na\tc\nb\td\nc\td\n"; + +#[test] +fn nodes_all_default() { + let output = tool!("depquery") + .args(["nodes", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "alpha\nbeta\ngamma\n"); +} + +#[test] +fn nodes_format_id() { + let output = tool!("depquery") + .args(["nodes", "--format", "id", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "1\n2\n3\n"); +} + +#[test] +fn nodes_select_roots() { + let output = tool!("depquery") + .args(["nodes", "--select", "roots", "--input-format", "tgf"]) + .write_stdin(CHAIN_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\n"); +} + +#[test] +fn nodes_select_leaves() { + let output = tool!("depquery") + .args(["nodes", "--select", "leaves", "--input-format", "tgf"]) + .write_stdin(CHAIN_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "d\n"); +} + +#[test] +fn nodes_include_pattern() { + let output = tool!("depquery") + .args(["nodes", "-g", "al*", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "alpha\n"); +} + +#[test] +fn nodes_exclude_pattern() { + let output = tool!("depquery") + .args(["nodes", "-x", "b*", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "alpha\ngamma\n"); +} + +#[test] +fn nodes_sort_topo() { + let output = tool!("depquery") + .args(["nodes", "--sort", "topo", "--input-format", "tgf"]) + .write_stdin(CHAIN_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\nd\n"); +} + +#[test] +fn nodes_sort_topo_reverse() { + let output = tool!("depquery") + .args([ + "nodes", + "--sort", + "topo", + "--reverse", + "--input-format", + "tgf", + ]) + .write_stdin(CHAIN_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "d\nc\nb\na\n"); +} + +#[test] +fn nodes_sort_out_degree() { + let output = tool!("depquery") + .args(["nodes", "--sort", "out-degree", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // alpha(id=1) has out-degree 2, beta(id=2) has 1, gamma(id=3) has 0 + assert_eq!(stdout, "alpha\t2\nbeta\t1\ngamma\t0\n"); +} + +#[test] +fn nodes_sort_in_degree() { + let output = tool!("depquery") + .args(["nodes", "--sort", "in-degree", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // gamma(id=3) has in-degree 2, beta(id=2) has 1, alpha(id=1) has 0 + assert_eq!(stdout, "gamma\t2\nbeta\t1\nalpha\t0\n"); +} + +#[test] +fn nodes_limit() { + let output = tool!("depquery") + .args([ + "nodes", + "--sort", + "topo", + "--limit", + "2", + "--input-format", + "tgf", + ]) + .write_stdin(CHAIN_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\n"); +} + +#[test] +fn nodes_deps_from_roots() { + let output = tool!("depquery") + .args([ + "nodes", + "--select", + "roots", + "--deps", + "--depth", + "1", + "--sort", + "topo", + "--input-format", + "tgf", + ]) + .write_stdin(CHAIN_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\n"); +} + +#[test] +fn nodes_rdeps_from_leaves() { + let output = tool!("depquery") + .args([ + "nodes", + "--select", + "leaves", + "--rdeps", + "--depth", + "1", + "--sort", + "topo", + "--input-format", + "tgf", + ]) + .write_stdin(CHAIN_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "c\nd\n"); +} + +#[test] +fn nodes_match_by_id() { + let output = tool!("depquery") + .args(["nodes", "-g", "1", "--key", "id", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "alpha\n"); +} + +#[test] +fn nodes_include_and_mode() { + let graph = "foo-alpha\nfoo-beta\nbar-alpha\n#\n"; + let output = tool!("depquery") + .args([ + "nodes", + "-g", + "foo*", + "-g", + "*alpha", + "--and", + "--input-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "foo-alpha\n"); +} + +#[test] +fn edges_all_default() { + let output = tool!("depquery") + .args(["edges", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Edge order follows TGF input: 1->2, 2->3, 1->3 + assert_eq!(stdout, "alpha\tbeta\nbeta\tgamma\nalpha\tgamma\n"); +} + +#[test] +fn edges_format_id() { + let output = tool!("depquery") + .args(["edges", "--format", "id", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Edge order follows TGF input: 1->2, 2->3, 1->3 + assert_eq!(stdout, "1\t2\n2\t3\n1\t3\n"); +} + +#[test] +fn edges_include_filter() { + let output = tool!("depquery") + .args(["edges", "-g", "alpha", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // edges touching "alpha": alpha->beta, alpha->gamma + assert_eq!(stdout, "alpha\tbeta\nalpha\tgamma\n"); +} + +#[test] +fn edges_exclude_filter() { + let output = tool!("depquery") + .args(["edges", "-x", "gamma", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // exclude edges touching gamma: only alpha->beta remains + assert_eq!(stdout, "alpha\tbeta\n"); +} + +#[test] +fn edges_sort_by_source() { + let graph = "a\tC\nb\tA\nc\tB\n#\na\tb\nc\ta\nb\tc\n"; + let output = tool!("depquery") + .args(["edges", "--sort", "source", "--input-format", "tgf"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "A\tB\nB\tC\nC\tA\n"); +} + +#[test] +fn edges_limit() { + let output = tool!("depquery") + .args(["edges", "--limit", "1", "--input-format", "tgf"]) + .write_stdin(SIMPLE_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "alpha\tbeta\n"); +} + +#[test] +fn metrics_chain() { + let output = tool!("depquery") + .args(["metrics", "--input-format", "tgf"]) + .write_stdin(CHAIN_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +nodes\t4 +edges\t3 +roots\t1 +leaves\t1 +max_depth\t3 +max_fan_out\t1 +max_fan_in\t1 +avg_fan_out\t0.75 +density\t0.250000 +cycles\t0 +diamonds\t0 +components\t1 +" + ); +} + +#[test] +fn metrics_diamond() { + let output = tool!("depquery") + .args(["metrics", "--input-format", "tgf"]) + .write_stdin(DIAMOND_GRAPH) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +nodes\t4 +edges\t4 +roots\t1 +leaves\t1 +max_depth\t2 +max_fan_out\t2 +max_fan_in\t2 +avg_fan_out\t1.00 +density\t0.333333 +cycles\t0 +diamonds\t1 +components\t1 +" + ); +} + +#[test] +fn metrics_cycle() { + let graph = "a\nb\nc\n#\na\tb\nb\tc\nc\ta\n"; + let output = tool!("depquery") + .args(["metrics", "--input-format", "tgf"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +nodes\t3 +edges\t3 +roots\t0 +leaves\t0 +max_depth\t +max_fan_out\t1 +max_fan_in\t1 +avg_fan_out\t1.00 +density\t0.500000 +cycles\t1 +diamonds\t0 +components\t1 +" + ); +} + +#[test] +fn metrics_empty() { + let output = tool!("depquery") + .args(["metrics", "--input-format", "tgf"]) + .write_stdin("#\n") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +nodes\t0 +edges\t0 +roots\t0 +leaves\t0 +max_depth\t0 +max_fan_out\t0 +max_fan_in\t0 +avg_fan_out\t0.00 +density\t0.000000 +cycles\t0 +diamonds\t0 +components\t0 +" + ); +} + +#[test] +fn metrics_disjoint() { + // Two separate components: a -> b, c -> d + let graph = "a\nb\nc\nd\n#\na\tb\nc\td\n"; + let output = tool!("depquery") + .args(["metrics", "--input-format", "tgf"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +nodes\t4 +edges\t2 +roots\t2 +leaves\t2 +max_depth\t1 +max_fan_out\t1 +max_fan_in\t1 +avg_fan_out\t0.50 +density\t0.166667 +cycles\t0 +diamonds\t0 +components\t2 +" + ); +} diff --git a/crates/deptangle-cli/tests/deptransform.rs b/crates/deptangle-cli/tests/deptransform.rs new file mode 100644 index 0000000..50ccf65 --- /dev/null +++ b/crates/deptangle-cli/tests/deptransform.rs @@ -0,0 +1,554 @@ +use std::io::Write; + +use deptangle_test::prelude::*; +use pretty_assertions::assert_eq; +use tempfile::NamedTempFile; + +#[test] +fn reverse_simple_chain() { + // a -> b -> c becomes c -> b, b -> a + let graph = "a\nb\nc\n#\na\tb\nb\tc\n"; + let output = tool!("deptransform") + .args(["reverse", "--input-format", "tgf", "--output-format", "tgf"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\n#\nb\ta\nc\tb\n"); +} + +#[test] +fn reverse_preserves_labels() { + let graph = "1\tAlpha\n2\tBeta\n#\n1\t2\n"; + let output = tool!("deptransform") + .args(["reverse", "--input-format", "tgf", "--output-format", "tgf"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "1\tAlpha\n2\tBeta\n#\n2\t1\n"); +} + +#[test] +fn reverse_empty_graph() { + let graph = "#\n"; + let output = tool!("deptransform") + .args(["reverse", "--input-format", "tgf", "--output-format", "tgf"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "#\n"); +} + +#[test] +fn reverse_dot_output() { + let graph = "a\nb\n#\na\tb\n"; + let output = tool!("deptransform") + .args(["reverse", "--input-format", "tgf", "--output-format", "dot"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + a; + b; + b -> a; +} +" + ); +} + +#[test] +fn simplify_removes_redundant_edge() { + // a -> b -> c, a -> c: the direct a->c is redundant + let graph = "a\nb\nc\n#\na\tb\nb\tc\na\tc\n"; + let output = tool!("deptransform") + .args([ + "simplify", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\n#\na\tb\nb\tc\n"); +} + +#[test] +fn simplify_diamond() { + // a -> b -> d, a -> c -> d, a -> d: a->d is redundant + let graph = "a\nb\nc\nd\n#\na\tb\na\tc\nb\td\nc\td\na\td\n"; + let output = tool!("deptransform") + .args([ + "simplify", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\nd\n#\na\tb\na\tc\nb\td\nc\td\n"); +} + +#[test] +fn simplify_no_redundant_edges() { + // a -> b -> c: nothing to remove + let graph = "a\nb\nc\n#\na\tb\nb\tc\n"; + let output = tool!("deptransform") + .args([ + "simplify", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\n#\na\tb\nb\tc\n"); +} + +#[test] +fn simplify_errors_on_cycle() { + // a -> b -> a: cycle, should fail + let graph = "a\nb\n#\na\tb\nb\ta\n"; + let output = tool!("deptransform") + .args([ + "simplify", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("cycles"), "stderr: {stderr}"); +} + +#[test] +fn shorten_default_strips_common_prefix() { + // Nodes share common prefix "src/foo/" -- defaults strip it + let graph = "src/foo/bar.rs\nsrc/foo/baz.rs\n#\nsrc/foo/bar.rs\tsrc/foo/baz.rs\n"; + let output = tool!("deptransform") + .args(["shorten", "--input-format", "tgf", "--output-format", "tgf"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "bar.rs\nbaz.rs\n#\nbar.rs\tbaz.rs\n"); +} + +#[test] +fn shorten_dot_separator() { + let graph = "com.example.foo\ncom.example.bar\n#\ncom.example.foo\tcom.example.bar\n"; + let output = tool!("deptransform") + .args([ + "shorten", + "--separator", + ".", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "foo\nbar\n#\nfoo\tbar\n"); +} + +#[test] +fn shorten_id_only() { + // --key id: shorten IDs but leave labels untouched + let graph = "src/foo/bar.rs\tOriginal\nsrc/foo/baz.rs\tOther\n#\n"; + let output = tool!("deptransform") + .args([ + "shorten", + "--key", + "id", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "bar.rs\tOriginal\nbaz.rs\tOther\n#\n"); +} + +#[test] +fn shorten_single_letter() { + // Explicit --single-letter overrides defaults + let graph = "src/foo/bar.rs\n#\n"; + let output = tool!("deptransform") + .args([ + "shorten", + "--single-letter", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "s/f/bar.rs\n#\n"); +} + +#[test] +fn sub_id_no_collision() { + // Rename node IDs with no collisions; labels are preserved from original + let graph = "a.do_compile\nb.do_build\n#\na.do_compile\tb.do_build\n"; + let output = tool!("deptransform") + .args([ + "sub", + "s/\\.do_.*//", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\ta.do_compile\nb\tb.do_build\n#\na\tb\n"); +} + +#[test] +fn sub_id_merges_and_removes_self_loops() { + // Two nodes map to the same ID; self-loop removed, first label wins + let graph = "a.x\ta.x\na.y\ta.y\n#\na.x\ta.y\n"; + let output = tool!("deptransform") + .args([ + "sub", + "s/\\..*//", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\ta.x\n#\n"); +} + +#[test] +fn sub_id_deduplicates_edges() { + // a.x -> b, a.y -> b both become a -> b; only one edge kept + let graph = "a.x\ta.x\na.y\ta.y\nb\tb\n#\na.x\tb\na.y\tb\n"; + let output = tool!("deptransform") + .args([ + "sub", + "s/\\..*//", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\ta.x\nb\n#\na\tb\n"); +} + +#[test] +fn sub_node_label() { + let graph = "a\thello world\nb\tgoodbye world\n#\n"; + let output = tool!("deptransform") + .args([ + "sub", + "--key", + "node:label", + "s/world/earth/", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\thello earth\nb\tgoodbye earth\n#\n"); +} + +#[test] +fn sub_alternate_delimiter() { + let graph = "a/b\nc/d\n#\na/b\tc/d\n"; + let output = tool!("deptransform") + .args([ + "sub", + "s|/|.|", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a.b\ta/b\nc.d\tc/d\n#\na.b\tc.d\n"); +} + +#[test] +fn sub_capture_groups() { + // Use capture group to extract first component + let graph = "foo.bar\nbaz.qux\n#\nfoo.bar\tbaz.qux\n"; + let output = tool!("deptransform") + .args([ + "sub", + "s/([^.]+)\\..*/$1/", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "foo\tfoo.bar\nbaz\tbaz.qux\n#\nfoo\tbaz\n"); +} + +#[test] +fn sub_invalid_expr() { + let graph = "a\n#\n"; + let output = tool!("deptransform") + .args([ + "sub", + "not-a-substitution", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .write_stdin(graph) + .captured_output(); + assert!(!output.status.success()); +} + +#[test] +fn merge_two_files() { + let mut f1 = NamedTempFile::new().unwrap(); + write!(f1, "a\nb\n#\na\tb\n").unwrap(); + + let mut f2 = NamedTempFile::new().unwrap(); + write!(f2, "c\nd\n#\nc\td\n").unwrap(); + + let output = tool!("deptransform") + .args(["merge", "--output-format", "tgf", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\nd\n#\na\tb\nc\td\n"); +} + +#[test] +fn merge_overlapping_nodes() { + let mut f1 = NamedTempFile::new().unwrap(); + write!(f1, "a\tFirst\nb\n#\na\tb\n").unwrap(); + + let mut f2 = NamedTempFile::new().unwrap(); + write!(f2, "a\tSecond\nc\n#\na\tc\n").unwrap(); + + let output = tool!("deptransform") + .args(["merge", "--output-format", "tgf", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Later file overwrites node "a" label; edges unioned + assert_eq!(stdout, "a\tSecond\nb\nc\n#\na\tb\na\tc\n"); +} + +#[test] +fn merge_deduplicates_edges() { + let mut f1 = NamedTempFile::new().unwrap(); + write!(f1, "a\nb\n#\na\tb\n").unwrap(); + + let mut f2 = NamedTempFile::new().unwrap(); + write!(f2, "a\nb\n#\na\tb\n").unwrap(); + + let output = tool!("deptransform") + .args(["merge", "--output-format", "tgf", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\n#\na\tb\n"); +} + +#[test] +fn merge_with_stdin() { + let mut f1 = NamedTempFile::new().unwrap(); + write!(f1, "a\nb\n#\na\tb\n").unwrap(); + + let output = tool!("deptransform") + .args(["merge", "--output-format", "tgf", "--input-format", "tgf"]) + .arg(f1.path()) + .arg("-") + .write_stdin("c\nd\n#\nc\td\n") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\nd\n#\na\tb\nc\td\n"); +} + +#[test] +fn merge_input_flag_included() { + let mut f1 = NamedTempFile::new().unwrap(); + write!(f1, "a\nb\n#\na\tb\n").unwrap(); + + let mut f2 = NamedTempFile::new().unwrap(); + write!(f2, "c\nd\n#\nc\td\n").unwrap(); + + // --input provides the first file, positional provides the second + let output = tool!("deptransform") + .args(["--input-format", "tgf", "--output-format", "tgf", "-i"]) + .arg(f1.path()) + .arg("merge") + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\nc\nd\n#\na\tb\nc\td\n"); +} + +#[test] +fn merge_requires_two_files() { + let mut f1 = NamedTempFile::new().unwrap(); + write!(f1, "a\n#\n").unwrap(); + + let output = tool!("deptransform") + .args(["merge", "--input-format", "tgf", "--output-format", "tgf"]) + .arg(f1.path()) + .captured_output(); + assert!(!output.status.success()); +} + +#[test] +fn merge_preserves_subgraphs() { + let mut f1 = NamedTempFile::new().unwrap(); + write!( + f1, + "\ +digraph {{ + subgraph cluster_0 {{ + a; + b; + a -> b; + }} + c; + b -> c; +}} +" + ) + .unwrap(); + + let mut f2 = NamedTempFile::new().unwrap(); + write!( + f2, + "\ +digraph {{ + d; + c -> d; +}} +" + ) + .unwrap(); + + let output = tool!("deptransform") + .args(["merge", "--input-format", "dot", "--output-format", "dot"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_0 { + a; + b; + a -> b; + } + c; + d; + b -> c; + c -> d; +} +" + ); +} + +#[test] +fn flatten_removes_subgraphs() { + let graph = "\ +digraph { + subgraph cluster_0 { + a; + b; + a -> b; + } + c; + b -> c; +} +"; + let output = tool!("deptransform") + .args(["flatten", "--input-format", "dot", "--output-format", "dot"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + c; + a; + b; + b -> c; + a -> b; +} +" + ); +} + +#[test] +fn flatten_no_subgraphs_unchanged() { + let graph = "a\nb\n#\na\tb\n"; + let output = tool!("deptransform") + .args(["flatten", "--input-format", "tgf", "--output-format", "tgf"]) + .write_stdin(graph) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "a\nb\n#\na\tb\n"); +} diff --git a/crates/deptangle-cli/tests/graphdiff.rs b/crates/deptangle-cli/tests/graphdiff.rs new file mode 100644 index 0000000..edb5113 --- /dev/null +++ b/crates/deptangle-cli/tests/graphdiff.rs @@ -0,0 +1,565 @@ +use deptangle_test::{CommandExt, tempfile, tool}; +use pretty_assertions::assert_eq; + +#[test] +fn annotate_identical_tgf() { + let f1 = tempfile("a\nb\n#\na\tb\n").unwrap(); + let f2 = tempfile("a\nb\n#\na\tb\n").unwrap(); + + let output = tool!("graphdiff") + .args([ + "annotate", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Unchanged nodes keep their labels, TGF drops diff attrs + assert_eq!(stdout, "a\nb\n#\na\tb\n"); +} + +#[test] +fn annotate_added_removed_dot() { + let f1 = tempfile("a\n#\n").unwrap(); + let f2 = tempfile("b\n#\n").unwrap(); + + let output = tool!("graphdiff") + .args([ + "annotate", + "--input-format", + "tgf", + "--output-format", + "dot", + ]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + b [label=\"+ b\", color=\"green\", fontcolor=\"green\", diff=\"added\"]; + a [label=\"- a\", color=\"red\", fontcolor=\"red\", diff=\"removed\"]; +} +" + ); +} + +#[test] +fn annotate_changed_dot() { + let f1 = tempfile("a\tAlpha\n#\n").unwrap(); + let f2 = tempfile("a\tAleph\n#\n").unwrap(); + + let output = tool!("graphdiff") + .args([ + "annotate", + "--input-format", + "tgf", + "--output-format", + "dot", + ]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + a [label=\"~ Aleph\", color=\"orange\", fontcolor=\"orange\", diff=\"changed\"]; +} +" + ); +} + +#[test] +fn annotate_edge_annotations_dot() { + let f1 = tempfile("a\nb\n#\na\tb\n").unwrap(); + let f2 = tempfile("a\nc\n#\na\tc\n").unwrap(); + + let output = tool!("graphdiff") + .args([ + "annotate", + "--input-format", + "tgf", + "--output-format", + "dot", + ]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + a [diff=\"unchanged\"]; + c [label=\"+ c\", color=\"green\", fontcolor=\"green\", diff=\"added\"]; + b [label=\"- b\", color=\"red\", fontcolor=\"red\", diff=\"removed\"]; + a -> c [color=\"green\", diff=\"added\"]; + a -> b [color=\"red\", diff=\"removed\"]; +} +" + ); +} + +#[test] +fn annotate_cluster_dot() { + let f1 = tempfile("a\n#\n").unwrap(); + let f2 = tempfile("b\n#\n").unwrap(); + + let output = tool!("graphdiff") + .args([ + "annotate", + "--cluster", + "--input-format", + "tgf", + "--output-format", + "dot", + ]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + subgraph cluster_removed { + a [label=\"- a\", color=\"red\", fontcolor=\"red\", diff=\"removed\"]; + } + b [label=\"+ b\", color=\"green\", fontcolor=\"green\", diff=\"added\"]; +} +" + ); +} + +#[test] +fn list_basic() { + let f1 = tempfile("a\tAlpha\nb\n#\na\tb\n").unwrap(); + let f2 = tempfile("b\nc\n#\nb\tc\n").unwrap(); + + let output = tool!("graphdiff") + .args(["list", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // b unchanged (omitted), c added, a removed (with label) + // edge b->c added, a->b removed + assert_eq!(stdout, "+\tc\n-\ta\tAlpha\n+\tb\tc\n-\ta\tb\n"); +} + +#[test] +fn list_empty_diff() { + let f1 = tempfile("a\nb\n#\na\tb\n").unwrap(); + let f2 = tempfile("a\nb\n#\na\tb\n").unwrap(); + + let output = tool!("graphdiff") + .args(["list", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, ""); +} + +#[test] +fn subtract_basic() { + let f1 = tempfile("a\nb\nc\n#\na\tb\nb\tc\n").unwrap(); + let f2 = tempfile("c\n#\n").unwrap(); + + let output = tool!("graphdiff") + .args([ + "subtract", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // Only a and b are removed; edge a->b has both endpoints removed + // Edge b->c is excluded because c is not removed + assert_eq!(stdout, "a\nb\n#\na\tb\n"); +} + +#[test] +fn subtract_empty_when_identical() { + let f1 = tempfile("a\n#\n").unwrap(); + let f2 = tempfile("a\n#\n").unwrap(); + + let output = tool!("graphdiff") + .args([ + "subtract", + "--input-format", + "tgf", + "--output-format", + "tgf", + ]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "#\n"); +} + +#[test] +fn summary_basic() { + let f1 = tempfile("a\nb\n#\na\tb\n").unwrap(); + let f2 = tempfile("b\nc\n#\nb\tc\n").unwrap(); + + let output = tool!("graphdiff") + .args(["summary", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +added_nodes\t1 +removed_nodes\t1 +changed_nodes\t0 +moved_nodes\t0 +unchanged_nodes\t1 +added_edges\t1 +removed_edges\t1 +changed_edges\t0 +unchanged_edges\t0 +" + ); +} + +#[test] +fn summary_all_unchanged() { + let f1 = tempfile("a\nb\n#\na\tb\n").unwrap(); + let f2 = tempfile("a\nb\n#\na\tb\n").unwrap(); + + let output = tool!("graphdiff") + .args(["summary", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +added_nodes\t0 +removed_nodes\t0 +changed_nodes\t0 +moved_nodes\t0 +unchanged_nodes\t2 +added_edges\t0 +removed_edges\t0 +changed_edges\t0 +unchanged_edges\t1 +" + ); +} + +#[test] +fn summary_all_different() { + let f1 = tempfile("a\nb\n#\na\tb\n").unwrap(); + let f2 = tempfile("c\nd\n#\nc\td\n").unwrap(); + + let output = tool!("graphdiff") + .args(["summary", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +added_nodes\t2 +removed_nodes\t2 +changed_nodes\t0 +moved_nodes\t0 +unchanged_nodes\t0 +added_edges\t1 +removed_edges\t1 +changed_edges\t0 +unchanged_edges\t0 +" + ); +} + +#[test] +fn summary_empty_graphs() { + let f1 = tempfile("#\n").unwrap(); + let f2 = tempfile("#\n").unwrap(); + + let output = tool!("graphdiff") + .args(["summary", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +added_nodes\t0 +removed_nodes\t0 +changed_nodes\t0 +moved_nodes\t0 +unchanged_nodes\t0 +added_edges\t0 +removed_edges\t0 +changed_edges\t0 +unchanged_edges\t0 +" + ); +} + +#[test] +fn stdin_and_file() { + let f1 = tempfile("a\n#\n").unwrap(); + + let output = tool!("graphdiff") + .args(["summary", "--input-format", "tgf"]) + .arg(f1.path()) + .arg("-") + .write_stdin("b\n#\n") + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +added_nodes\t1 +removed_nodes\t1 +changed_nodes\t0 +moved_nodes\t0 +unchanged_nodes\t0 +added_edges\t0 +removed_edges\t0 +changed_edges\t0 +unchanged_edges\t0 +" + ); +} + +#[test] +fn both_stdin_error() { + let output = tool!("graphdiff") + .args(["summary", "--input-format", "tgf", "-", "-"]) + .write_stdin("") + .captured_output(); + assert!(!output.status.success()); +} + +#[test] +fn check_identical_exits_zero() { + let f1 = tempfile("a\nb\n#\na\tb\n").unwrap(); + let f2 = tempfile("a\nb\n#\na\tb\n").unwrap(); + + let output = tool!("graphdiff") + .args(["summary", "--check", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); +} + +#[test] +fn check_different_exits_nonzero() { + let f1 = tempfile("a\n#\n").unwrap(); + let f2 = tempfile("b\n#\n").unwrap(); + + let output = tool!("graphdiff") + .args(["summary", "--check", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(!output.status.success()); +} + +#[test] +fn check_still_produces_output() { + let f1 = tempfile("a\n#\n").unwrap(); + let f2 = tempfile("b\n#\n").unwrap(); + + let output = tool!("graphdiff") + .args(["list", "--check", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(!output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "+\tb\n-\ta\n"); +} + +#[test] +fn moved_node_list() { + // c has same info but different single parent + let f1 = tempfile("p1\nc\n#\np1\tc\n").unwrap(); + let f2 = tempfile("p2\nc\n#\np2\tc\n").unwrap(); + + let output = tool!("graphdiff") + .args(["list", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // p2 added, c moved, p1 removed; edges follow + assert_eq!(stdout, "+\tp2\n>\tc\n-\tp1\n+\tp2\tc\n-\tp1\tc\n"); +} + +#[test] +fn moved_node_annotate_dot() { + let f1 = tempfile("p1\nc\n#\np1\tc\n").unwrap(); + let f2 = tempfile("p2\nc\n#\np2\tc\n").unwrap(); + + let output = tool!("graphdiff") + .args([ + "annotate", + "--input-format", + "tgf", + "--output-format", + "dot", + ]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +digraph { + p2 [label=\"+ p2\", color=\"green\", fontcolor=\"green\", diff=\"added\"]; + c [label=\"> c\", color=\"blue\", fontcolor=\"blue\", diff=\"moved\"]; + p1 [label=\"- p1\", color=\"red\", fontcolor=\"red\", diff=\"removed\"]; + p2 -> c [color=\"green\", diff=\"added\"]; + p1 -> c [color=\"red\", diff=\"removed\"]; +} +" + ); +} + +#[test] +fn multi_parent_not_moved() { + // c has multiple parents in both graphs - not moved + let f1 = tempfile("p1\np2\nc\n#\np1\tc\np2\tc\n").unwrap(); + let f2 = tempfile("p1\np3\nc\n#\np1\tc\np3\tc\n").unwrap(); + + let output = tool!("graphdiff") + .args(["summary", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // c is unchanged (not moved), p1 unchanged, p3 added, p2 removed + assert_eq!( + stdout, + "\ +added_nodes\t1 +removed_nodes\t1 +changed_nodes\t0 +moved_nodes\t0 +unchanged_nodes\t2 +added_edges\t1 +removed_edges\t1 +changed_edges\t0 +unchanged_edges\t1 +" + ); +} + +#[test] +fn changed_edges() { + // Same edge (a->b) but different label + let f1 = tempfile("a\nb\n#\na\tb\tuses\n").unwrap(); + let f2 = tempfile("a\nb\n#\na\tb\tdepends\n").unwrap(); + + let output = tool!("graphdiff") + .args(["summary", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout, + "\ +added_nodes\t0 +removed_nodes\t0 +changed_nodes\t0 +moved_nodes\t0 +unchanged_nodes\t2 +added_edges\t0 +removed_edges\t0 +changed_edges\t1 +unchanged_edges\t0 +" + ); +} + +#[test] +fn duplicate_edges() { + // Two edges a->b with different labels in each graph + // "uses" is in both (unchanged), "dev" only in before (removed), "test" only in after (added) + let f1 = tempfile("a\nb\n#\na\tb\tuses\na\tb\tdev\n").unwrap(); + let f2 = tempfile("a\nb\n#\na\tb\tuses\na\tb\ttest\n").unwrap(); + + let output = tool!("graphdiff") + .args(["summary", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + // "uses" matched unchanged, "test" paired with "dev" as changed + assert_eq!( + stdout, + "\ +added_nodes\t0 +removed_nodes\t0 +changed_nodes\t0 +moved_nodes\t0 +unchanged_nodes\t2 +added_edges\t0 +removed_edges\t0 +changed_edges\t1 +unchanged_edges\t1 +" + ); +} + +#[test] +fn nodes_only_graphs() { + let f1 = tempfile("a\nb\n#\n").unwrap(); + let f2 = tempfile("b\nc\n#\n").unwrap(); + + let output = tool!("graphdiff") + .args(["list", "--input-format", "tgf"]) + .arg(f1.path()) + .arg(f2.path()) + .captured_output(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "+\tc\n-\ta\n"); +} diff --git a/crates/deptangle-cli/tests/minpath.rs b/crates/deptangle-cli/tests/minpath.rs new file mode 100644 index 0000000..6285dd6 --- /dev/null +++ b/crates/deptangle-cli/tests/minpath.rs @@ -0,0 +1,195 @@ +use deptangle_test::prelude::*; +use pretty_assertions::assert_eq; + +#[test] +fn empty_input() { + let output = tool!("minpath").captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert_eq!(stdout, ""); +} + +#[test] +fn single_path_from_stdin() { + let input = "/home/user/project/src/main.rs\n"; + let output = tool!("minpath").write_stdin(input).captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert_eq!(stdout, "main.rs\n"); +} + +#[test] +fn single_path_from_args() { + let output = tool!("minpath") + .arg("/home/user/project/src/main.rs") + .captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert_eq!(stdout, "main.rs\n"); +} + +#[test] +fn mix_stdin_and_args() { + let input = "/home/user/from_stdin.rs\n"; + let output = tool!("minpath") + .arg("/home/user/from_args.rs") + .write_stdin(input) + .captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + // When args are provided, stdin should be ignored + assert_eq!(stdout, "from_args.rs\n"); + + // Unless '-' is given as an argument + let output = tool!("minpath") + .arg("/home/user/from_args.rs") + .arg("-") + .write_stdin(input) + .captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert_eq!(stdout, "from_args.rs\nfrom_stdin.rs\n"); +} + +#[test] +fn multiple_paths_no_duplicates() { + let input = "/home/user/project/src/main.rs\n/home/user/project/src/util.rs\n"; + let output = tool!("minpath").write_stdin(input).captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert_eq!(stdout, "main.rs\nutil.rs\n"); +} + +#[test] +fn duplicate_filenames_minimal_unique() { + let input = "/home/user/project/src/utils/parse.rs\n/home/user/project/tests/utils/parse.rs\n"; + let output = tool!("minpath").write_stdin(input).captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert_eq!(stdout, "src/utils/parse.rs\ntests/utils/parse.rs\n"); +} + +#[test] +fn no_minimal_suffix() { + let input = "/home/user/project/src/utils/parse.rs\n/home/user/project/tests/utils/parse.rs\n"; + let output = tool!("minpath") + .arg("--no-minimal-suffix") + .write_stdin(input) + .captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + // Without minimal suffix, both should just show the path with common prefix removed + assert_eq!(stdout, "src/utils/parse.rs\ntests/utils/parse.rs\n"); +} + +#[test] +fn prefix_removal() { + let input = "/home/user/project/src/main.rs\n/home/user/project/lib/util.rs\n"; + let output = tool!("minpath") + .arg("--no-minimal-suffix") + .arg("--prefix") + .arg("/home/user/") + .write_stdin(input) + .captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert_eq!(stdout, "src/main.rs\nlib/util.rs\n"); +} + +#[test] +fn relative_to_base() { + let input = "/home/user/project/src/main.rs\n/home/user/project/lib/util.rs\n"; + let output = tool!("minpath") + .arg("--no-minimal-suffix") + .arg("--relative-to") + .arg("/home/user/project") + .write_stdin(input) + .captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert_eq!(stdout, "src/main.rs\nlib/util.rs\n"); +} + +#[test] +fn single_letter_abbreviation() { + let input = "/home/user/project/src/utils/parse.rs\n/home/user/project/tests/utils/parse.rs"; + let output = tool!("minpath") + .arg("--single-letter") + .write_stdin(input) + .captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert_eq!(stdout, "s/u/parse.rs\nt/u/parse.rs\n"); +} + +#[test] +fn smart_abbreviation() { + let input = "/home/user/Documents/project/Source/main.rs\n"; + let output = tool!("minpath") + .arg("--no-minimal-suffix") + .arg("--smart-abbreviate") + .write_stdin(input) + .captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert_eq!(stdout, "~/docs/project/src/main.rs\n"); +} + +#[test] +fn sort_and_unique() { + let input = "/home/user/b.rs\n/home/user/a.rs\n/home/user/b.rs\n"; + let output = tool!("minpath") + .arg("--sort") + .write_stdin(input) + .captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + // Sorted with duplicates removed + assert_eq!(stdout, "a.rs\nb.rs\n"); +} + +#[test] +fn preserve_input_order() { + let input = "/home/user/c.rs\n/home/user/b.rs\n/home/user/a.rs\n/home/user/b.rs\n"; + let output = tool!("minpath").write_stdin(input).captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + // Unsorted, duplicates removed (first occurrence kept) + assert_eq!(stdout, "c.rs\nb.rs\na.rs\n"); +} + +#[test] +fn select_specific_paths() { + let input = "/home/user/src/main.rs\n/home/user/tests/test.rs\n/home/user/lib/util.rs\n"; + let output = tool!("minpath") + .arg("--select") + .arg("**/src/**") + .arg("--select") + .arg("**/lib/**") + .write_stdin(input) + .captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + // Test select behavior, not exact transform output + assert!(stdout.contains("main.rs")); + assert!(stdout.contains("util.rs")); + assert!(!stdout.contains("test.rs")); + assert_eq!(stdout.lines().count(), 2); +} + +#[test] +fn exclude_patterns() { + let input = "/home/user/src/main.rs\n/home/user/tests/test.rs\n/home/user/lib/util.rs\n"; + let output = tool!("minpath") + .arg("--exclude") + .arg("**/tests/**") + .write_stdin(input) + .captured_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + // Test exclude behavior, not exact transform output + assert!(stdout.contains("main.rs")); + assert!(stdout.contains("util.rs")); + assert!(!stdout.contains("test.rs")); + assert_eq!(stdout.lines().count(), 2); +} diff --git a/crates/deptangle-graph/Cargo.toml b/crates/deptangle-graph/Cargo.toml new file mode 100644 index 0000000..b694114 --- /dev/null +++ b/crates/deptangle-graph/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "deptangle-graph" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Dependency graph data model" + +[dependencies] +indexmap.workspace = true +petgraph.workspace = true diff --git a/crates/deptangle-graph/src/graph.rs b/crates/deptangle-graph/src/graph.rs new file mode 100644 index 0000000..712a7df --- /dev/null +++ b/crates/deptangle-graph/src/graph.rs @@ -0,0 +1,604 @@ +use std::cell::OnceCell; +use std::collections::{HashSet, VecDeque}; + +use indexmap::IndexMap; +use petgraph::Direction; +use petgraph::graph::{DiGraph, NodeIndex}; + +#[derive(Clone, Default)] +pub struct DepGraph { + /// Graph or subgraph identifier (e.g. DOT `digraph ` / `subgraph `). + pub id: Option, + /// Graph-level attributes (e.g. DOT `rankdir`, `label`, `color`). + pub attrs: IndexMap, + pub nodes: IndexMap, + pub edges: Vec, + /// Nested subgraphs, each owning its own nodes and edges. + pub subgraphs: Vec, + + /// Cached flattened nodes from [`Self::all_nodes`]. Lazily populated on first access. + pub all_nodes_cache: OnceCell>, + /// Cached flattened edges from [`Self::all_edges`]. Lazily populated on first access. + pub all_edges_cache: OnceCell>, + /// Cached adjacency list from [`Self::adjacency_list`]. Lazily populated on first access. + pub adjacency_cache: OnceCell>>, +} + +impl DepGraph { + /// Collect all nodes from this graph and all nested subgraphs. + /// + /// The result is cached internally using interior mutability. The first call recurses over + /// subgraphs in DFS order and clones all node data into an owned map; subsequent calls + /// return a reference to the cached result. + pub fn all_nodes(&self) -> &IndexMap { + self.all_nodes_cache.get_or_init(|| { + let mut result = IndexMap::new(); + self.collect_nodes(&mut result); + result + }) + } + + fn collect_nodes(&self, result: &mut IndexMap) { + for (id, info) in &self.nodes { + result.insert(id.clone(), info.clone()); + } + for sg in &self.subgraphs { + sg.collect_nodes(result); + } + } + + /// Collect all edges from this graph and all nested subgraphs. + /// + /// The result is cached internally using interior mutability. The first call recurses over + /// subgraphs in DFS order and clones all edge data into an owned vec; subsequent calls + /// return a reference to the cached result. + pub fn all_edges(&self) -> &Vec { + self.all_edges_cache.get_or_init(|| { + let mut result = Vec::new(); + self.collect_edges(&mut result); + result + }) + } + + fn collect_edges(&self, result: &mut Vec) { + result.extend(self.edges.iter().cloned()); + for sg in &self.subgraphs { + sg.collect_edges(result); + } + } + + /// Clear all internal caches on this graph and its subgraphs. + /// + /// Call this after mutating nodes, edges, or subgraphs so that subsequent calls to + /// [`Self::all_nodes`], [`Self::all_edges`], or [`Self::adjacency_list`] recompute. + pub fn clear_caches(&mut self) { + self.all_nodes_cache.take(); + self.all_edges_cache.take(); + self.adjacency_cache.take(); + for sg in &mut self.subgraphs { + sg.clear_caches(); + } + } + + /// Build an adjacency list from all edges across all subgraphs. + /// + /// The result is cached internally using interior mutability. The first call builds the + /// adjacency map from [`Self::all_edges`]; subsequent calls return a reference to the + /// cached result. + pub fn adjacency_list(&self) -> &IndexMap> { + self.adjacency_cache.get_or_init(|| { + let mut adj = IndexMap::new(); + for edge in self.all_edges() { + adj.entry(edge.from.clone()) + .or_insert_with(Vec::new) + .push(edge.to.clone()); + } + adj + }) + } +} + +#[derive(Clone, Debug)] +pub struct NodeInfo { + pub label: String, + /// Node type/kind (e.g. "lib", "bin", "proc-macro", "build-script"). + /// Semantics are format-specific on input; normalized to canonical names where possible. + /// Formats that don't support types leave this as None. + pub node_type: Option, + /// Arbitrary extra attributes. Parsers populate these from format-specific features; + /// emitters carry them through where the output format allows. + pub attrs: IndexMap, +} + +impl NodeInfo { + /// Create a new NodeInfo with the given label. + /// Node type and attributes are initialized to their defaults (None and empty, respectively). + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + node_type: None, + attrs: Default::default(), + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct Edge { + pub from: String, + pub to: String, + pub label: Option, + /// Arbitrary extra attributes (e.g. DOT `style`, `color`). + pub attrs: IndexMap, +} + +/// A flattened view of a [`DepGraph`] as a petgraph [`DiGraph`]. +/// +/// Bridges `DepGraph` (which has nested subgraphs and string-keyed maps) with petgraph's +/// graph algorithms by flattening all nodes and edges into a single directed graph. +pub struct FlatGraphView<'a> { + /// The source dependency graph. + pub graph: &'a DepGraph, + /// Flattened petgraph with all nodes and edges from all subgraph levels. + pub pg: DiGraph<(), ()>, + /// Map from node ID string to petgraph NodeIndex. + pub id_to_idx: IndexMap<&'a str, NodeIndex>, + /// Map from petgraph NodeIndex (as usize index) to node ID string. + pub idx_to_id: Vec<&'a str>, +} + +impl<'a> FlatGraphView<'a> { + /// Create a new `FlatGraphView` from a `DepGraph`. + /// + /// Collects all nodes and edges from the graph and its nested subgraphs into a flat + /// petgraph `DiGraph`. Edges whose endpoints are not present in the node set are skipped. + pub fn new(graph: &'a DepGraph) -> Self { + let all_nodes = graph.all_nodes(); + let all_edges = graph.all_edges(); + + let mut pg = DiGraph::new(); + let mut id_to_idx = IndexMap::new(); + let mut idx_to_id = Vec::with_capacity(all_nodes.len()); + + for id in all_nodes.keys() { + let idx = pg.add_node(()); + id_to_idx.insert(id.as_str(), idx); + idx_to_id.push(id.as_str()); + } + + for edge in all_edges { + let from = id_to_idx.get(edge.from.as_str()); + let to = id_to_idx.get(edge.to.as_str()); + if let (Some(&from_idx), Some(&to_idx)) = (from, to) { + pg.add_edge(from_idx, to_idx, ()); + } + } + + Self { + graph, + pg, + id_to_idx, + idx_to_id, + } + } + + /// Return all root nodes (nodes with no incoming edges). + pub fn roots(&self) -> impl Iterator + '_ { + self.pg.node_indices().filter(|&idx| { + self.pg + .neighbors_directed(idx, Direction::Incoming) + .next() + .is_none() + }) + } + + /// BFS from `seeds` following edges in `direction`, returning all visited nodes. + /// + /// If `max_depth` is `Some(n)`, only nodes within `n` hops of a seed are included. + /// The seeds themselves are always included (depth 0). + pub fn bfs( + &self, + seeds: impl IntoIterator, + direction: Direction, + max_depth: Option, + ) -> HashSet { + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + for seed in seeds { + if visited.insert(seed) { + queue.push_back((seed, 0)); + } + } + + while let Some((node, depth)) = queue.pop_front() { + if max_depth.is_some_and(|max| depth >= max) { + continue; + } + for neighbor in self.pg.neighbors_directed(node, direction) { + if visited.insert(neighbor) { + queue.push_back((neighbor, depth + 1)); + } + } + } + + visited + } + + /// Filter the original `DepGraph` to only include nodes in the `keep` set. + /// + /// Returns a new `DepGraph` that preserves the original subgraph structure but only + /// contains nodes whose `NodeIndex` is in `keep`, plus edges where both endpoints survive. + /// Empty subgraphs (no nodes and no non-empty child subgraphs) are dropped. + pub fn filter(&self, keep: &HashSet) -> DepGraph { + let keep_ids: HashSet<&str> = keep + .iter() + .filter_map(|idx| self.idx_to_id.get(idx.index()).copied()) + .collect(); + filter_depgraph(self.graph, &keep_ids) + } +} + +fn filter_depgraph(graph: &DepGraph, keep: &HashSet<&str>) -> DepGraph { + DepGraph { + id: graph.id.clone(), + attrs: graph.attrs.clone(), + nodes: graph + .nodes + .iter() + .filter(|(id, _)| keep.contains(id.as_str())) + .map(|(id, info)| (id.clone(), info.clone())) + .collect(), + edges: graph + .edges + .iter() + .filter(|e| keep.contains(e.from.as_str()) && keep.contains(e.to.as_str())) + .cloned() + .collect(), + subgraphs: graph + .subgraphs + .iter() + .map(|sg| filter_depgraph(sg, keep)) + .filter(|sg| !sg.nodes.is_empty() || !sg.subgraphs.is_empty()) + .collect(), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_graph( + nodes: &[(&str, &str)], + edges: &[(&str, &str)], + subgraphs: Vec, + ) -> DepGraph { + DepGraph { + nodes: nodes + .iter() + .map(|(id, label)| (id.to_string(), NodeInfo::new(*label))) + .collect(), + edges: edges + .iter() + .map(|(from, to)| Edge { + from: from.to_string(), + to: to.to_string(), + ..Default::default() + }) + .collect(), + subgraphs, + ..Default::default() + } + } + + #[test] + fn new_empty() { + let g = DepGraph::default(); + let view = FlatGraphView::new(&g); + assert_eq!(view.pg.node_count(), 0); + assert_eq!(view.pg.edge_count(), 0); + assert!(view.id_to_idx.is_empty()); + assert!(view.idx_to_id.is_empty()); + } + + #[test] + fn new_flat() { + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + + assert_eq!(view.pg.node_count(), 3); + assert_eq!(view.pg.edge_count(), 2); + + // Round-trip: id -> idx -> id + for &id in &["a", "b", "c"] { + let idx = view.id_to_idx[id]; + assert_eq!(view.idx_to_id[idx.index()], id); + } + } + + #[test] + fn new_with_subgraphs() { + let sub = make_graph(&[("c", "C")], &[("a", "c")], vec![]); + let g = make_graph(&[("a", "A"), ("b", "B")], &[("a", "b")], vec![sub]); + let view = FlatGraphView::new(&g); + + assert_eq!(view.pg.node_count(), 3); + // a->b from root, a->c from subgraph + assert_eq!(view.pg.edge_count(), 2); + assert!(view.id_to_idx.contains_key("c")); + } + + #[test] + fn new_skips_dangling_edges() { + let g = make_graph( + &[("a", "A")], + &[("a", "b"), ("x", "a")], // b and x don't exist + vec![], + ); + let view = FlatGraphView::new(&g); + + assert_eq!(view.pg.node_count(), 1); + assert_eq!(view.pg.edge_count(), 0); + } + + #[test] + fn filter_keeps_matching_nodes() { + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c"), ("a", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + + let keep: HashSet = ["a", "b"].iter().map(|id| view.id_to_idx[*id]).collect(); + let filtered = view.filter(&keep); + + assert_eq!(filtered.nodes.len(), 2); + assert!(filtered.nodes.contains_key("a")); + assert!(filtered.nodes.contains_key("b")); + assert_eq!(filtered.edges.len(), 1); + assert_eq!(filtered.edges[0].from, "a"); + assert_eq!(filtered.edges[0].to, "b"); + } + + #[test] + fn filter_drops_unmatched_edges() { + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + + // Keep a and c but not b -- both edges touch b so both are dropped + let keep: HashSet = ["a", "c"].iter().map(|id| view.id_to_idx[*id]).collect(); + let filtered = view.filter(&keep); + + assert_eq!(filtered.nodes.len(), 2); + assert!(filtered.edges.is_empty()); + } + + #[test] + fn filter_preserves_subgraph_structure() { + let sub = make_graph(&[("c", "C")], &[], vec![]); + let g = make_graph(&[("a", "A"), ("b", "B")], &[("a", "b")], vec![sub]); + let view = FlatGraphView::new(&g); + + // Keep all three nodes + let keep: HashSet = ["a", "b", "c"] + .iter() + .map(|id| view.id_to_idx[*id]) + .collect(); + let filtered = view.filter(&keep); + + assert_eq!(filtered.nodes.len(), 2); // a, b at root + assert_eq!(filtered.subgraphs.len(), 1); + assert_eq!(filtered.subgraphs[0].nodes.len(), 1); // c in subgraph + assert!(filtered.subgraphs[0].nodes.contains_key("c")); + } + + #[test] + fn filter_drops_empty_subgraphs() { + let sub = make_graph(&[("c", "C")], &[], vec![]); + let g = make_graph(&[("a", "A"), ("b", "B")], &[], vec![sub]); + let view = FlatGraphView::new(&g); + + // Keep only root nodes, subgraph node c is excluded + let keep: HashSet = ["a", "b"].iter().map(|id| view.id_to_idx[*id]).collect(); + let filtered = view.filter(&keep); + + assert_eq!(filtered.nodes.len(), 2); + assert!(filtered.subgraphs.is_empty()); + } + + #[test] + fn filter_preserves_subgraph_attrs() { + let mut sub = make_graph(&[("c", "C")], &[], vec![]); + sub.id = Some("cluster_0".to_string()); + sub.attrs.insert("color".to_string(), "blue".to_string()); + + let g = make_graph(&[("a", "A")], &[], vec![sub]); + let view = FlatGraphView::new(&g); + + let keep: HashSet = ["a", "c"].iter().map(|id| view.id_to_idx[*id]).collect(); + let filtered = view.filter(&keep); + + assert_eq!(filtered.subgraphs.len(), 1); + assert_eq!(filtered.subgraphs[0].id.as_deref(), Some("cluster_0")); + assert_eq!( + filtered.subgraphs[0].attrs.get("color").map(String::as_str), + Some("blue") + ); + } + + // -- roots -- + + #[test] + fn roots_empty_graph() { + let g = DepGraph::default(); + let view = FlatGraphView::new(&g); + assert_eq!(view.roots().count(), 0); + } + + #[test] + fn roots_no_edges() { + let g = make_graph(&[("a", "A"), ("b", "B")], &[], vec![]); + let view = FlatGraphView::new(&g); + let root_ids: Vec<&str> = view + .roots() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + assert_eq!(root_ids, vec!["a", "b"]); + } + + #[test] + fn roots_chain() { + // a -> b -> c: only a is a root + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + let root_ids: Vec<&str> = view + .roots() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + assert_eq!(root_ids, vec!["a"]); + } + + #[test] + fn roots_diamond() { + // a -> b, a -> c, b -> d, c -> d + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C"), ("d", "D")], + &[("a", "b"), ("a", "c"), ("b", "d"), ("c", "d")], + vec![], + ); + let view = FlatGraphView::new(&g); + let root_ids: Vec<&str> = view + .roots() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + assert_eq!(root_ids, vec!["a"]); + } + + #[test] + fn roots_cycle() { + // a -> b -> c -> a: every node has an incoming edge, no roots + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c"), ("c", "a")], + vec![], + ); + let view = FlatGraphView::new(&g); + assert_eq!(view.roots().count(), 0); + } + + // -- bfs -- + + #[test] + fn bfs_outgoing_full() { + // a -> b -> c + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + let result = view.bfs([view.id_to_idx["a"]], Direction::Outgoing, None); + let mut ids: Vec<&str> = result + .iter() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["a", "b", "c"]); + } + + #[test] + fn bfs_incoming_full() { + // a -> b -> c: ancestors of c = {a, b, c} + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c")], + vec![], + ); + let view = FlatGraphView::new(&g); + let result = view.bfs([view.id_to_idx["c"]], Direction::Incoming, None); + let mut ids: Vec<&str> = result + .iter() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["a", "b", "c"]); + } + + #[test] + fn bfs_depth_limited() { + // a -> b -> c -> d: depth 1 from a = {a, b} + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C"), ("d", "D")], + &[("a", "b"), ("b", "c"), ("c", "d")], + vec![], + ); + let view = FlatGraphView::new(&g); + let result = view.bfs([view.id_to_idx["a"]], Direction::Outgoing, Some(1)); + let mut ids: Vec<&str> = result + .iter() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["a", "b"]); + } + + #[test] + fn bfs_depth_zero() { + // depth 0 from a = just {a} + let g = make_graph(&[("a", "A"), ("b", "B")], &[("a", "b")], vec![]); + let view = FlatGraphView::new(&g); + let result = view.bfs([view.id_to_idx["a"]], Direction::Outgoing, Some(0)); + let ids: Vec<&str> = result + .iter() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + assert_eq!(ids, vec!["a"]); + } + + #[test] + fn bfs_multiple_seeds() { + // a -> b, c -> d: seeds {a, c} outgoing = {a, b, c, d} + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C"), ("d", "D")], + &[("a", "b"), ("c", "d")], + vec![], + ); + let view = FlatGraphView::new(&g); + let result = view.bfs( + [view.id_to_idx["a"], view.id_to_idx["c"]], + Direction::Outgoing, + None, + ); + let mut ids: Vec<&str> = result + .iter() + .map(|idx| view.idx_to_id[idx.index()]) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["a", "b", "c", "d"]); + } + + #[test] + fn bfs_cycle() { + // a -> b -> c -> a: full traversal doesn't loop forever + let g = make_graph( + &[("a", "A"), ("b", "B"), ("c", "C")], + &[("a", "b"), ("b", "c"), ("c", "a")], + vec![], + ); + let view = FlatGraphView::new(&g); + let result = view.bfs([view.id_to_idx["a"]], Direction::Outgoing, None); + assert_eq!(result.len(), 3); + } +} diff --git a/crates/deptangle-graph/src/lib.rs b/crates/deptangle-graph/src/lib.rs new file mode 100644 index 0000000..1dd1e35 --- /dev/null +++ b/crates/deptangle-graph/src/lib.rs @@ -0,0 +1,3 @@ +mod graph; + +pub use graph::{DepGraph, Edge, FlatGraphView, NodeInfo}; diff --git a/crates/deptangle-io/Cargo.toml b/crates/deptangle-io/Cargo.toml new file mode 100644 index 0000000..925cded --- /dev/null +++ b/crates/deptangle-io/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "deptangle-io" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Parse, detect, and emit dependency graph formats" + +[dependencies] +cargo_metadata.workspace = true +clap.workspace = true +deptangle-graph.workspace = true +eyre.workspace = true +graphviz-rust.workspace = true +indexmap.workspace = true +mermaid-rs-renderer.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true diff --git a/crates/deptangle-io/src/detect.rs b/crates/deptangle-io/src/detect.rs new file mode 100644 index 0000000..8086748 --- /dev/null +++ b/crates/deptangle-io/src/detect.rs @@ -0,0 +1,266 @@ +use clap::ValueEnum; + +use crate::parse::InputFormat; + +/// Detect input format from content heuristics. +/// +/// Returns `None` if no format matches. Variants are tried in enum declaration +/// order (most specific first). +pub fn detect(input: &str) -> Option { + InputFormat::value_variants() + .iter() + .find(|fmt| fmt.matches_content(input)) + .copied() +} + +impl InputFormat { + /// Content-based detection heuristic. The match is exhaustive so adding a + /// new variant without a detection rule is a compile error. + fn matches_content(&self, input: &str) -> bool { + match self { + Self::CargoMetadata => is_json(input), + Self::Mermaid => is_mermaid(input), + Self::Dot => is_dot(input), + Self::Tgf => is_tgf(input), + Self::Depfile => is_depfile(input), + Self::CargoTree => is_cargo_tree(input), + Self::Tree => is_tree(input), + Self::Pathlist => is_pathlist(input), + } + } +} + +/// First non-blank line starts with `{`. +fn is_json(input: &str) -> bool { + // TODO: This will need some attention if we add more JSON formats (like conan) + first_nonblank(input).starts_with('{') +} + +/// First non-blank line starts with `flowchart`, or `graph` followed by a +/// direction keyword (`TD`/`TB`/`BT`/`LR`/`RL`). +fn is_mermaid(input: &str) -> bool { + let first = first_nonblank(input); + first.starts_with("flowchart") + || (first.starts_with("graph") + && matches!( + first.split_whitespace().nth(1), + Some("TD" | "TB" | "BT" | "LR" | "RL") + )) +} + +/// First non-blank line starts with `digraph`, `strict graph`, or `graph`. +/// +/// Note: `graph` + direction keyword is matched as Mermaid first (higher +/// priority in enum order), so only `graph` + identifier reaches here. +fn is_dot(input: &str) -> bool { + let first = first_nonblank(input); + first.starts_with("digraph") || first.starts_with("strict graph") || first.starts_with("graph") +} + +/// Any line is exactly `#` (TGF node/edge separator). +fn is_tgf(input: &str) -> bool { + input.lines().any(|l| l.trim() == "#") +} + +/// Any line matches the `target: dep` pattern of a makefile depfile. +fn is_depfile(input: &str) -> bool { + input.lines().any(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return false; + } + let line = line.strip_suffix('\\').unwrap_or(line).trim(); + match line.find(':') { + Some(colon) => { + let target = &line[..colon]; + let deps = line[colon + 1..].trim(); + !target.is_empty() + && !target.contains(char::is_whitespace) + && !deps.is_empty() + && !deps.starts_with(':') + } + None => false, + } + }) +} + +/// Tree-drawing decorations + version tokens (e.g. `v1.2.3`). +fn is_cargo_tree(input: &str) -> bool { + has_tree_drawing(input) && has_version_pattern(input) +} + +/// Tree-drawing decorations without version tokens. +/// +/// Note: `is_cargo_tree` has higher priority, so if versions are present +/// the input won't reach this check. +fn is_tree(input: &str) -> bool { + has_tree_drawing(input) +} + +/// Every non-blank line contains a `/` path separator. +fn is_pathlist(input: &str) -> bool { + let mut any = false; + for line in input.lines() { + if line.trim().is_empty() { + continue; + } + if !line.contains('/') { + return false; + } + any = true; + } + any +} + +/// Any line contains tree-drawing characters -- Unicode box-drawing or ASCII +/// equivalents (`tree --charset=ascii`, `scons --tree`). +fn has_tree_drawing(input: &str) -> bool { + input.lines().any(|l| { + l.contains('├') + || l.contains('└') + || l.contains('│') + || l.contains("|--") + || l.contains("`--") + || l.contains("\\--") + || l.contains("+-") + }) +} + +/// Any whitespace-delimited token looks like a version: `v` followed by a digit. +fn has_version_pattern(input: &str) -> bool { + input.lines().any(|l| { + l.split_whitespace().any(|tok| { + // TODO: This is pretty loose + tok.starts_with('v') + && tok.len() > 1 + && tok[1..].starts_with(|c: char| c.is_ascii_digit()) + }) + }) +} + +fn first_nonblank(input: &str) -> &str { + // TODO: Should this strip out comments? + input + .lines() + .find(|l| !l.trim().is_empty()) + .map(|l| l.trim()) + .unwrap_or("") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_cargo_metadata() { + let input = "{\n \"packages\": [\n"; + assert_eq!(detect(input), Some(InputFormat::CargoMetadata)); + } + + #[test] + fn detect_mermaid_flowchart() { + let input = include_str!("../../../data/depconv/flowchart.mmd"); + assert_eq!(detect(input), Some(InputFormat::Mermaid)); + } + + #[test] + fn detect_mermaid_subgraph() { + let input = include_str!("../../../data/depconv/subgraph.mmd"); + assert_eq!(detect(input), Some(InputFormat::Mermaid)); + } + + #[test] + fn detect_mermaid_graph_with_direction() { + assert_eq!(detect("graph TD\n A --> B\n"), Some(InputFormat::Mermaid)); + assert_eq!(detect("graph LR\n A --> B\n"), Some(InputFormat::Mermaid)); + assert_eq!(detect("graph RL\n A --> B\n"), Some(InputFormat::Mermaid)); + assert_eq!(detect("graph BT\n A --> B\n"), Some(InputFormat::Mermaid)); + assert_eq!(detect("graph TB\n A --> B\n"), Some(InputFormat::Mermaid)); + } + + #[test] + fn detect_dot() { + let input = include_str!("../../../data/depconv/small.dot"); + assert_eq!(detect(input), Some(InputFormat::Dot)); + } + + #[test] + fn detect_dot_graph_with_id() { + assert_eq!( + detect("graph deps {\n a -- b;\n}\n"), + Some(InputFormat::Dot) + ); + } + + #[test] + fn detect_dot_strict() { + assert_eq!( + detect("strict graph {\n a -- b;\n}\n"), + Some(InputFormat::Dot) + ); + } + + #[test] + fn detect_tgf() { + let input = include_str!("../../../data/depconv/small.tgf"); + assert_eq!(detect(input), Some(InputFormat::Tgf)); + } + + #[test] + fn detect_depfile() { + let input = include_str!("../../../data/depconv/small.d"); + assert_eq!(detect(input), Some(InputFormat::Depfile)); + } + + #[test] + fn detect_cargo_tree() { + let input = include_str!("../../../data/depconv/cargo-tree.txt"); + assert_eq!(detect(input), Some(InputFormat::CargoTree)); + } + + #[test] + fn detect_cargo_tree_ascii() { + let input = "+-myapp v1.0.0\n +-libfoo v0.2.1\n | +-libbar v0.1.0\n"; + assert_eq!(detect(input), Some(InputFormat::CargoTree)); + } + + #[test] + fn detect_tree() { + let input = include_str!("../../../data/depconv/tree.txt"); + assert_eq!(detect(input), Some(InputFormat::Tree)); + } + + #[test] + fn detect_tree_ascii() { + let input = include_str!("../../../data/depconv/tree-ascii.txt"); + assert_eq!(detect(input), Some(InputFormat::Tree)); + } + + #[test] + fn detect_tree_ascii_backslash() { + let input = "|-- dir1\n| |-- file1\n| \\-- file2\n\\-- dir2\n"; + assert_eq!(detect(input), Some(InputFormat::Tree)); + } + + #[test] + fn detect_pathlist_gitfiles() { + let input = include_str!("../../../data/depconv/gitfiles.txt"); + assert_eq!(detect(input), Some(InputFormat::Pathlist)); + } + + #[test] + fn detect_pathlist_find() { + let input = include_str!("../../../data/depconv/find.txt"); + assert_eq!(detect(input), Some(InputFormat::Pathlist)); + } + + #[test] + fn detect_plain_names_not_pathlist() { + assert_eq!(detect("foo\nbar\nbaz\n"), None); + } + + #[test] + fn detect_empty() { + assert_eq!(detect(""), None); + } +} diff --git a/crates/deptangle-io/src/emit/depfile.rs b/crates/deptangle-io/src/emit/depfile.rs new file mode 100644 index 0000000..9ee1786 --- /dev/null +++ b/crates/deptangle-io/src/emit/depfile.rs @@ -0,0 +1,177 @@ +use std::io::Write; + +use deptangle_graph::DepGraph; + +/// Emit a [`DepGraph`] as a makefile-style `.d` depfile. +/// +/// Each unique edge source becomes a target line: `target: dep1 dep2 ...`. +/// This is the most lossy emitter -- only graph topology (edge endpoints) is +/// preserved. Everything else is silently dropped: +/// - Node labels and attrs +/// - Edge labels and attrs +/// - Graph-level attrs +/// - Nodes with no outgoing edges (they appear implicitly as dependencies) +pub fn emit(graph: &DepGraph, writer: &mut dyn Write) -> eyre::Result<()> { + for (target, deps) in graph.adjacency_list() { + write!(writer, "{target}:")?; + for dep in deps { + write!(writer, " {dep}")?; + } + writeln!(writer)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use deptangle_graph::{Edge, NodeInfo}; + use indexmap::IndexMap; + + use super::*; + use crate::emit::fixtures::sample_graph; + + fn emit_to_string(graph: &DepGraph) -> String { + let mut buf = Vec::new(); + emit(graph, &mut buf).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn empty_graph() { + assert_eq!(emit_to_string(&DepGraph::default()), ""); + } + + #[test] + fn single_target() { + let graph = DepGraph { + edges: vec![ + Edge { + from: "main.o".into(), + to: "main.c".into(), + ..Default::default() + }, + Edge { + from: "main.o".into(), + to: "config.h".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "main.o: main.c config.h\n"); + } + + #[test] + fn multiple_targets() { + let graph = DepGraph { + edges: vec![ + Edge { + from: "a.o".into(), + to: "a.c".into(), + ..Default::default() + }, + Edge { + from: "b.o".into(), + to: "b.c".into(), + ..Default::default() + }, + Edge { + from: "b.o".into(), + to: "common.h".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "a.o: a.c\nb.o: b.c common.h\n"); + } + + #[test] + fn sample() { + // a -> b, b -> c, a -> c + let output = emit_to_string(&sample_graph()); + assert_eq!(output, "a: b c\nb: c\n"); + } + + #[test] + fn nodes_only_produces_empty() { + let graph = DepGraph { + nodes: IndexMap::from([ + ("x".into(), NodeInfo::new("x")), + ("y".into(), NodeInfo::new("y")), + ]), + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), ""); + } + + #[test] + fn rich_graph_drops_labels_and_attrs() { + let graph = DepGraph { + attrs: IndexMap::from([("name".into(), "deps".into())]), + nodes: IndexMap::from([( + "a".into(), + NodeInfo { + label: "Alpha".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "box".into())]), + }, + )]), + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + label: Some("uses".into()), + attrs: IndexMap::from([("style".into(), "dashed".into())]), + }], + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "a: b\n"); + } + + #[test] + fn preserves_target_order() { + let graph = DepGraph { + edges: vec![ + Edge { + from: "z.o".into(), + to: "z.c".into(), + ..Default::default() + }, + Edge { + from: "a.o".into(), + to: "a.c".into(), + ..Default::default() + }, + Edge { + from: "m.o".into(), + to: "m.c".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "z.o: z.c\na.o: a.c\nm.o: m.c\n"); + } + + #[test] + fn subgraph_edges_included() { + let graph = DepGraph { + edges: vec![Edge { + from: "top".into(), + to: "a".into(), + ..Default::default() + }], + subgraphs: vec![DepGraph { + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "top: a\na: b\n"); + } +} diff --git a/crates/deptangle-io/src/emit/dot.rs b/crates/deptangle-io/src/emit/dot.rs new file mode 100644 index 0000000..e56ada1 --- /dev/null +++ b/crates/deptangle-io/src/emit/dot.rs @@ -0,0 +1,856 @@ +use std::io::Write; + +use deptangle_graph::DepGraph; + +/// DOT double-quoted string: wrap in `"..."` and escape `"` -> `\"`. +/// +/// We intentionally do NOT escape `\` -> `\\`. DOT uses backslash sequences like +/// `\n`, `\l`, `\r` as label formatting directives, and our internal representation +/// preserves all DOT backslash sequences verbatim (see `unquote` in `parse/dot.rs`). +/// Escaping backslashes here would corrupt formatting directives on DOT -> DOT round-trip. +fn quote(s: &str) -> String { + let escaped = s.replace('"', "\\\""); + format!("\"{escaped}\"") +} + +/// Returns true if `s` is a bare DOT identifier (alphanumeric + underscore, not +/// digit-leading, and not a DOT reserved keyword). +fn is_bare_id(s: &str) -> bool { + !s.is_empty() + && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && !s.starts_with(|c: char| c.is_ascii_digit()) + && !is_dot_keyword(s) +} + +fn is_dot_keyword(s: &str) -> bool { + s.eq_ignore_ascii_case("node") + || s.eq_ignore_ascii_case("edge") + || s.eq_ignore_ascii_case("graph") + || s.eq_ignore_ascii_case("digraph") + || s.eq_ignore_ascii_case("subgraph") + || s.eq_ignore_ascii_case("strict") +} + +/// Quote a DOT ID: bare identifiers pass through, everything else gets double-quoted. +fn quote_id(s: &str) -> String { + if is_bare_id(s) { + s.to_string() + } else { + quote(s) + } +} + +/// Emit a [`DepGraph`] as a DOT `digraph`. +/// +/// All graph features are preserved: +/// - Graph name is taken from `graph.id`. +/// - Graph-level attrs are emitted as top-level `key="val";` statements. +/// - Node labels and arbitrary attrs are emitted as `[key="val", ...]`. +/// - Edge labels and arbitrary attrs are emitted the same way. +/// - Subgraphs are emitted as nested `subgraph { ... }` blocks. +/// - Identifiers are bare when safe (alphanumeric, non-keyword), otherwise +/// double-quoted. Backslash sequences (`\n`, `\l`, `\r`) are preserved +/// verbatim for DOT -> DOT round-trips. +pub fn emit(graph: &DepGraph, writer: &mut dyn Write) -> eyre::Result<()> { + // Emit graph header with optional name. + if let Some(name) = &graph.id { + writeln!(writer, "digraph {} {{", quote_id(name))?; + } else { + writeln!(writer, "digraph {{")?; + } + + emit_body(graph, writer, 1)?; + + writeln!(writer, "}}")?; + Ok(()) +} + +/// Emit the body of a graph or subgraph: attrs, subgraphs, nodes, edges. +fn emit_body(graph: &DepGraph, writer: &mut dyn Write, depth: usize) -> eyre::Result<()> { + let indent = " ".repeat(depth); + + // Emit graph-level attributes. + for (k, v) in &graph.attrs { + writeln!(writer, "{indent}{}={};", quote_id(k), quote(v))?; + } + + // Emit subgraphs before nodes/edges (matches typical DOT convention). + for sg in &graph.subgraphs { + emit_subgraph(sg, writer, depth)?; + } + + // Emit nodes. + for (id, info) in &graph.nodes { + emit_node(id, info, writer, depth)?; + } + + // Emit edges. + for edge in &graph.edges { + emit_edge(edge, writer, depth)?; + } + + Ok(()) +} + +fn emit_node( + id: &str, + info: &deptangle_graph::NodeInfo, + writer: &mut dyn Write, + depth: usize, +) -> eyre::Result<()> { + let indent = " ".repeat(depth); + let mut attrs = Vec::new(); + if info.label != id { + attrs.push(format!("label={}", quote(&info.label))); + } + if let Some(node_type) = &info.node_type { + attrs.push(format!("type={}", quote(node_type))); + } + for (k, v) in &info.attrs { + attrs.push(format!("{}={}", quote_id(k), quote(v))); + } + + if attrs.is_empty() { + writeln!(writer, "{indent}{};", quote_id(id))?; + } else { + writeln!(writer, "{indent}{} [{}];", quote_id(id), attrs.join(", "))?; + } + Ok(()) +} + +fn emit_edge( + edge: &deptangle_graph::Edge, + writer: &mut dyn Write, + depth: usize, +) -> eyre::Result<()> { + let indent = " ".repeat(depth); + let mut attrs = Vec::new(); + if let Some(label) = &edge.label { + attrs.push(format!("label={}", quote(label))); + } + for (k, v) in &edge.attrs { + attrs.push(format!("{}={}", quote_id(k), quote(v))); + } + + if attrs.is_empty() { + writeln!( + writer, + "{indent}{} -> {};", + quote_id(&edge.from), + quote_id(&edge.to) + )?; + } else { + writeln!( + writer, + "{indent}{} -> {} [{}];", + quote_id(&edge.from), + quote_id(&edge.to), + attrs.join(", ") + )?; + } + Ok(()) +} + +fn emit_subgraph(sg: &DepGraph, writer: &mut dyn Write, depth: usize) -> eyre::Result<()> { + let indent = " ".repeat(depth); + + let added_prefix; + if let Some(id) = &sg.id { + // GraphViz only renders subgraphs as visual clusters when the name + // starts with "cluster". Prefix IDs that don't already have it. + if id.starts_with("cluster") { + added_prefix = false; + writeln!(writer, "{indent}subgraph {} {{", quote_id(id))?; + } else { + added_prefix = true; + writeln!( + writer, + "{indent}subgraph {} {{", + quote_id(&format!("cluster_{id}")) + )?; + } + } else { + added_prefix = false; + writeln!(writer, "{indent}subgraph {{")?; + } + + // When we added the cluster_ prefix, emit a label with the original ID + // so the rendered cluster shows the original name (unless there's already + // an explicit label attr). + if added_prefix && !sg.attrs.contains_key("label") { + let inner = " ".repeat(depth + 1); + writeln!(writer, "{inner}label={};", quote(sg.id.as_deref().unwrap()))?; + } + + emit_body(sg, writer, depth + 1)?; + + writeln!(writer, "{indent}}}")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use deptangle_graph::{Edge, NodeInfo}; + use indexmap::IndexMap; + + use super::*; + use crate::emit::fixtures::sample_graph; + + fn emit_to_string(graph: &DepGraph) -> String { + let mut buf = Vec::new(); + emit(graph, &mut buf).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn quote_plain() { + assert_eq!(quote("hello"), r#""hello""#); + } + + #[test] + fn quote_with_quotes() { + assert_eq!(quote(r#"say "hi""#), r#""say \"hi\"""#); + } + + #[test] + fn quote_backslash_preserved() { + // Backslashes pass through unchanged -- DOT formatting directives + // like \n, \l, \r must not be double-escaped. + assert_eq!(quote(r"a\nb"), r#""a\nb""#); + assert_eq!(quote(r"a\\b"), r#""a\\b""#); + } + + #[test] + fn quote_backslash_before_quote() { + // Internal \\" (backslash + quote) must produce \\\" in DOT. + assert_eq!(quote(r#"\\"b"#), r#""\\\"b""#); + } + + #[test] + fn quote_unquote_roundtrip() { + let cases = [ + "hello", + r#"say "hi""#, + r"path\to\file", + r"line1\nline2", + r"a\\b", + r#"a\\"b"#, + "", + ]; + for s in cases { + let quoted = quote(s); + let roundtripped = crate::parse::dot::unquote("ed); + assert_eq!(roundtripped, s, "round-trip failed for {s:?}"); + } + } + + #[test] + fn empty_graph() { + let output = emit_to_string(&DepGraph::default()); + assert_eq!(output, "digraph {\n}\n"); + } + + #[test] + fn sample() { + let output = emit_to_string(&sample_graph()); + assert_eq!( + output, + "\ +digraph { + a [label=\"alpha\"]; + b [label=\"bravo\"]; + c; + a -> b [label=\"depends\"]; + b -> c; + a -> c; +} +" + ); + } + + #[test] + fn nodes_only() { + let mut nodes = IndexMap::new(); + nodes.insert("x".into(), NodeInfo::new("X Node")); + nodes.insert("y".into(), NodeInfo::new("y")); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + x [label=\"X Node\"]; + y; +} +" + ); + } + + #[test] + fn node_attrs() { + let mut nodes = IndexMap::new(); + nodes.insert( + "mylib".into(), + NodeInfo { + label: "My Library".into(), + node_type: None, + attrs: IndexMap::from([ + ("shape".into(), "box".into()), + ("version".into(), "1.0".into()), + ]), + }, + ); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + mylib [label=\"My Library\", shape=\"box\", version=\"1.0\"]; +} +" + ); + } + + #[test] + fn special_chars_in_ids() { + let mut nodes = IndexMap::new(); + nodes.insert("my node".into(), NodeInfo::new("my node")); + nodes.insert("has\"quotes".into(), NodeInfo::new("a \"label\"")); + let graph = DepGraph { + nodes, + edges: vec![Edge { + from: "my node".into(), + to: "has\"quotes".into(), + ..Default::default() + }], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + \"my node\"; + \"has\\\"quotes\" [label=\"a \\\"label\\\"\"]; + \"my node\" -> \"has\\\"quotes\"; +} +" + ); + } + + #[test] + fn bare_ids_not_quoted() { + let mut nodes = IndexMap::new(); + nodes.insert("foo_bar".into(), NodeInfo::new("foo_bar")); + nodes.insert("Baz123".into(), NodeInfo::new("Baz123")); + let graph = DepGraph { + nodes, + edges: vec![Edge { + from: "foo_bar".into(), + to: "Baz123".into(), + ..Default::default() + }], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + foo_bar; + Baz123; + foo_bar -> Baz123; +} +" + ); + } + + #[test] + fn dot_keyword_ids_are_quoted() { + let mut nodes = IndexMap::new(); + nodes.insert("node".into(), NodeInfo::new("node")); + nodes.insert("edge".into(), NodeInfo::new("edge")); + let graph = DepGraph { + nodes, + edges: vec![Edge { + from: "node".into(), + to: "edge".into(), + ..Default::default() + }], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + \"node\"; + \"edge\"; + \"node\" -> \"edge\"; +} +" + ); + } + + #[test] + fn dot_keyword_ids_case_insensitive() { + let mut nodes = IndexMap::new(); + nodes.insert("Node".into(), NodeInfo::new("Node")); + nodes.insert("GRAPH".into(), NodeInfo::new("GRAPH")); + nodes.insert("Subgraph".into(), NodeInfo::new("Subgraph")); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + \"Node\"; + \"GRAPH\"; + \"Subgraph\"; +} +" + ); + } + + #[test] + fn digit_leading_id_is_quoted() { + let mut nodes = IndexMap::new(); + nodes.insert("1abc".into(), NodeInfo::new("1abc")); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + \"1abc\"; +} +" + ); + } + + #[test] + fn edge_labels() { + let graph = DepGraph { + nodes: IndexMap::new(), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + label: Some("uses".into()), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "c".into(), + label: Some("has space".into()), + ..Default::default() + }, + ], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + a -> b [label=\"uses\"]; + a -> c [label=\"has space\"]; +} +" + ); + } + + #[test] + fn edge_attrs_emitted() { + let graph = DepGraph { + nodes: IndexMap::new(), + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + label: Some("uses".into()), + attrs: IndexMap::from([ + ("style".into(), "dashed".into()), + ("color".into(), "red".into()), + ]), + }], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + a -> b [label=\"uses\", style=\"dashed\", color=\"red\"]; +} +" + ); + } + + #[test] + fn graph_name_emitted() { + let graph = DepGraph { + id: Some("deps".into()), + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!(output, "digraph deps {\n}\n"); + } + + #[test] + fn graph_attrs_emitted() { + let graph = DepGraph { + id: Some("deps".into()), + attrs: IndexMap::from([("rankdir".into(), "LR".into())]), + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph deps { + rankdir=\"LR\"; +} +" + ); + } + + #[test] + fn graph_and_node_and_edge_attrs_combined() { + let graph = DepGraph { + id: Some("deps".into()), + attrs: IndexMap::from([("rankdir".into(), "LR".into())]), + nodes: IndexMap::from([ + ( + "a".into(), + NodeInfo { + label: "A".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "box".into())]), + }, + ), + ("b".into(), NodeInfo::new("b")), + ]), + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + attrs: IndexMap::from([ + ("style".into(), "dashed".into()), + ("color".into(), "red".into()), + ]), + ..Default::default() + }], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph deps { + rankdir=\"LR\"; + a [label=\"A\", shape=\"box\"]; + b; + a -> b [style=\"dashed\", color=\"red\"]; +} +" + ); + } + + #[test] + fn subgraph_emitted() { + let graph = DepGraph { + nodes: IndexMap::from([("top".into(), NodeInfo::new("top"))]), + subgraphs: vec![DepGraph { + id: Some("cluster0".into()), + attrs: IndexMap::from([("label".into(), "Group A".into())]), + nodes: IndexMap::from([ + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ]), + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }], + ..Default::default() + }], + edges: vec![Edge { + from: "top".into(), + to: "a".into(), + ..Default::default() + }], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + subgraph cluster0 { + label=\"Group A\"; + a; + b; + a -> b; + } + top; + top -> a; +} +" + ); + } + + #[test] + fn node_type_emitted() { + let mut nodes = IndexMap::new(); + nodes.insert( + "mylib".into(), + NodeInfo { + label: "My Library".into(), + node_type: Some("lib".into()), + attrs: Default::default(), + }, + ); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + assert!(output.contains(r#"type="lib""#)); + assert!(output.contains(r#"[label="My Library", type="lib"]"#)); + } + + #[test] + fn node_type_ordering() { + let mut nodes = IndexMap::new(); + nodes.insert( + "mylib".into(), + NodeInfo { + label: "My Library".into(), + node_type: Some("proc-macro".into()), + attrs: IndexMap::from([ + ("version".into(), "1.0".into()), + ("shape".into(), "diamond".into()), + ]), + }, + ); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + // label first, then type, then attrs in insertion order + assert!(output.contains( + r#"[label="My Library", type="proc-macro", version="1.0", shape="diamond"]"# + )); + } + + #[test] + fn node_type_none_omitted() { + let mut nodes = IndexMap::new(); + nodes.insert( + "mylib".into(), + NodeInfo { + label: "My Library".into(), + node_type: None, + attrs: IndexMap::from([("version".into(), "1.0".into())]), + }, + ); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + assert!(!output.contains("type=")); + assert!(output.contains(r#"[label="My Library", version="1.0"]"#)); + } + + #[test] + fn node_type_with_shape_attrs() { + let mut nodes = IndexMap::new(); + nodes.insert( + "pm".into(), + NodeInfo { + label: "pm".into(), + node_type: Some("proc-macro".into()), + attrs: IndexMap::from([("shape".into(), "diamond".into())]), + }, + ); + nodes.insert( + "mybin".into(), + NodeInfo { + label: "mybin".into(), + node_type: Some("bin".into()), + attrs: IndexMap::from([("shape".into(), "box".into())]), + }, + ); + nodes.insert( + "bs".into(), + NodeInfo { + label: "bs".into(), + node_type: Some("build-script".into()), + attrs: IndexMap::from([("shape".into(), "note".into())]), + }, + ); + nodes.insert( + "opt".into(), + NodeInfo { + label: "opt".into(), + node_type: Some("optional".into()), + attrs: IndexMap::from([("style".into(), "dashed".into())]), + }, + ); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + pm [type=\"proc-macro\", shape=\"diamond\"]; + mybin [type=\"bin\", shape=\"box\"]; + bs [type=\"build-script\", shape=\"note\"]; + opt [type=\"optional\", style=\"dashed\"]; +} +" + ); + } + + #[test] + fn node_type_no_override() { + let mut nodes = IndexMap::new(); + nodes.insert( + "pm".into(), + NodeInfo { + label: "pm".into(), + node_type: Some("proc-macro".into()), + attrs: IndexMap::from([("shape".into(), "box".into())]), + }, + ); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + // Emitter serializes type and attrs as-is + assert_eq!( + output, + "\ +digraph { + pm [type=\"proc-macro\", shape=\"box\"]; +} +" + ); + } + + #[test] + fn edge_kind_styled() { + let graph = DepGraph { + nodes: IndexMap::new(), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + attrs: IndexMap::from([ + ("kind".into(), "dev".into()), + ("style".into(), "dashed".into()), + ("color".into(), "gray60".into()), + ]), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "c".into(), + attrs: IndexMap::from([ + ("kind".into(), "build".into()), + ("style".into(), "dashed".into()), + ]), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "d".into(), + attrs: IndexMap::from([ + ("kind".into(), "normal,build".into()), + ("style".into(), "dashed".into()), + ]), + ..Default::default() + }, + ], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + a -> b [kind=\"dev\", style=\"dashed\", color=\"gray60\"]; + a -> c [kind=\"build\", style=\"dashed\"]; + a -> d [kind=\"normal,build\", style=\"dashed\"]; +} +" + ); + } + + #[test] + fn edge_kind_no_override() { + let graph = DepGraph { + nodes: IndexMap::new(), + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + attrs: IndexMap::from([ + ("kind".into(), "dev".into()), + ("style".into(), "bold".into()), + ("color".into(), "gray60".into()), + ]), + ..Default::default() + }], + ..Default::default() + }; + let output = emit_to_string(&graph); + // Emitter serializes all attrs as-is + assert_eq!( + output, + "\ +digraph { + a -> b [kind=\"dev\", style=\"bold\", color=\"gray60\"]; +} +" + ); + } + + #[test] + fn no_type_no_styling() { + let mut nodes = IndexMap::new(); + nodes.insert("plain".into(), NodeInfo::new("Plain")); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +digraph { + plain [label=\"Plain\"]; +} +" + ); + assert!(!output.contains("shape=")); + assert!(!output.contains("style=")); + } +} diff --git a/crates/deptangle-io/src/emit/mermaid.rs b/crates/deptangle-io/src/emit/mermaid.rs new file mode 100644 index 0000000..7fc408b --- /dev/null +++ b/crates/deptangle-io/src/emit/mermaid.rs @@ -0,0 +1,572 @@ +use std::io::Write; + +use deptangle_graph::DepGraph; + +/// Sanitize a node ID to be a valid Mermaid identifier. +/// +/// Mermaid IDs support alphanumeric, underscore, dash, and dot characters, +/// and must not start with a digit. Spaces are replaced with underscores. +/// Other characters are replaced with `_XX` hex encoding. +fn sanitize_id(id: &str) -> String { + if is_bare_id(id) { + return id.to_string(); + } + let mut result = String::new(); + for c in id.chars() { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { + result.push(c); + } else if c == ' ' { + result.push('_'); + } else { + for b in c.to_string().as_bytes() { + result.push_str(&format!("_{b:02x}")); + } + } + } + if result.starts_with(|c: char| c.is_ascii_digit()) { + result.insert(0, '_'); + } + result +} + +fn is_bare_id(s: &str) -> bool { + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') + && !s.starts_with(|c: char| c.is_ascii_digit()) +} + +/// Escape a label string for use inside a quoted Mermaid label (`"..."`). +/// +/// Labels are always wrapped in double quotes inside shape brackets, so +/// Mermaid syntax characters like `[]{}()|` are safe. Only `"` and `&` +/// need escaping via HTML named entities. +fn escape_label(s: &str) -> String { + s.replace('&', "&").replace('"', """) +} + +/// Map a DOT shape attribute to a Mermaid shape bracket. +/// +/// Returns None if the shape doesn't have a good Mermaid equivalent. +fn dot_shape_to_mermaid(shape: &str, label: &str) -> Option { + match shape { + "box" => Some(format!("[\"{label}\"]")), + "circle" => Some(format!("((\"{label}\"))")), + "ellipse" => Some(format!("([\"{label}\"])")), + "diamond" => Some(format!("{{\"{label}\"}}")), + "hexagon" => Some(format!("{{{{\"{label}\"}}}}")), + "note" => Some(format!("[/\"{label}\"/]")), + "cylinder" => Some(format!("[(\"{label}\")]")), + _ => None, + } +} + +/// Choose node shape brackets based on shape attr. +/// +/// Mermaid supports various shapes via bracket syntax: +/// - `[label]` - rectangle (default) +/// - `([label])` - stadium/pill shape +/// - `((label))` - circle +/// - `{label}` - rhombus/diamond +/// - `{{label}}` - hexagon +/// - `[/label/]` - parallelogram +/// - `[(label)]` - cylindrical (database) +/// +/// The `shape` attr is expected to be populated by `apply_default_styles()` +/// before emitting, so semantic types like "proc-macro" are already mapped +/// to DOT shape names (e.g. "diamond") by the time this runs. +fn node_shape(info: &deptangle_graph::NodeInfo, label: &str) -> String { + if let Some(shape) = info.attrs.get("shape") + && let Some(mermaid_shape) = dot_shape_to_mermaid(shape, label) + { + return mermaid_shape; + } + + // Default to rectangle + format!("[\"{label}\"]") +} + +/// Emit a [`DepGraph`] as a Mermaid flowchart. +/// +/// Preserves: +/// - Graph direction from `rankdir` attr (LR, RL, TB, BT, TD) +/// - Node labels (escaped for Mermaid syntax) +/// - Node types as shape hints (lossy mapping to Mermaid shapes) +/// - Edge labels +/// - Subgraphs as nested `subgraph ... end` blocks +/// +/// Drops: +/// - Graph-level attrs (except rankdir for direction) +/// - Arbitrary node attrs (no Mermaid syntax for them) +/// - Arbitrary edge attrs (no Mermaid syntax for them) +pub fn emit(graph: &DepGraph, writer: &mut dyn Write) -> eyre::Result<()> { + let direction = graph + .attrs + .get("rankdir") + .or(graph.attrs.get("direction")) + .map(|s| s.as_str()) + .unwrap_or("LR"); + + writeln!(writer, "flowchart {direction}")?; + emit_body(graph, writer, 1)?; + Ok(()) +} + +/// Emit the body of a graph or subgraph: subgraphs, nodes, edges. +fn emit_body(graph: &DepGraph, writer: &mut dyn Write, depth: usize) -> eyre::Result<()> { + let indent = " ".repeat(depth); + + // Emit subgraphs before nodes/edges. + for sg in &graph.subgraphs { + emit_subgraph(sg, writer, depth)?; + } + + // Emit nodes (only if they have labels or types that affect shape). + // Mermaid doesn't require explicit node declarations if they appear + // in edges, but we emit them to show labels and shapes. + for (id, info) in &graph.nodes { + let sanitized = sanitize_id(id); + let label = &info.label; + let escaped = escape_label(label); + let shape = node_shape(info, &escaped); + writeln!(writer, "{indent}{sanitized}{shape}")?; + } + + // Emit edges. + for edge in &graph.edges { + emit_edge(edge, writer, depth)?; + } + + Ok(()) +} + +fn emit_edge( + edge: &deptangle_graph::Edge, + writer: &mut dyn Write, + depth: usize, +) -> eyre::Result<()> { + let indent = " ".repeat(depth); + let from = sanitize_id(&edge.from); + let to = sanitize_id(&edge.to); + + if let Some(label) = &edge.label { + let escaped = escape_label(label); + writeln!(writer, "{indent}{from} -->|\"{escaped}\"| {to}")?; + } else { + writeln!(writer, "{indent}{from} --> {to}")?; + } + + Ok(()) +} + +fn emit_subgraph(sg: &DepGraph, writer: &mut dyn Write, depth: usize) -> eyre::Result<()> { + let indent = " ".repeat(depth); + + if let Some(id) = &sg.id { + let sanitized = sanitize_id(id); + writeln!(writer, "{indent}subgraph {sanitized}")?; + } else { + // Anonymous subgraphs not well-supported in Mermaid; use a generic name + writeln!(writer, "{indent}subgraph sg{depth}")?; + } + + emit_body(sg, writer, depth + 1)?; + + writeln!(writer, "{indent}end")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use deptangle_graph::{Edge, NodeInfo}; + use indexmap::IndexMap; + + use super::*; + use crate::emit::fixtures::sample_graph; + + fn emit_to_string(graph: &DepGraph) -> String { + let mut buf = Vec::new(); + emit(graph, &mut buf).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn sanitize_bare_id() { + assert_eq!(sanitize_id("foo"), "foo"); + assert_eq!(sanitize_id("foo_bar"), "foo_bar"); + assert_eq!(sanitize_id("foo-bar"), "foo-bar"); + assert_eq!(sanitize_id("Foo123"), "Foo123"); + } + + #[test] + fn sanitize_non_bare_id() { + assert_eq!(sanitize_id("my node"), "my_node"); + assert_eq!(sanitize_id("has\"quotes"), "has_22quotes"); + assert_eq!(sanitize_id("123abc"), "_123abc"); + assert_eq!(sanitize_id("main.o"), "main.o"); + assert_eq!(sanitize_id("myapp v0.1.0"), "myapp_v0.1.0"); + } + + #[test] + fn escape_label_basic() { + assert_eq!(escape_label("hello"), "hello"); + } + + #[test] + fn escape_label_brackets_preserved() { + assert_eq!(escape_label("foo[bar]"), "foo[bar]"); + } + + #[test] + fn escape_label_quotes() { + assert_eq!(escape_label("say \"hi\""), "say "hi""); + } + + #[test] + fn escape_label_ampersand() { + assert_eq!(escape_label("A & B"), "A & B"); + } + + #[test] + fn empty_graph() { + let output = emit_to_string(&DepGraph::default()); + assert_eq!(output, "flowchart LR\n"); + } + + #[test] + fn sample() { + let output = emit_to_string(&sample_graph()); + assert_eq!( + output, + "\ +flowchart LR + a[\"alpha\"] + b[\"bravo\"] + c[\"c\"] + a -->|\"depends\"| b + b --> c + a --> c +" + ); + } + + #[test] + fn nodes_only() { + let mut nodes = IndexMap::new(); + nodes.insert("x".into(), NodeInfo::new("X Node")); + nodes.insert("y".into(), NodeInfo::new("y")); + let graph = DepGraph { + nodes, + edges: vec![], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +flowchart LR + x[\"X Node\"] + y[\"y\"] +" + ); + } + + #[test] + fn shape_attrs_from_node_types() { + let mut nodes = IndexMap::new(); + nodes.insert( + "lib1".into(), + NodeInfo { + label: "Library".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "ellipse".into())]), + }, + ); + nodes.insert( + "bin1".into(), + NodeInfo { + label: "Binary".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "box".into())]), + }, + ); + nodes.insert( + "pm1".into(), + NodeInfo { + label: "Proc Macro".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "diamond".into())]), + }, + ); + nodes.insert( + "bs1".into(), + NodeInfo { + label: "Build Script".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "note".into())]), + }, + ); + nodes.insert( + "test1".into(), + NodeInfo { + label: "Test".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "hexagon".into())]), + }, + ); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + assert!(output.contains("lib1([\"Library\"])")); + assert!(output.contains("bin1[\"Binary\"]")); + assert!(output.contains("pm1{\"Proc Macro\"}")); + assert!(output.contains("bs1[/\"Build Script\"/]")); + assert!(output.contains("test1{{\"Test\"}}")); + } + + #[test] + fn edge_labels() { + let graph = DepGraph { + nodes: IndexMap::new(), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + label: Some("uses".into()), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "c".into(), + label: Some("has space".into()), + ..Default::default() + }, + ], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert!(output.contains("a -->|\"uses\"| b")); + assert!(output.contains("a -->|\"has space\"| c")); + } + + #[test] + fn direction_from_rankdir() { + let graph = DepGraph { + attrs: IndexMap::from([("rankdir".into(), "TB".into())]), + nodes: IndexMap::from([("a".into(), NodeInfo::new("a"))]), + ..Default::default() + }; + let output = emit_to_string(&graph); + assert!(output.starts_with("flowchart TB\n")); + } + + #[test] + fn direction_from_direction_attr() { + let graph = DepGraph { + attrs: IndexMap::from([("direction".into(), "RL".into())]), + nodes: IndexMap::from([("a".into(), NodeInfo::new("a"))]), + ..Default::default() + }; + let output = emit_to_string(&graph); + assert!(output.starts_with("flowchart RL\n")); + } + + #[test] + fn subgraph_emitted() { + let graph = DepGraph { + nodes: IndexMap::from([("top".into(), NodeInfo::new("top"))]), + subgraphs: vec![DepGraph { + id: Some("backend".into()), + nodes: IndexMap::from([ + ("api".into(), NodeInfo::new("API Server")), + ("db".into(), NodeInfo::new("Database")), + ]), + edges: vec![Edge { + from: "api".into(), + to: "db".into(), + ..Default::default() + }], + ..Default::default() + }], + edges: vec![Edge { + from: "top".into(), + to: "api".into(), + ..Default::default() + }], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert_eq!( + output, + "\ +flowchart LR + subgraph backend + api[\"API Server\"] + db[\"Database\"] + api --> db + end + top[\"top\"] + top --> api +" + ); + } + + #[test] + fn special_chars_in_ids() { + let mut nodes = IndexMap::new(); + nodes.insert("my node".into(), NodeInfo::new("my node")); + nodes.insert("has\"quotes".into(), NodeInfo::new("a \"label\"")); + let graph = DepGraph { + nodes, + edges: vec![Edge { + from: "my node".into(), + to: "has\"quotes".into(), + ..Default::default() + }], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert!(output.contains("my_node[\"my node\"]")); + assert!(output.contains("has_22quotes[\"a "label"\"]")); + assert!(output.contains("my_node --> has_22quotes")); + } + + #[test] + fn node_attrs_dropped() { + let mut nodes = IndexMap::new(); + nodes.insert( + "a".into(), + NodeInfo { + label: "Alpha".into(), + node_type: None, + attrs: IndexMap::from([ + ("shape".into(), "box".into()), + ("color".into(), "red".into()), + ]), + }, + ); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + // Mermaid doesn't support arbitrary attrs in basic syntax, so they're dropped + assert!(output.contains("a[\"Alpha\"]")); + assert!(!output.contains("shape")); + assert!(!output.contains("color")); + } + + #[test] + fn edge_attrs_dropped() { + let graph = DepGraph { + nodes: IndexMap::new(), + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + label: Some("uses".into()), + attrs: IndexMap::from([("style".into(), "dashed".into())]), + }], + ..Default::default() + }; + let output = emit_to_string(&graph); + assert!(output.contains("a -->|\"uses\"| b")); + assert!(!output.contains("dashed")); + } + + #[test] + fn shape_attrs_mapped_to_mermaid() { + let mut nodes = IndexMap::new(); + nodes.insert( + "n1".into(), + NodeInfo { + label: "Circle".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "circle".into())]), + }, + ); + nodes.insert( + "n2".into(), + NodeInfo { + label: "Diamond".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "diamond".into())]), + }, + ); + nodes.insert( + "n3".into(), + NodeInfo { + label: "Hexagon".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "hexagon".into())]), + }, + ); + nodes.insert( + "n4".into(), + NodeInfo { + label: "Ellipse".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "ellipse".into())]), + }, + ); + nodes.insert( + "n5".into(), + NodeInfo { + label: "Cylinder".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "cylinder".into())]), + }, + ); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + assert!(output.contains("n1((\"Circle\"))")); + assert!(output.contains("n2{\"Diamond\"}")); + assert!(output.contains("n3{{\"Hexagon\"}}")); + assert!(output.contains("n4([\"Ellipse\"])")); + assert!(output.contains("n5[(\"Cylinder\")]")); + } + + #[test] + fn explicit_shape_attr_used() { + let mut nodes = IndexMap::new(); + nodes.insert( + "lib1".into(), + NodeInfo { + label: "Library".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "box".into())]), + }, + ); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + // shape=box maps to rectangle brackets + assert!(output.contains("lib1[\"Library\"]")); + } + + #[test] + fn unknown_shape_falls_back_to_rectangle() { + let mut nodes = IndexMap::new(); + nodes.insert( + "n1".into(), + NodeInfo { + label: "Unknown".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "trapezium".into())]), + }, + ); + let graph = DepGraph { + nodes, + ..Default::default() + }; + let output = emit_to_string(&graph); + // Unknown shape should fall back to rectangle + assert!(output.contains("n1[\"Unknown\"]")); + } +} diff --git a/crates/deptangle-io/src/emit/mod.rs b/crates/deptangle-io/src/emit/mod.rs new file mode 100644 index 0000000..13b9e22 --- /dev/null +++ b/crates/deptangle-io/src/emit/mod.rs @@ -0,0 +1,134 @@ +mod depfile; +pub(crate) mod dot; +mod mermaid; +mod pathlist; +mod tgf; +mod tree; +mod walk; + +use std::io::Write; +use std::path::Path; + +use clap::ValueEnum; +use deptangle_graph::DepGraph; + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum OutputFormat { + Dot, + Mermaid, + Tgf, + Depfile, + Tree, + Pathlist, +} + +impl TryFrom<&Path> for OutputFormat { + type Error = eyre::Report; + + fn try_from(path: &Path) -> Result { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .ok_or_else(|| eyre::eyre!("no file extension: {}", path.display()))?; + match ext { + "dot" | "gv" => Ok(Self::Dot), + "mmd" | "mermaid" => Ok(Self::Mermaid), + "tgf" => Ok(Self::Tgf), + "d" => Ok(Self::Depfile), + _ => eyre::bail!("unrecognized dependency graph file extension: .{ext}"), + } + } +} + +/// Resolve output format using explicit flag, file extension, or default to DOT. +/// +/// Resolution order: +/// 1. Explicit flag if provided +/// 2. File extension if path is available +/// 3. Default to DOT format +/// +/// Returns an error if file extension is present but unrecognized. +pub fn resolve_output_format( + flag: Option, + path: Option<&Path>, +) -> eyre::Result { + if let Some(f) = flag { + return Ok(f); + } + match path.map(OutputFormat::try_from) { + Some(Ok(f)) => { + tracing::info!("Detected output format: {f:?} from file extension"); + Ok(f) + } + Some(Err(e)) => Err( + e.wrap_err("Failed to detect output format from file extension; use --output-format") + ), + None => Ok(OutputFormat::Dot), + } +} + +/// Emit a [`DepGraph`] in the given output format. +/// +/// Not every format can represent all graph features. The table below +/// summarises what each emitter preserves: +/// +/// | Format | Graph attrs | Node label | Node attrs | Edge label | Edge attrs | +/// |----------|-------------|------------|------------|------------|------------| +/// | DOT | yes | yes | yes | yes | yes | +/// | Mermaid | direction | yes | shapes | yes | dropped | +/// | TGF | dropped | yes | dropped | yes | dropped | +/// | Tree | dropped | yes | dropped | dropped | dropped | +/// | Pathlist | dropped | yes | dropped | dropped | dropped | +/// | Depfile | dropped | dropped | dropped | dropped | dropped | +/// +/// Features marked "dropped" are silently discarded. Converting from a +/// rich format (e.g. DOT) to a lossy one (e.g. Depfile) is intentionally +/// non-destructive to the source data -- the information simply isn't +/// written to the output. +pub fn emit(format: OutputFormat, graph: &DepGraph, writer: &mut dyn Write) -> eyre::Result<()> { + match format { + OutputFormat::Dot => dot::emit(graph, writer), + OutputFormat::Mermaid => mermaid::emit(graph, writer), + OutputFormat::Tgf => tgf::emit(graph, writer), + OutputFormat::Depfile => depfile::emit(graph, writer), + OutputFormat::Pathlist => pathlist::emit(graph, writer), + OutputFormat::Tree => tree::emit(graph, writer), + } +} + +#[cfg(test)] +pub(crate) mod fixtures { + use deptangle_graph::{DepGraph, Edge, NodeInfo}; + use indexmap::IndexMap; + + /// A small graph for testing: a -> b -> c, a -> c + pub fn sample_graph() -> DepGraph { + let mut nodes = IndexMap::new(); + nodes.insert("a".into(), NodeInfo::new("alpha")); + nodes.insert("b".into(), NodeInfo::new("bravo")); + nodes.insert("c".into(), NodeInfo::new("c")); + + DepGraph { + nodes, + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + label: Some("depends".into()), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "c".into(), + ..Default::default() + }, + ], + ..Default::default() + } + } +} diff --git a/crates/deptangle-io/src/emit/pathlist.rs b/crates/deptangle-io/src/emit/pathlist.rs new file mode 100644 index 0000000..e09834e --- /dev/null +++ b/crates/deptangle-io/src/emit/pathlist.rs @@ -0,0 +1,353 @@ +use std::io::Write; + +use deptangle_graph::DepGraph; + +use super::walk::{self, TreeVisitor, VisitContext, VisitStatus}; + +/// Emit a [`DepGraph`] as a pathlist (one path per line). +/// +/// Performs a DFS tree walk and emits one line per leaf node, joining +/// ancestor labels with `/` to form a path. Intermediate nodes (those +/// with children that are being expanded) do not produce output lines -- +/// they appear only as path prefixes. +/// +/// Nodes whose subtrees are truncated are annotated with tab-separated +/// markers and a trailing `/`: +/// - `path/to/node/\t(*)` for nodes whose children were already expanded elsewhere +/// - `path/to/node/\t(cycle)` for back-edges (cycles) +/// +/// Childless nodes that were already visited are emitted as plain leaves +/// (no marker) since there is no subtree being suppressed. +/// +/// The markers can be stripped with `cut -f1` or filtered with `cut -f2`. +/// +/// Preserves node labels (as path components). Everything else is +/// silently dropped: graph attrs, node attrs, edge labels, edge attrs. +pub fn emit(graph: &DepGraph, writer: &mut dyn Write) -> eyre::Result<()> { + let mut visitor = PathlistVisitor { + writer, + stack: Vec::new(), + }; + walk::walk(graph, &mut visitor) +} + +struct PathlistVisitor<'w> { + writer: &'w mut dyn Write, + stack: Vec, +} + +impl TreeVisitor for PathlistVisitor<'_> { + fn visit(&mut self, ctx: &VisitContext) -> eyre::Result<()> { + self.stack.truncate(ctx.depth); + let label = &ctx.info.label; + self.stack.push(label.to_string()); + + let is_leaf = ctx.child_count == 0; + + match ctx.status { + // Leaf node (no children): emit plain path. + _ if is_leaf => { + let path = self.stack.join("/"); + writeln!(self.writer, "{path}")?; + } + // Non-leaf already expanded elsewhere: subtree suppressed. + VisitStatus::AlreadyExpanded => { + let path = self.stack.join("/"); + writeln!(self.writer, "{path}/\t(*)")?; + } + // Non-leaf cycle back-edge: subtree suppressed. + VisitStatus::Cycle => { + let path = self.stack.join("/"); + writeln!(self.writer, "{path}/\t(cycle)")?; + } + // Non-leaf first visit: intermediate, don't emit. + VisitStatus::First => {} + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use deptangle_graph::{Edge, NodeInfo}; + use indexmap::IndexMap; + + use super::*; + + fn emit_to_string(graph: &DepGraph) -> String { + let mut buf = Vec::new(); + emit(graph, &mut buf).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn empty_graph() { + assert_eq!(emit_to_string(&DepGraph::default()), ""); + } + + #[test] + fn single_node() { + let graph = DepGraph { + nodes: IndexMap::from([("readme".into(), NodeInfo::new("README.md"))]), + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "README.md\n"); + } + + #[test] + fn linear_chain() { + let graph = DepGraph { + nodes: IndexMap::from([ + ("src".into(), NodeInfo::new("src")), + ("src/parse".into(), NodeInfo::new("parse")), + ("src/parse/tgf.rs".into(), NodeInfo::new("tgf.rs")), + ]), + edges: vec![ + Edge { + from: "src".into(), + to: "src/parse".into(), + ..Default::default() + }, + Edge { + from: "src/parse".into(), + to: "src/parse/tgf.rs".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "src/parse/tgf.rs\n"); + } + + #[test] + fn branching() { + let graph = DepGraph { + nodes: IndexMap::from([ + ("src".into(), NodeInfo::new("src")), + ("src/a.rs".into(), NodeInfo::new("a.rs")), + ("src/b.rs".into(), NodeInfo::new("b.rs")), + ]), + edges: vec![ + Edge { + from: "src".into(), + to: "src/a.rs".into(), + ..Default::default() + }, + Edge { + from: "src".into(), + to: "src/b.rs".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "src/a.rs\nsrc/b.rs\n"); + } + + #[test] + fn diamond_already_expanded() { + // a -> b -> d, a -> c -> d + let graph = DepGraph { + nodes: IndexMap::from([ + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), + ]), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "d".into(), + ..Default::default() + }, + Edge { + from: "c".into(), + to: "d".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + // d is a leaf with no children; it was already visited but nothing + // is suppressed, so it appears as a plain leaf both times. + assert_eq!(emit_to_string(&graph), "a/b/d\na/c/d\n"); + } + + #[test] + fn already_expanded_with_children() { + // a -> b -> c -> d, a -> c -> d + // c has children (d), so when revisited under a it's a suppressed subtree. + let graph = DepGraph { + nodes: IndexMap::from([ + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), + ]), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "c".into(), + to: "d".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + // c was expanded under b (showing c/d), so under a it's truncated. + assert_eq!(emit_to_string(&graph), "a/b/c/d\na/c/\t(*)\n"); + } + + #[test] + fn cycle_marker() { + // a -> b -> c -> b + let graph = DepGraph { + nodes: IndexMap::from([ + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ]), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "c".into(), + to: "b".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "a/b/c/b/\t(cycle)\n"); + } + + #[test] + fn falls_back_to_node_id() { + // Nodes without labels use the node ID as the path component. + let graph = DepGraph { + nodes: IndexMap::from([ + ("root".into(), NodeInfo::new("root")), + ("child".into(), NodeInfo::new("child")), + ]), + edges: vec![Edge { + from: "root".into(), + to: "child".into(), + ..Default::default() + }], + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "root/child\n"); + } + + #[test] + fn sample_graph() { + // a(alpha) -> b(bravo) -> c, a -> c + // c has no label, so falls back to node ID "c". + let graph = crate::emit::fixtures::sample_graph(); + // c has no children, so no subtree is suppressed -- plain leaf. + assert_eq!(emit_to_string(&graph), "alpha/bravo/c\nalpha/c\n"); + } + + #[test] + fn multiple_roots() { + let graph = DepGraph { + nodes: IndexMap::from([ + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ]), + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "a\nb\n"); + } + + #[test] + fn drops_attrs_and_edge_labels() { + let graph = DepGraph { + attrs: IndexMap::from([("name".into(), "deps".into())]), + nodes: IndexMap::from([ + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "box".into())]), + }, + ), + ("b".into(), NodeInfo::new("b")), + ]), + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + label: Some("uses".into()), + attrs: IndexMap::from([("style".into(), "dashed".into())]), + }], + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "a/b\n"); + } + + #[test] + fn roundtrip_simple() { + let input = "src/a.rs\nsrc/b.rs\n"; + let graph = crate::parse::parse(crate::parse::InputFormat::Pathlist, input).unwrap(); + assert_eq!(emit_to_string(&graph), input); + } + + #[test] + fn roundtrip_nested() { + let input = "a/b/c\na/b/d\na/e\n"; + let graph = crate::parse::parse(crate::parse::InputFormat::Pathlist, input).unwrap(); + assert_eq!(emit_to_string(&graph), input); + } + + #[test] + fn subgraph_nodes_included() { + let graph = DepGraph { + nodes: IndexMap::from([("root".into(), NodeInfo::new("root"))]), + edges: vec![Edge { + from: "root".into(), + to: "child".into(), + ..Default::default() + }], + subgraphs: vec![DepGraph { + nodes: IndexMap::from([("child".into(), NodeInfo::new("child"))]), + ..Default::default() + }], + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "root/child\n"); + } +} diff --git a/crates/deptangle-io/src/emit/tgf.rs b/crates/deptangle-io/src/emit/tgf.rs new file mode 100644 index 0000000..0d7ef4f --- /dev/null +++ b/crates/deptangle-io/src/emit/tgf.rs @@ -0,0 +1,179 @@ +use std::io::Write; + +use deptangle_graph::DepGraph; + +/// Replace whitespace in a node ID with underscores so it survives TGF roundtripping. +/// +/// TGF uses whitespace to separate tokens, so bare whitespace inside an ID +/// would be mis-parsed on re-read. Tabs and spaces are both replaced. +fn sanitize_id(id: &str) -> String { + if id.contains(|c: char| c.is_ascii_whitespace()) { + id.chars() + .map(|c| if c.is_ascii_whitespace() { '_' } else { c }) + .collect() + } else { + id.to_string() + } +} + +/// Emit a [`DepGraph`] as TGF (Trivial Graph Format). +/// +/// Preserves node IDs, node labels, edge endpoints, and edge labels. +/// Graph-level attrs, node attrs, and edge attrs are silently dropped +/// (TGF has no syntax for them). Whitespace in node IDs is replaced +/// with underscores to ensure the output can be parsed back. +pub fn emit(graph: &DepGraph, writer: &mut dyn Write) -> eyre::Result<()> { + for (id, info) in graph.all_nodes() { + let id = sanitize_id(id); + if info.label != *id { + writeln!(writer, "{id}\t{}", info.label)?; + } else { + writeln!(writer, "{id}")?; + } + } + + writeln!(writer, "#")?; + + for edge in graph.all_edges() { + let from = sanitize_id(&edge.from); + let to = sanitize_id(&edge.to); + match &edge.label { + Some(label) => writeln!(writer, "{from}\t{to}\t{label}")?, + None => writeln!(writer, "{from}\t{to}")?, + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use deptangle_graph::{Edge, NodeInfo}; + use indexmap::IndexMap; + + use super::*; + use crate::emit::fixtures::sample_graph; + + #[test] + fn emit_sample() { + let mut buf = Vec::new(); + emit(&sample_graph(), &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert_eq!( + output, + "a\talpha\nb\tbravo\nc\n#\na\tb\tdepends\nb\tc\na\tc\n" + ); + } + + #[test] + fn emit_empty() { + let mut buf = Vec::new(); + emit(&DepGraph::default(), &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert_eq!(output, "#\n"); + } + + #[test] + fn emit_nodes_only() { + let mut nodes = IndexMap::new(); + nodes.insert("x".into(), NodeInfo::new("xray")); + let graph = DepGraph { + nodes, + edges: vec![], + ..Default::default() + }; + let mut buf = Vec::new(); + emit(&graph, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert_eq!(output, "x\txray\n#\n"); + } + + #[test] + fn rich_graph_drops_attrs() { + let mut nodes = IndexMap::new(); + nodes.insert( + "a".into(), + NodeInfo { + label: "Alpha".into(), + node_type: None, + attrs: IndexMap::from([ + ("shape".into(), "box".into()), + ("color".into(), "red".into()), + ]), + }, + ); + nodes.insert("b".into(), NodeInfo::new("b")); + let graph = DepGraph { + attrs: IndexMap::from([ + ("name".into(), "deps".into()), + ("rankdir".into(), "LR".into()), + ]), + nodes, + edges: vec![deptangle_graph::Edge { + from: "a".into(), + to: "b".into(), + label: Some("uses".into()), + attrs: IndexMap::from([("style".into(), "dashed".into())]), + }], + ..Default::default() + }; + let mut buf = Vec::new(); + emit(&graph, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + // TGF only preserves IDs, labels, and edge labels -- all attrs are dropped. + assert_eq!(output, "a\tAlpha\nb\n#\na\tb\tuses\n"); + } + + #[test] + fn whitespace_in_ids_replaced_with_underscores() { + let mut nodes = IndexMap::new(); + nodes.insert("my app v1.0".into(), NodeInfo::new("my app")); + nodes.insert("lib foo v2.0".into(), NodeInfo::new("lib foo")); + let graph = DepGraph { + nodes, + edges: vec![Edge { + from: "my app v1.0".into(), + to: "lib foo v2.0".into(), + ..Default::default() + }], + ..Default::default() + }; + let mut buf = Vec::new(); + emit(&graph, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert_eq!( + output, + "my_app_v1.0\tmy app\nlib_foo_v2.0\tlib foo\n#\nmy_app_v1.0\tlib_foo_v2.0\n" + ); + } + + #[test] + fn subgraph_nodes_and_edges_included() { + let graph = DepGraph { + nodes: IndexMap::from([("top".into(), NodeInfo::new("top"))]), + edges: vec![Edge { + from: "top".into(), + to: "a".into(), + ..Default::default() + }], + subgraphs: vec![DepGraph { + nodes: IndexMap::from([ + ("a".into(), NodeInfo::new("Alpha")), + ("b".into(), NodeInfo::new("b")), + ]), + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + label: Some("uses".into()), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + let mut buf = Vec::new(); + emit(&graph, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert_eq!(output, "top\na\tAlpha\nb\n#\ntop\ta\na\tb\tuses\n"); + } +} diff --git a/crates/deptangle-io/src/emit/tree.rs b/crates/deptangle-io/src/emit/tree.rs new file mode 100644 index 0000000..dfbac87 --- /dev/null +++ b/crates/deptangle-io/src/emit/tree.rs @@ -0,0 +1,513 @@ +use std::io::Write; + +use deptangle_graph::DepGraph; + +use super::walk::{self, TreeVisitor, VisitContext, VisitStatus}; + +/// Emit a [`DepGraph`] as a box-drawing tree (matching `tree` CLI output). +/// +/// Performs a DFS tree walk and emits every node with box-drawing +/// prefixes that show the tree structure. Root nodes appear at the +/// left margin; children are indented with branch characters. +/// +/// Nodes whose subtrees are truncated carry a suffix marker: +/// - ` (*)` for nodes whose children were already expanded elsewhere +/// - ` (cycle)` for back-edges (cycles) +/// +/// Childless nodes that were already visited are emitted without a +/// marker since there is no subtree being suppressed. +/// +/// Preserves node labels. Everything else is silently dropped: +/// graph attrs, node attrs, edge labels, edge attrs. +pub fn emit(graph: &DepGraph, writer: &mut dyn Write) -> eyre::Result<()> { + let mut visitor = TreeEmitVisitor { + writer, + prefix_stack: Vec::new(), + }; + walk::walk(graph, &mut visitor) +} + +struct TreeEmitVisitor<'w> { + writer: &'w mut dyn Write, + // prefix_stack[i] = is_last for the ancestor at depth i+1. + // Used to decide continuation columns: is_last draws spaces, + // otherwise draws a vertical bar. + prefix_stack: Vec, +} + +impl TreeVisitor for TreeEmitVisitor<'_> { + fn visit(&mut self, ctx: &VisitContext) -> eyre::Result<()> { + // Keep only the ancestor entries for depths 1..ctx.depth. + self.prefix_stack.truncate(ctx.depth.saturating_sub(1)); + + if ctx.depth > 0 { + // Continuation columns for each ancestor. + for &ancestor_is_last in &self.prefix_stack { + if ancestor_is_last { + write!(self.writer, " ")?; + } else { + write!(self.writer, "│ ")?; + } + } + // Branch for this node. + if ctx.is_last { + write!(self.writer, "└── ")?; + } else { + write!(self.writer, "├── ")?; + } + } + + let label = &ctx.info.label; + write!(self.writer, "{label}")?; + + match ctx.status { + VisitStatus::AlreadyExpanded if ctx.child_count > 0 => { + write!(self.writer, " (*)")?; + } + VisitStatus::Cycle => { + write!(self.writer, " (cycle)")?; + } + _ => {} + } + + writeln!(self.writer)?; + self.prefix_stack.push(ctx.is_last); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use deptangle_graph::{Edge, NodeInfo}; + use indexmap::IndexMap; + + use super::*; + + fn emit_to_string(graph: &DepGraph) -> String { + let mut buf = Vec::new(); + emit(graph, &mut buf).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn empty_graph() { + assert_eq!(emit_to_string(&DepGraph::default()), ""); + } + + #[test] + fn single_root() { + let graph = DepGraph { + nodes: IndexMap::from([("r".into(), NodeInfo::new("root"))]), + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "root\n"); + } + + #[test] + fn simple_children() { + // root -> a, root -> b + let graph = DepGraph { + nodes: IndexMap::from([ + ("root".into(), NodeInfo::new("root")), + ("a".into(), NodeInfo::new("alpha")), + ("b".into(), NodeInfo::new("bravo")), + ]), + edges: vec![ + Edge { + from: "root".into(), + to: "a".into(), + ..Default::default() + }, + Edge { + from: "root".into(), + to: "b".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!( + emit_to_string(&graph), + "\ +root +├── alpha +└── bravo +" + ); + } + + #[test] + fn nested() { + // root -> a -> b, root -> c + let graph = DepGraph { + nodes: IndexMap::from([ + ("root".into(), NodeInfo::new("root")), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ]), + edges: vec![ + Edge { + from: "root".into(), + to: "a".into(), + ..Default::default() + }, + Edge { + from: "root".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!( + emit_to_string(&graph), + "\ +root +├── a +│ └── b +└── c +" + ); + } + + #[test] + fn deep_nesting_continuation() { + // root -> a -> b -> c, root -> d + // Verifies continuation columns render correctly at depth 3. + let graph = DepGraph { + nodes: IndexMap::from([ + ("root".into(), NodeInfo::new("root")), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), + ]), + edges: vec![ + Edge { + from: "root".into(), + to: "a".into(), + ..Default::default() + }, + Edge { + from: "root".into(), + to: "d".into(), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "c".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + // a is not last (d follows), so its column draws a vertical bar. + // b is last child of a, so its column draws spaces. + assert_eq!( + emit_to_string(&graph), + "\ +root +├── a +│ └── b +│ └── c +└── d +" + ); + } + + #[test] + fn parallel_continuation_bars() { + // root -> a -> b -> x, a -> c, root -> d + // Both a and b have siblings after them, so two vertical bars + // appear in parallel when rendering x at depth 3. + let graph = DepGraph { + nodes: IndexMap::from([ + ("root".into(), NodeInfo::new("root")), + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), + ("x".into(), NodeInfo::new("x")), + ]), + edges: vec![ + Edge { + from: "root".into(), + to: "a".into(), + ..Default::default() + }, + Edge { + from: "root".into(), + to: "d".into(), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "x".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!( + emit_to_string(&graph), + "\ +root +├── a +│ ├── b +│ │ └── x +│ └── c +└── d +" + ); + } + + #[test] + fn diamond_leaf_no_marker() { + // a -> b -> d, a -> c -> d + // d is a leaf -- no subtree suppressed, no marker. + let graph = DepGraph { + nodes: IndexMap::from([ + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), + ]), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "d".into(), + ..Default::default() + }, + Edge { + from: "c".into(), + to: "d".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!( + emit_to_string(&graph), + "\ +a +├── b +│ └── d +└── c + └── d +" + ); + } + + #[test] + fn already_expanded_with_children() { + // a -> b, a -> c, b -> c, c -> d + // c is expanded under b (showing d), then truncated under a. + let graph = DepGraph { + nodes: IndexMap::from([ + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ("d".into(), NodeInfo::new("d")), + ]), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "c".into(), + to: "d".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!( + emit_to_string(&graph), + "\ +a +├── b +│ └── c +│ └── d +└── c (*) +" + ); + } + + #[test] + fn cycle_marker() { + // a -> b -> c -> b + let graph = DepGraph { + nodes: IndexMap::from([ + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ("c".into(), NodeInfo::new("c")), + ]), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "c".into(), + to: "b".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + assert_eq!( + emit_to_string(&graph), + "\ +a +└── b + └── c + └── b (cycle) +" + ); + } + + #[test] + fn falls_back_to_node_id() { + let graph = DepGraph { + nodes: IndexMap::from([ + ("root".into(), NodeInfo::new("root")), + ("child".into(), NodeInfo::new("child")), + ]), + edges: vec![Edge { + from: "root".into(), + to: "child".into(), + ..Default::default() + }], + ..Default::default() + }; + assert_eq!( + emit_to_string(&graph), + "\ +root +└── child +" + ); + } + + #[test] + fn sample_graph() { + // a(alpha) -> b(bravo) -> c, a -> c + // c has no label, falls back to "c". c is a leaf, no marker. + let graph = crate::emit::fixtures::sample_graph(); + assert_eq!( + emit_to_string(&graph), + "\ +alpha +├── bravo +│ └── c +└── c +" + ); + } + + #[test] + fn multiple_roots() { + let graph = DepGraph { + nodes: IndexMap::from([ + ("a".into(), NodeInfo::new("a")), + ("b".into(), NodeInfo::new("b")), + ]), + ..Default::default() + }; + assert_eq!(emit_to_string(&graph), "a\nb\n"); + } + + #[test] + fn roundtrip() { + let input = "\ +root +├── a +│ └── b +└── c +"; + let graph = crate::parse::parse(crate::parse::InputFormat::Tree, input).unwrap(); + assert_eq!(emit_to_string(&graph), input); + } + + #[test] + fn drops_attrs_and_edge_labels() { + let graph = DepGraph { + attrs: IndexMap::from([("name".into(), "deps".into())]), + nodes: IndexMap::from([ + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: IndexMap::from([("shape".into(), "box".into())]), + }, + ), + ("b".into(), NodeInfo::new("b")), + ]), + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + label: Some("uses".into()), + attrs: IndexMap::from([("style".into(), "dashed".into())]), + }], + ..Default::default() + }; + assert_eq!( + emit_to_string(&graph), + "\ +a +└── b +" + ); + } +} diff --git a/crates/deptangle-io/src/emit/walk.rs b/crates/deptangle-io/src/emit/walk.rs new file mode 100644 index 0000000..d2828df --- /dev/null +++ b/crates/deptangle-io/src/emit/walk.rs @@ -0,0 +1,682 @@ +use std::collections::HashSet; + +use deptangle_graph::{DepGraph, NodeInfo}; +use indexmap::IndexMap; + +/// Status of a node during DFS tree traversal. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VisitStatus { + /// First visit -- the walker will recurse into this node's children. + First, + /// Already fully expanded from a different path. Corresponds to `(*)` marker. + AlreadyExpanded, + /// Ancestor on the current DFS path (back-edge). Corresponds to `(cycle)` marker. + Cycle, +} + +/// Context passed to a [`TreeVisitor`] at each visited node. +pub struct VisitContext<'a> { + /// Node ID. + // the two DFS emitters only use the node label, but the ID is still available for future visitors + #[allow(unused)] + pub node_id: &'a str, + /// Node metadata (label, attrs). + pub info: &'a NodeInfo, + /// Depth in the traversal tree (0 for roots). + pub depth: usize, + /// True if this is the last sibling at this depth. + pub is_last: bool, + /// Number of children in the adjacency list (regardless of visit status). + pub child_count: usize, + /// Visit status. + pub status: VisitStatus, +} + +/// Trait for visiting nodes during DFS tree traversal. +pub trait TreeVisitor { + fn visit(&mut self, ctx: &VisitContext) -> eyre::Result<()>; +} + +/// Walk a [`DepGraph`] as a tree using DFS. +/// +/// Finds root nodes (no incoming edges), iterates over them in order, and performs DFS from each. +/// A shared visited set ensures each node's subtree is expanded only once. Already-expanded nodes +/// are reported with [`VisitStatus::AlreadyExpanded`]. Back-edges to ancestors on the current path +/// are reported with [`VisitStatus::Cycle`]. +/// +/// If no root nodes are found (due to all candidates being a part of a cycle), no nodes will be +/// visited. +pub fn walk(graph: &DepGraph, visitor: &mut dyn TreeVisitor) -> eyre::Result<()> { + let data = GraphData { + nodes: graph.all_nodes(), + adj: graph.adjacency_list(), + default_info: NodeInfo::new(""), + }; + + // Find roots: nodes with no incoming edges. + let targets: HashSet<&str> = graph.all_edges().iter().map(|e| e.to.as_str()).collect(); + let roots: Vec<&str> = data + .nodes + .keys() + .map(String::as_str) + .filter(|n| !targets.contains(n)) + .collect(); + + let mut visited = HashSet::new(); + let mut in_progress = HashSet::new(); + + let root_count = roots.len(); + for (i, root) in roots.iter().enumerate() { + dfs( + root, + 0, + i == root_count - 1, + &data, + &mut visited, + &mut in_progress, + visitor, + )?; + } + + Ok(()) +} + +struct GraphData<'a> { + nodes: &'a IndexMap, + adj: &'a IndexMap>, + default_info: NodeInfo, +} + +fn dfs<'a>( + node: &'a str, + depth: usize, + is_last: bool, + data: &'a GraphData<'a>, + visited: &mut HashSet<&'a str>, + in_progress: &mut HashSet<&'a str>, + visitor: &mut dyn TreeVisitor, +) -> eyre::Result<()> { + let info = data.nodes.get(node).unwrap_or(&data.default_info); + let children = data.adj.get(node); + let child_count = children.map_or(0, |c| c.len()); + + // Cycle: node is an ancestor on the current DFS path. + if in_progress.contains(node) { + visitor.visit(&VisitContext { + node_id: node, + info, + depth, + is_last, + child_count, + status: VisitStatus::Cycle, + })?; + return Ok(()); + } + + // Already expanded: node was fully visited from a different path. + if visited.contains(node) { + visitor.visit(&VisitContext { + node_id: node, + info, + depth, + is_last, + child_count, + status: VisitStatus::AlreadyExpanded, + })?; + return Ok(()); + } + + // First visit. + visitor.visit(&VisitContext { + node_id: node, + info, + depth, + is_last, + child_count, + status: VisitStatus::First, + })?; + + in_progress.insert(node); + + if let Some(children) = children { + let len = children.len(); + for (i, child) in children.iter().enumerate() { + dfs( + child.as_str(), + depth + 1, + i == len - 1, + data, + visited, + in_progress, + visitor, + )?; + } + } + + in_progress.remove(node); + visited.insert(node); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use deptangle_graph::{Edge, NodeInfo}; + use indexmap::IndexMap; + + use super::*; + + #[derive(Debug, PartialEq)] + struct Visit { + node: String, + depth: usize, + is_last: bool, + child_count: usize, + status: VisitStatus, + } + + fn v( + node: &str, + depth: usize, + is_last: bool, + child_count: usize, + status: VisitStatus, + ) -> Visit { + Visit { + node: node.to_string(), + depth, + is_last, + child_count, + status, + } + } + + struct CollectVisitor { + visits: Vec, + } + + impl CollectVisitor { + fn new() -> Self { + Self { visits: Vec::new() } + } + } + + impl TreeVisitor for CollectVisitor { + fn visit(&mut self, ctx: &VisitContext) -> eyre::Result<()> { + self.visits.push(Visit { + node: ctx.node_id.to_string(), + depth: ctx.depth, + is_last: ctx.is_last, + child_count: ctx.child_count, + status: ctx.status, + }); + Ok(()) + } + } + + #[test] + fn empty_graph() { + let mut visitor = CollectVisitor::new(); + walk(&DepGraph::default(), &mut visitor).unwrap(); + assert_eq!(visitor.visits, vec![]); + } + + #[test] + fn single_node() { + let graph = DepGraph { + nodes: IndexMap::from([( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + )]), + ..Default::default() + }; + let mut visitor = CollectVisitor::new(); + walk(&graph, &mut visitor).unwrap(); + assert_eq!(visitor.visits, vec![v("a", 0, true, 0, VisitStatus::First)]); + } + + #[test] + fn linear_chain() { + let graph = DepGraph { + nodes: IndexMap::from([ + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "c".into(), + NodeInfo { + label: "c".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ]), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "c".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + let mut visitor = CollectVisitor::new(); + walk(&graph, &mut visitor).unwrap(); + assert_eq!( + visitor.visits, + vec![ + v("a", 0, true, 1, VisitStatus::First), + v("b", 1, true, 1, VisitStatus::First), + v("c", 2, true, 0, VisitStatus::First), + ] + ); + } + + #[test] + fn diamond_dag() { + // a -> b -> d, a -> c -> d + // d is expanded under b, then AlreadyExpanded under c. + let graph = DepGraph { + nodes: IndexMap::from([ + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "c".into(), + NodeInfo { + label: "c".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "d".into(), + NodeInfo { + label: "d".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ]), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "a".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "d".into(), + ..Default::default() + }, + Edge { + from: "c".into(), + to: "d".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + let mut visitor = CollectVisitor::new(); + walk(&graph, &mut visitor).unwrap(); + assert_eq!( + visitor.visits, + vec![ + v("a", 0, true, 2, VisitStatus::First), + v("b", 1, false, 1, VisitStatus::First), + v("d", 2, true, 0, VisitStatus::First), + v("c", 1, true, 1, VisitStatus::First), + v("d", 2, true, 0, VisitStatus::AlreadyExpanded), + ] + ); + } + + #[test] + fn visitor_skips_cycles() { + // a -> b -> a + let graph = DepGraph { + nodes: IndexMap::from([ + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ]), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "a".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + let mut visitor = CollectVisitor::new(); + walk(&graph, &mut visitor).unwrap(); + // Because the root is involved in a cycle, the visitor will never visit any of the nodes + assert_eq!(visitor.visits, vec![]); + } + + #[test] + fn self_loop() { + // a -> a + let graph = DepGraph { + nodes: IndexMap::from([( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + )]), + edges: vec![Edge { + from: "a".into(), + to: "a".into(), + ..Default::default() + }], + ..Default::default() + }; + let mut visitor = CollectVisitor::new(); + walk(&graph, &mut visitor).unwrap(); + // a has an incoming edge (from itself), so it's not a root. + // No roots, no visits. + assert_eq!(visitor.visits, vec![]); + } + + #[test] + fn cycle_with_entry() { + // a -> b -> c -> b (c cycles back to b, a is the root) + let graph = DepGraph { + nodes: IndexMap::from([ + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "c".into(), + NodeInfo { + label: "c".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ]), + edges: vec![ + Edge { + from: "a".into(), + to: "b".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "c".into(), + to: "b".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + let mut visitor = CollectVisitor::new(); + walk(&graph, &mut visitor).unwrap(); + assert_eq!( + visitor.visits, + vec![ + v("a", 0, true, 1, VisitStatus::First), + v("b", 1, true, 1, VisitStatus::First), + v("c", 2, true, 1, VisitStatus::First), + v("b", 3, true, 1, VisitStatus::Cycle), + ] + ); + } + + #[test] + fn multiple_roots() { + // a (isolated), b -> c + let graph = DepGraph { + nodes: IndexMap::from([ + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "c".into(), + NodeInfo { + label: "c".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ]), + edges: vec![Edge { + from: "b".into(), + to: "c".into(), + ..Default::default() + }], + ..Default::default() + }; + let mut visitor = CollectVisitor::new(); + walk(&graph, &mut visitor).unwrap(); + assert_eq!( + visitor.visits, + vec![ + v("a", 0, false, 0, VisitStatus::First), + v("b", 0, true, 1, VisitStatus::First), + v("c", 1, true, 0, VisitStatus::First), + ] + ); + } + + #[test] + fn shared_across_roots() { + // a -> c, b -> c (both a and b are roots, c is shared) + let graph = DepGraph { + nodes: IndexMap::from([ + ( + "a".into(), + NodeInfo { + label: "a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "b".into(), + NodeInfo { + label: "b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "c".into(), + NodeInfo { + label: "c".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ]), + edges: vec![ + Edge { + from: "a".into(), + to: "c".into(), + ..Default::default() + }, + Edge { + from: "b".into(), + to: "c".into(), + ..Default::default() + }, + ], + ..Default::default() + }; + let mut visitor = CollectVisitor::new(); + walk(&graph, &mut visitor).unwrap(); + assert_eq!( + visitor.visits, + vec![ + v("a", 0, false, 1, VisitStatus::First), + v("c", 1, true, 0, VisitStatus::First), + v("b", 0, true, 1, VisitStatus::First), + v("c", 1, true, 0, VisitStatus::AlreadyExpanded), + ] + ); + } + + #[test] + fn sample_graph() { + // a -> b -> c, a -> c + let graph = crate::emit::fixtures::sample_graph(); + let mut visitor = CollectVisitor::new(); + walk(&graph, &mut visitor).unwrap(); + assert_eq!( + visitor.visits, + vec![ + v("a", 0, true, 2, VisitStatus::First), + v("b", 1, false, 1, VisitStatus::First), + v("c", 2, true, 0, VisitStatus::First), + v("c", 1, true, 0, VisitStatus::AlreadyExpanded), + ] + ); + } + + #[test] + fn subgraph_nodes_included() { + let graph = DepGraph { + nodes: IndexMap::from([( + "root".into(), + NodeInfo { + label: "root".into(), + node_type: None, + attrs: Default::default(), + }, + )]), + edges: vec![Edge { + from: "root".into(), + to: "sub_a".into(), + ..Default::default() + }], + subgraphs: vec![DepGraph { + nodes: IndexMap::from([ + ( + "sub_a".into(), + NodeInfo { + label: "sub_a".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ( + "sub_b".into(), + NodeInfo { + label: "sub_b".into(), + node_type: None, + attrs: Default::default(), + }, + ), + ]), + edges: vec![Edge { + from: "sub_a".into(), + to: "sub_b".into(), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + let mut visitor = CollectVisitor::new(); + walk(&graph, &mut visitor).unwrap(); + assert_eq!( + visitor.visits, + vec![ + v("root", 0, true, 1, VisitStatus::First), + v("sub_a", 1, true, 1, VisitStatus::First), + v("sub_b", 2, true, 0, VisitStatus::First), + ] + ); + } +} diff --git a/crates/deptangle-io/src/lib.rs b/crates/deptangle-io/src/lib.rs new file mode 100644 index 0000000..f61d5fc --- /dev/null +++ b/crates/deptangle-io/src/lib.rs @@ -0,0 +1,3 @@ +pub mod detect; +pub mod emit; +pub mod parse; diff --git a/crates/deptangle-io/src/parse/cargo_metadata.rs b/crates/deptangle-io/src/parse/cargo_metadata.rs new file mode 100644 index 0000000..57d36a5 --- /dev/null +++ b/crates/deptangle-io/src/parse/cargo_metadata.rs @@ -0,0 +1,281 @@ +use std::collections::HashMap; + +use cargo_metadata::{DependencyKind, Metadata, Package, PackageId}; +use deptangle_graph::{DepGraph, Edge, NodeInfo}; +use indexmap::IndexMap; + +/// Extract "name version" from a cargo package ID, stripping the source. +/// +/// Handles two formats: +/// - Old: "name version (source)" -> "serde 1.0.217" +/// - New: "source#name@version" or "source#version" -> "serde 1.0.217" or "name version" +fn extract_name_version(id: &str) -> String { + // Check if this is the new format (contains '#') + if let Some(after_hash) = id.split('#').nth(1) { + // Format: "source#name@version" or "source#version" + if let Some((name, version)) = after_hash.split_once('@') { + // Has '@', so: name@version + format!("{} {}", name, version) + } else { + // No '@', need to extract name from path before '#' + let name = id + .split('#') + .next() + .and_then(|path| path.rsplit('/').next()) + .unwrap_or("unknown"); + format!("{} {}", name, after_hash) + } + } else { + // Old format: "name version (source)" + if let Some(name_version) = id.split('(').next() { + name_version.trim().to_string() + } else { + id.to_string() + } + } +} + +/// Extract just the package name from a cargo package ID. +/// +/// Handles two formats: +/// - Old: "name version (source)" -> "serde" +/// - New: "source#name@version" -> "serde" +fn extract_package_name(id: &str) -> String { + if let Some(after_hash) = id.split('#').nth(1) { + // New format + if let Some((name, _version)) = after_hash.split_once('@') { + name.to_string() + } else { + // Path format, extract from before '#' + id.split('#') + .next() + .and_then(|path| path.rsplit('/').next()) + .unwrap_or(id) + .to_string() + } + } else { + // Old format + id.split_whitespace().next().unwrap_or(id).to_string() + } +} + +/// Extract the primary target kind from a Package using priority order. +/// Returns the highest-priority type found (e.g., "lib" for a package with lib + examples). +/// Priority: lib > proc-macro > bin > build-script > test > bench > example +fn extract_package_types(pkg: &Package) -> Option { + let types: Vec = pkg + .targets + .iter() + .flat_map(|t| &t.kind) + .map(|k| { + let kind_str = k.to_string(); + super::normalize_node_type(&kind_str) + }) + .collect(); + + if types.is_empty() { + return None; + } + + // Priority order: most fundamental/important types first + const PRIORITY: &[&str] = &[ + "lib", + "proc-macro", + "bin", + "build-script", + "test", + "bench", + "example", + ]; + + // Return the first match in priority order + for &priority_type in PRIORITY { + if types.iter().any(|t| t == priority_type) { + return Some(priority_type.to_string()); + } + } + + // Fallback: return the first type if none match the priority list + types.into_iter().next() +} + +pub fn parse(input: &str) -> eyre::Result { + let metadata: Metadata = serde_json::from_str(input)?; + + let resolve = metadata + .resolve + .ok_or_else(|| eyre::eyre!("cargo metadata missing 'resolve' field"))?; + + // Build a map from PackageId to Package for looking up optional dependencies + let package_map: HashMap<&PackageId, &Package> = + metadata.packages.iter().map(|pkg| (&pkg.id, pkg)).collect(); + + let mut graph = DepGraph::default(); + + // Create nodes + for node in &resolve.nodes { + let mut attrs = IndexMap::new(); + + // Get package info to extract name and version + if let Some(pkg) = package_map.get(&node.id) { + attrs.insert("version".to_string(), pkg.version.to_string()); + } + + // Store features if present + if !node.features.is_empty() { + attrs.insert("features".to_string(), node.features.join(",")); + } + + let node_id = extract_name_version(&node.id.repr); + let label = package_map + .get(&node.id) + .map(|pkg| pkg.name.to_string()) + .unwrap_or_else(|| extract_package_name(&node.id.repr)); + + let node_type = package_map + .get(&node.id) + .and_then(|pkg| extract_package_types(pkg)); + + graph.nodes.insert( + node_id, + NodeInfo { + label, + node_type, + attrs, + }, + ); + } + + // Create edges + for node in &resolve.nodes { + let source_id = extract_name_version(&node.id.repr); + let source_pkg = package_map.get(&node.id); + + for dep in &node.deps { + let mut edge_attrs = IndexMap::new(); + + // Collect dependency kinds for this edge + let kinds: Vec = dep + .dep_kinds + .iter() + .map(|dk| match dk.kind { + DependencyKind::Normal => "normal", + DependencyKind::Development => "dev", + DependencyKind::Build => "build", + DependencyKind::Unknown => "unknown", + }) + .map(String::from) + .collect(); + + // Store dependency kind on the edge + if !kinds.is_empty() { + edge_attrs.insert("kind".to_string(), kinds.join(",")); + } + + // Check if this is an optional dependency + if let Some(pkg) = source_pkg + && let Some(target_pkg) = package_map.get(&dep.pkg) + { + let target_name = target_pkg.name.to_string(); + if let Some(feature) = find_feature_for_optional_dep(pkg, &target_name) { + edge_attrs.insert("optional".to_string(), feature); + } + } + + graph.edges.push(Edge { + from: source_id.clone(), + to: extract_name_version(&dep.pkg.repr), + label: None, + attrs: edge_attrs, + }); + } + } + + Ok(graph) +} + +/// Find which feature enables an optional dependency. +/// +/// Returns the feature name if the dependency is optional and enabled by a feature. +fn find_feature_for_optional_dep(pkg: &Package, dep_name: &str) -> Option { + // Check if the dependency is optional + let is_optional = pkg + .dependencies + .iter() + .any(|d| d.name == dep_name && d.optional); + + if !is_optional { + return None; + } + + // Find which feature enables this dependency + // Features can enable dependencies with "dep:name" or just "name" (implicit) + for (feature_name, enables) in &pkg.features { + for item in enables { + // Check for "dep:name" or just "name" + if item == &format!("dep:{}", dep_name) || item == dep_name { + return Some(feature_name.clone()); + } + } + } + + // If no feature explicitly enables it, the dependency name itself might be a feature + if pkg.features.contains_key(dep_name) { + return Some(dep_name.to_string()); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_name_version() { + // Old format + assert_eq!( + extract_name_version( + "serde 1.0.217 (registry+https://github.com/rust-lang/crates.io-index)" + ), + "serde 1.0.217" + ); + assert_eq!( + extract_name_version("my-crate 0.1.0 (path+file:///home/user/project)"), + "my-crate 0.1.0" + ); + assert_eq!(extract_name_version("simple 1.0.0"), "simple 1.0.0"); + + // New format + assert_eq!( + extract_name_version( + "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.217" + ), + "serde 1.0.217" + ); + assert_eq!( + extract_name_version("path+file://~/src/deptangle/crates/deptangle-depgraph#0.5.0"), + "deptangle-depgraph 0.5.0" + ); + } + + #[test] + fn test_extract_package_name() { + assert_eq!( + extract_package_name( + "serde 1.0.217 (registry+https://github.com/rust-lang/crates.io-index)" + ), + "serde" + ); + assert_eq!( + extract_package_name("my-crate 0.1.0 (path+file:///home/user/project)"), + "my-crate" + ); + assert_eq!(extract_package_name("simple"), "simple"); + } + + // NOTE: Unit tests for the parse function are not included here because + // the cargo_metadata crate requires the full, valid JSON structure which + // is tedious to maintain in unit tests. Instead, we rely on integration + // tests using the real cargo-metadata.json fixture in tests/depconv.rs. +} diff --git a/crates/deptangle-io/src/parse/cargo_tree.rs b/crates/deptangle-io/src/parse/cargo_tree.rs new file mode 100644 index 0000000..eb579d1 --- /dev/null +++ b/crates/deptangle-io/src/parse/cargo_tree.rs @@ -0,0 +1,749 @@ +use deptangle_graph::{DepGraph, Edge, NodeInfo}; +use indexmap::IndexMap; + +/// Parse a line of `cargo tree` output into (depth, text), or None for blank lines +/// and section headers like `[dev-dependencies]`. +fn parse_line(line: &str) -> Option<(usize, &str)> { + let mut depth = 0; + let mut rest = line; + + loop { + // Unicode branch markers (terminal) + if rest.starts_with("├── ") || rest.starts_with("└── ") { + rest = &rest[10..]; + depth += 1; + break; + } + // Unicode continuation + if rest.starts_with("│ ") { + rest = &rest[6..]; + depth += 1; + continue; + } + // ASCII branch markers (terminal) + if rest.starts_with("|-- ") || rest.starts_with("`-- ") || rest.starts_with("\\-- ") { + rest = &rest[4..]; + depth += 1; + break; + } + // ASCII continuation (pipe + 3 spaces) or blank continuation (last-child ancestor) + if rest.starts_with("| ") || rest.starts_with(" ") { + rest = &rest[4..]; + depth += 1; + continue; + } + break; + } + + let text = rest.trim_end(); + if text.is_empty() || text.starts_with('[') { + None + } else { + Some((depth, text)) + } +} + +/// Parse the text portion of a cargo tree line. +/// +/// Returns (id, is_dup, node_type, dep_kind, attrs). The node ID is the full +/// `"name v1.2.3"` string. The version is also stored in `attrs["version"]`. +/// +/// - `node_type`: crate type, populated for `(proc-macro)` markers. +/// - `dep_kind`: dependency kind from `(build)` or `(dev)` annotations. +/// This describes the edge from parent to this node, not the node itself. +fn parse_node_text( + text: &str, +) -> ( + &str, + bool, + Option, + Option<&'static str>, + IndexMap, +) { + let mut rest = text; + let mut is_dup = false; + let mut node_type = None; + let mut dep_kind = None; + let mut attrs = IndexMap::new(); + + // Strip trailing (*) duplicate marker + if let Some(stripped) = rest.strip_suffix("(*)") { + rest = stripped.trim_end(); + is_dup = true; + } + + // Strip parenthesized annotations from the end + while rest.ends_with(')') { + let Some(open) = rest.rfind('(') else { break }; + let annotation = &rest[open + 1..rest.len() - 1]; + let before = rest[..open].trim_end(); + + match annotation { + "proc-macro" => { + node_type = Some(super::normalize_node_type(annotation)); + } + "build" => dep_kind = Some("build"), + "dev" => dep_kind = Some("dev"), + _ if annotation.contains('/') || annotation.starts_with('.') => { + attrs.insert("path".into(), annotation.into()); + } + _ => break, + } + rest = before; + } + + // Extract version into attrs, but keep "name v1.2.3" as the full ID + // for disambiguation (multiple versions of the same crate can coexist) + if let Some((_, version)) = split_name_version(rest) { + attrs.insert("version".into(), version.into()); + } + + (rest, is_dup, node_type, dep_kind, attrs) +} + +/// Split `"name v1.2.3"` into `("name", "v1.2.3")`, or None if no version token. +fn split_name_version(text: &str) -> Option<(&str, &str)> { + for (i, _) in text.match_indices(" v") { + let version = &text[i + 1..]; + if version[1..].starts_with(|c: char| c.is_ascii_digit()) { + return Some((&text[..i], version)); + } + } + None +} + +/// Parse a section header like `[dev-dependencies]` or `[build-dependencies]`. +/// Returns the dependency kind for the section, or None if not a section header. +fn parse_section(line: &str) -> Option<&'static str> { + let trimmed = line.trim(); + match trimmed { + "[dev-dependencies]" => Some("dev"), + "[build-dependencies]" => Some("build"), + _ => None, + } +} + +pub fn parse(input: &str) -> eyre::Result { + let mut graph = DepGraph::default(); + // stack[i] = node ID at depth i + let mut stack: Vec = Vec::new(); + // Current section kind (from [dev-dependencies] / [build-dependencies] headers). + // Applies to depth-1 edges (root -> direct child) only. + let mut section_kind: Option<&str> = None; + + for raw_line in input.lines() { + // Normalize NO-BREAK SPACE (U+00A0) to ASCII space + let owned; + let line = if raw_line.contains('\u{a0}') { + owned = raw_line.replace('\u{a0}', " "); + owned.as_str() + } else { + raw_line + }; + + // Check for section headers before tree-line parsing + if let Some(kind) = parse_section(line) { + section_kind = Some(kind); + continue; + } + + let (depth, text) = match parse_line(line) { + Some(pair) => pair, + None => continue, + }; + + if depth > stack.len() { + eyre::bail!("unexpected depth jump at line: {text:?}"); + } + + let (name, _is_dup, node_type, dep_kind, attrs) = parse_node_text(text); + let id = name.to_string(); + let label = match split_name_version(name) { + Some((crate_name, _)) => crate_name.to_string(), + None => name.to_string(), + }; + + // A new root node resets section_kind (workspace output has multiple roots + // separated by blank lines, and each root starts its own section context). + if depth == 0 { + section_kind = None; + } + + stack.truncate(depth); + + // Insert node if not already present (handles duplicates and repeated leaves) + if !graph.nodes.contains_key(&id) { + graph.nodes.insert( + id.clone(), + NodeInfo { + label, + node_type, + attrs, + }, + ); + } + + // Add edge from parent + if let Some(parent) = stack.last() { + // Determine edge kind: + // 1. Explicit annotation on the node: (build) or (dev) + // 2. Section header applies to depth-1 edges (root -> child) + let edge_kind = dep_kind.or(if depth == 1 { section_kind } else { None }); + let mut edge_attrs = IndexMap::new(); + if let Some(kind) = edge_kind { + edge_attrs.insert("kind".to_string(), kind.to_string()); + } + graph.edges.push(Edge { + from: parent.clone(), + to: id.clone(), + attrs: edge_attrs, + ..Default::default() + }); + } + + stack.push(id); + } + + Ok(graph) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input() { + let graph = parse("").unwrap(); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + } + + #[test] + fn root_only() { + let graph = parse("myapp v1.0.0\n").unwrap(); + assert_eq!(graph.nodes.len(), 1); + let node = &graph.nodes["myapp v1.0.0"]; + assert_eq!(node.label.as_str(), "myapp"); + assert_eq!( + node.attrs.get("version").map(|s| s.as_str()), + Some("v1.0.0") + ); + assert!(graph.edges.is_empty()); + } + + #[test] + fn simple_tree() { + let input = "\ +myapp v1.0.0 +├── libfoo v0.2.1 +└── libbar v0.1.0 +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert!(graph.nodes.contains_key("myapp v1.0.0")); + assert!(graph.nodes.contains_key("libfoo v0.2.1")); + assert!(graph.nodes.contains_key("libbar v0.1.0")); + assert_eq!(graph.edges.len(), 2); + assert_eq!(graph.edges[0].from, "myapp v1.0.0"); + assert_eq!(graph.edges[0].to, "libfoo v0.2.1"); + assert_eq!(graph.edges[1].from, "myapp v1.0.0"); + assert_eq!(graph.edges[1].to, "libbar v0.1.0"); + } + + #[test] + fn nested_tree() { + let input = "\ +myapp v1.0.0 +├── libfoo v0.2.1 +│ └── libbar v0.1.0 +└── libbaz v0.3.0 +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 4); + assert_eq!(graph.edges.len(), 3); + assert_eq!(graph.edges[0].from, "myapp v1.0.0"); + assert_eq!(graph.edges[0].to, "libfoo v0.2.1"); + assert_eq!(graph.edges[1].from, "libfoo v0.2.1"); + assert_eq!(graph.edges[1].to, "libbar v0.1.0"); + assert_eq!(graph.edges[2].from, "myapp v1.0.0"); + assert_eq!(graph.edges[2].to, "libbaz v0.3.0"); + } + + #[test] + fn duplicate_star_marker() { + let input = "\ +myapp v1.0.0 +├── libfoo v0.2.1 +│ └── shared v1.0.0 +└── libbar v0.1.0 + └── shared v1.0.0 (*) +"; + let graph = parse(input).unwrap(); + // "shared v1.0.0" should appear only once as a node + assert_eq!(graph.nodes.len(), 4); + assert!(graph.nodes.contains_key("shared v1.0.0")); + // But there should be two edges pointing to it + let shared_edges: Vec<_> = graph + .edges + .iter() + .filter(|e| e.to == "shared v1.0.0") + .collect(); + assert_eq!(shared_edges.len(), 2); + assert_eq!(shared_edges[0].from, "libfoo v0.2.1"); + assert_eq!(shared_edges[1].from, "libbar v0.1.0"); + } + + #[test] + fn repeated_leaf_no_star() { + // Leaf nodes can appear multiple times without (*) since they have no subtree + let input = "\ +myapp v1.0.0 +├── libfoo v0.2.1 +│ └── leaf v1.0.0 +└── libbar v0.1.0 + └── leaf v1.0.0 +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 4); + let leaf_edges: Vec<_> = graph + .edges + .iter() + .filter(|e| e.to == "leaf v1.0.0") + .collect(); + assert_eq!(leaf_edges.len(), 2); + } + + #[test] + fn proc_macro_kind() { + let input = "\ +myapp v1.0.0 +└── derive-thing v0.5.0 (proc-macro) +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 2); + let node = &graph.nodes["derive-thing v0.5.0"]; + assert_eq!(node.node_type.as_deref(), Some("proc-macro")); + assert!(!node.attrs.contains_key("kind")); + assert_eq!(node.label.as_str(), "derive-thing"); + assert_eq!( + node.attrs.get("version").map(|s| s.as_str()), + Some("v0.5.0") + ); + } + + #[test] + fn local_path_attr() { + let input = "\ +myapp v1.0.0 (my/workspace/path) +└── mylib v0.1.0 (my/workspace/lib) +"; + let graph = parse(input).unwrap(); + assert_eq!( + graph.nodes["myapp v1.0.0"] + .attrs + .get("path") + .map(|s| s.as_str()), + Some("my/workspace/path") + ); + assert_eq!( + graph.nodes["mylib v0.1.0"] + .attrs + .get("path") + .map(|s| s.as_str()), + Some("my/workspace/lib") + ); + } + + #[test] + fn proc_macro_with_path() { + let input = "\ +myapp v1.0.0 +└── mymacro v0.1.0 (my/path) (proc-macro) +"; + let graph = parse(input).unwrap(); + let node = &graph.nodes["mymacro v0.1.0"]; + assert_eq!(node.node_type.as_deref(), Some("proc-macro")); + assert!(!node.attrs.contains_key("kind")); + assert_eq!(node.attrs.get("path").map(|s| s.as_str()), Some("my/path")); + } + + #[test] + fn dev_dependencies_section() { + let input = "\ +myapp v1.0.0 +└── libfoo v0.2.1 +[dev-dependencies] +└── testlib v1.0.0 +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.edges.len(), 2); + assert_eq!(graph.edges[0].from, "myapp v1.0.0"); + assert_eq!(graph.edges[0].to, "libfoo v0.2.1"); + // Normal dep has no kind attr + assert!(!graph.edges[0].attrs.contains_key("kind")); + // Dev dep edge from root has kind=dev + assert_eq!(graph.edges[1].from, "myapp v1.0.0"); + assert_eq!(graph.edges[1].to, "testlib v1.0.0"); + assert_eq!(graph.edges[1].attrs.get("kind").unwrap(), "dev"); + } + + #[test] + fn build_dependencies_section() { + let input = "\ +myapp v1.0.0 +└── libfoo v0.2.1 +[build-dependencies] +└── buildlib v1.0.0 +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.edges.len(), 2); + assert_eq!(graph.edges[1].from, "myapp v1.0.0"); + assert_eq!(graph.edges[1].to, "buildlib v1.0.0"); + assert_eq!(graph.edges[1].attrs.get("kind").unwrap(), "build"); + } + + #[test] + fn section_kind_only_depth_1() { + // Children of dev-deps should NOT inherit the dev kind + let input = "\ +myapp v1.0.0 +[dev-dependencies] +└── testlib v1.0.0 + └── helper v0.1.0 +"; + let graph = parse(input).unwrap(); + // root -> testlib is dev + assert_eq!(graph.edges[0].attrs.get("kind").unwrap(), "dev"); + // testlib -> helper has no kind (it's a normal dep of testlib) + assert!(!graph.edges[1].attrs.contains_key("kind")); + } + + #[test] + fn section_kind_resets_at_new_root() { + // Workspace output: second root should not inherit first root's section_kind + let input = "\ +crateA v1.0.0 +├── libfoo v0.2.1 +[dev-dependencies] +└── testlib v1.0.0 + +crateB v2.0.0 +├── libbar v0.1.0 +└── libbaz v0.3.0 +"; + let graph = parse(input).unwrap(); + // crateA -> libfoo: normal (no kind) + assert!(!graph.edges[0].attrs.contains_key("kind")); + // crateA -> testlib: dev + assert_eq!(graph.edges[1].attrs.get("kind").unwrap(), "dev"); + // crateB -> libbar: normal (no kind), NOT dev + let bar_edge = graph + .edges + .iter() + .find(|e| e.from == "crateB v2.0.0" && e.to == "libbar v0.1.0") + .expect("edge should exist"); + assert!(!bar_edge.attrs.contains_key("kind")); + // crateB -> libbaz: normal (no kind) + let baz_edge = graph + .edges + .iter() + .find(|e| e.from == "crateB v2.0.0" && e.to == "libbaz v0.3.0") + .expect("edge should exist"); + assert!(!baz_edge.attrs.contains_key("kind")); + } + + #[test] + fn build_annotation_edge_kind() { + let input = "\ +myapp v1.0.0 +├── libfoo v0.2.1 +└── cc v1.0.0 (build) +"; + let graph = parse(input).unwrap(); + // libfoo edge has no kind + assert!(!graph.edges[0].attrs.contains_key("kind")); + // cc edge has kind=build from (build) annotation + assert_eq!(graph.edges[1].attrs.get("kind").unwrap(), "build"); + } + + #[test] + fn dev_annotation_edge_kind() { + let input = "\ +myapp v1.0.0 +└── testutil v0.1.0 (dev) +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.edges[0].attrs.get("kind").unwrap(), "dev"); + } + + #[test] + fn feature_entries() { + let input = "\ +myapp v1.0.0 +├── clap feature \"default\" +│ └── clap v4.5.57 +└── clap feature \"derive\" + └── clap v4.5.57 (*) +"; + let graph = parse(input).unwrap(); + assert!(graph.nodes.contains_key("clap feature \"default\"")); + assert!(graph.nodes.contains_key("clap feature \"derive\"")); + assert!(graph.nodes.contains_key("clap v4.5.57")); + // clap v4.5.57 appears only once as a node despite two references + assert_eq!( + graph + .nodes + .keys() + .filter(|k| k.starts_with("clap v")) + .count(), + 1 + ); + // Two edges to clap v4.5.57 + let clap_edges: Vec<_> = graph + .edges + .iter() + .filter(|e| e.to == "clap v4.5.57") + .collect(); + assert_eq!(clap_edges.len(), 2); + } + + // -- parse_line unit tests -- + + #[test] + fn parse_line_root() { + assert_eq!(parse_line("myapp v1.0.0"), Some((0, "myapp v1.0.0"))); + } + + #[test] + fn parse_line_unicode_depth() { + assert_eq!(parse_line("├── child v1.0.0"), Some((1, "child v1.0.0"))); + assert_eq!( + parse_line("│ └── grandchild v1.0.0"), + Some((2, "grandchild v1.0.0")) + ); + } + + #[test] + fn parse_line_ascii_depth() { + assert_eq!(parse_line("|-- child v1.0.0"), Some((1, "child v1.0.0"))); + assert_eq!( + parse_line("| `-- grandchild v1.0.0"), + Some((2, "grandchild v1.0.0")) + ); + } + + #[test] + fn parse_line_blank_continuation() { + // When parent is last child, uses spaces instead of pipe + assert_eq!( + parse_line(" └── child v1.0.0"), + Some((2, "child v1.0.0")) + ); + } + + #[test] + fn parse_line_skips_blank() { + assert_eq!(parse_line(""), None); + assert_eq!(parse_line(" "), None); + } + + #[test] + fn parse_line_skips_section_headers() { + assert_eq!(parse_line("[dev-dependencies]"), None); + assert_eq!(parse_line("[build-dependencies]"), None); + } + + // -- parse_node_text unit tests -- + + #[test] + fn parse_node_text_simple() { + let (id, is_dup, node_type, dep_kind, attrs) = parse_node_text("clap v4.5.57"); + assert_eq!(id, "clap v4.5.57"); + assert!(!is_dup); + assert_eq!(node_type, None); + assert_eq!(dep_kind, None); + assert_eq!(attrs.get("version").map(|s| s.as_str()), Some("v4.5.57")); + } + + #[test] + fn parse_node_text_dup() { + let (id, is_dup, node_type, dep_kind, attrs) = parse_node_text("clap v4.5.57 (*)"); + assert_eq!(id, "clap v4.5.57"); + assert!(is_dup); + assert_eq!(node_type, None); + assert_eq!(dep_kind, None); + assert_eq!(attrs.get("version").map(|s| s.as_str()), Some("v4.5.57")); + } + + #[test] + fn parse_node_text_proc_macro() { + let (id, is_dup, node_type, dep_kind, attrs) = + parse_node_text("clap_derive v4.5.55 (proc-macro)"); + assert_eq!(id, "clap_derive v4.5.55"); + assert!(!is_dup); + assert_eq!(node_type.as_deref(), Some("proc-macro")); + assert_eq!(dep_kind, None); + assert_eq!(attrs.get("version").map(|s| s.as_str()), Some("v4.5.55")); + } + + #[test] + fn parse_node_text_proc_macro_dup() { + let (id, is_dup, node_type, dep_kind, attrs) = + parse_node_text("clap_derive v4.5.55 (proc-macro) (*)"); + assert_eq!(id, "clap_derive v4.5.55"); + assert!(is_dup); + assert_eq!(node_type.as_deref(), Some("proc-macro")); + assert_eq!(dep_kind, None); + assert_eq!(attrs.get("version").map(|s| s.as_str()), Some("v4.5.55")); + } + + #[test] + fn parse_node_text_path() { + let (id, is_dup, node_type, dep_kind, attrs) = + parse_node_text("myapp v1.0.0 (my/workspace/path)"); + assert_eq!(id, "myapp v1.0.0"); + assert!(!is_dup); + assert_eq!(node_type, None); + assert_eq!(dep_kind, None); + assert_eq!( + attrs.get("path").map(|s| s.as_str()), + Some("my/workspace/path") + ); + assert_eq!(attrs.get("version").map(|s| s.as_str()), Some("v1.0.0")); + } + + #[test] + fn parse_node_text_path_and_proc_macro() { + let (id, is_dup, node_type, dep_kind, attrs) = + parse_node_text("mymacro v0.1.0 (my/path) (proc-macro)"); + assert_eq!(id, "mymacro v0.1.0"); + assert!(!is_dup); + assert_eq!(node_type.as_deref(), Some("proc-macro")); + assert_eq!(dep_kind, None); + assert_eq!(attrs.get("path").map(|s| s.as_str()), Some("my/path")); + assert_eq!(attrs.get("version").map(|s| s.as_str()), Some("v0.1.0")); + } + + #[test] + fn parse_node_text_build_kind() { + let (id, _, node_type, dep_kind, attrs) = parse_node_text("cc v1.0.0 (build)"); + assert_eq!(id, "cc v1.0.0"); + assert_eq!(node_type, None); + assert_eq!(dep_kind, Some("build")); + assert_eq!(attrs.get("version").map(|s| s.as_str()), Some("v1.0.0")); + } + + #[test] + fn parse_node_text_dev_kind() { + let (id, _, node_type, dep_kind, attrs) = parse_node_text("testlib v1.0.0 (dev)"); + assert_eq!(id, "testlib v1.0.0"); + assert_eq!(node_type, None); + assert_eq!(dep_kind, Some("dev")); + assert_eq!(attrs.get("version").map(|s| s.as_str()), Some("v1.0.0")); + } + + #[test] + fn parse_node_text_feature_entry() { + let (id, is_dup, node_type, dep_kind, attrs) = parse_node_text("clap feature \"default\""); + assert_eq!(id, "clap feature \"default\""); + assert!(!is_dup); + assert_eq!(node_type, None); + assert_eq!(dep_kind, None); + assert!(attrs.get("version").is_none()); + } + + // -- fixture tests -- + + #[test] + fn fixture_cargo_tree() { + let input = include_str!("../../../../data/depconv/cargo-tree.txt"); + let graph = parse(input).unwrap(); + + // Root node: ID has version, label is just the name + let root = &graph.nodes["deptangle-depgraph v0.5.0"]; + assert_eq!(root.label.as_str(), "deptangle-depgraph"); + assert_eq!( + root.attrs.get("version").map(|s| s.as_str()), + Some("v0.5.0") + ); + assert_eq!( + root.attrs.get("path").map(|s| s.as_str()), + Some("deptangle/crates/deptangle-depgraph") + ); + + // Check a few specific nodes + assert_eq!(graph.nodes["clap v4.5.57"].label.as_str(), "clap"); + assert_eq!( + graph.nodes["clap_derive v4.5.55"].node_type.as_deref(), + Some("proc-macro") + ); + + // proc-macro2 appears multiple times with (*) but should be one node + assert!(graph.nodes.contains_key("proc-macro2 v1.0.106")); + + // Multiple edges to proc-macro2 from different parents + let pm2_edges: Vec<_> = graph + .edges + .iter() + .filter(|e| e.to == "proc-macro2 v1.0.106") + .collect(); + assert!(pm2_edges.len() >= 2); + + // Root has no incoming edges + assert!( + !graph + .edges + .iter() + .any(|e| e.to == "deptangle-depgraph v0.5.0") + ); + + // Spot-check a direct dependency edge + assert!( + graph + .edges + .iter() + .any(|e| e.from == "deptangle-depgraph v0.5.0" && e.to == "clap v4.5.57") + ); + + // Dev dependencies should be children of the root with kind=dev + let dev_edge = graph + .edges + .iter() + .find(|e| e.from == "deptangle-depgraph v0.5.0" && e.to == "deptangle-test v0.5.0") + .expect("dev-dep edge should exist"); + assert_eq!(dev_edge.attrs.get("kind").unwrap(), "dev"); + + // Normal dependency edges should not have a kind attr + let normal_edge = graph + .edges + .iter() + .find(|e| e.from == "deptangle-depgraph v0.5.0" && e.to == "clap v4.5.57") + .expect("normal dep edge should exist"); + assert!(!normal_edge.attrs.contains_key("kind")); + } + + #[test] + fn fixture_cargo_tree_features() { + let input = include_str!("../../../../data/depconv/cargo-tree-features.txt"); + let graph = parse(input).unwrap(); + + // Root node + assert!(graph.nodes.contains_key("deptangle-depgraph v0.5.0")); + + // Feature nodes + assert!(graph.nodes.contains_key("clap feature \"default\"")); + assert!(graph.nodes.contains_key("clap feature \"derive\"")); + + // Regular nodes + assert!(graph.nodes.contains_key("clap v4.5.57")); + + // Root -> feature edge + assert!( + graph.edges.iter().any( + |e| e.from == "deptangle-depgraph v0.5.0" && e.to == "clap feature \"default\"" + ) + ); + } +} diff --git a/crates/deptangle-io/src/parse/depfile.rs b/crates/deptangle-io/src/parse/depfile.rs new file mode 100644 index 0000000..45978e0 --- /dev/null +++ b/crates/deptangle-io/src/parse/depfile.rs @@ -0,0 +1,229 @@ +use deptangle_graph::{DepGraph, Edge, NodeInfo}; +use indexmap::IndexMap; + +/// Parse a makefile-style `.d` depfile into a `DepGraph`. +/// +/// Each `target: dep1 dep2 ...` rule creates nodes for the target and +/// dependencies, plus edges from target -> each dependency. Supports `\` +/// line continuations and `#` comment lines. +pub fn parse(input: &str) -> eyre::Result { + let mut graph = DepGraph::default(); + + // Join continuation lines first: strip trailing `\` and merge with the next line. + let joined = join_continuations(input); + + for line in joined.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let Some(colon) = line.find(':') else { + continue; + }; + + let target = line[..colon].trim(); + if target.is_empty() { + continue; + } + + ensure_node(&mut graph, target); + + for dep in line[colon + 1..].split_whitespace() { + ensure_node(&mut graph, dep); + graph.edges.push(Edge { + from: target.to_string(), + to: dep.to_string(), + ..Default::default() + }); + } + } + + Ok(graph) +} + +fn ensure_node(graph: &mut DepGraph, id: &str) { + graph + .nodes + .entry(id.to_string()) + .or_insert_with(|| NodeInfo { + label: id.to_string(), + node_type: None, + attrs: IndexMap::new(), + }); +} + +/// Join backslash-continued lines into single logical lines. +fn join_continuations(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut continuation = false; + + for line in input.lines() { + if continuation { + // Append to the current logical line (with a space separator). + result.push(' '); + } + + if let Some(stripped) = line.strip_suffix('\\') { + result.push_str(stripped); + continuation = true; + } else { + result.push_str(line); + result.push('\n'); + continuation = false; + } + } + + // If the last line had a trailing backslash, close it off. + if continuation { + result.push('\n'); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input() { + let graph = parse("").unwrap(); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + } + + #[test] + fn single_rule() { + let graph = parse("main.o: main.c\n").unwrap(); + assert_eq!(graph.nodes.len(), 2); + assert!(graph.nodes.contains_key("main.o")); + assert!(graph.nodes.contains_key("main.c")); + assert_eq!(graph.edges.len(), 1); + assert_eq!(graph.edges[0].from, "main.o"); + assert_eq!(graph.edges[0].to, "main.c"); + } + + #[test] + fn multiple_deps() { + let graph = parse("main.o: main.c config.h utils.h\n").unwrap(); + assert_eq!(graph.nodes.len(), 4); + assert_eq!(graph.edges.len(), 3); + assert_eq!(graph.edges[0].to, "main.c"); + assert_eq!(graph.edges[1].to, "config.h"); + assert_eq!(graph.edges[2].to, "utils.h"); + } + + #[test] + fn line_continuations() { + let input = "main.o: main.c \\\n config.h \\\n utils.h\n"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 4); + assert_eq!(graph.edges.len(), 3); + assert_eq!(graph.edges[0].from, "main.o"); + assert_eq!(graph.edges[0].to, "main.c"); + assert_eq!(graph.edges[1].to, "config.h"); + assert_eq!(graph.edges[2].to, "utils.h"); + } + + #[test] + fn multiple_rules() { + let input = "a.o: a.c\nb.o: b.c\n"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 4); + assert_eq!(graph.edges.len(), 2); + } + + #[test] + fn shared_deps_deduplicated() { + let input = "a.o: common.h\nb.o: common.h\n"; + let graph = parse(input).unwrap(); + // common.h appears in both rules but should only be one node + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.edges.len(), 2); + } + + #[test] + fn comment_lines_ignored() { + let input = "# generated by gcc\nmain.o: main.c\n"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 2); + } + + #[test] + fn blank_lines_ignored() { + let input = "\nmain.o: main.c\n\nb.o: b.c\n\n"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 4); + } + + #[test] + fn target_no_deps() { + let input = "empty.o:\n"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 1); + assert!(graph.nodes.contains_key("empty.o")); + assert!(graph.edges.is_empty()); + } + + #[test] + fn no_labels_or_attrs() { + let graph = parse("a.o: a.c\n").unwrap(); + assert_eq!(graph.nodes["a.o"].label, "a.o"); + assert!(graph.nodes["a.o"].attrs.is_empty()); + assert_eq!(graph.edges[0].label, None); + assert!(graph.edges[0].attrs.is_empty()); + } + + #[test] + fn preserves_node_order() { + let input = "z.o: y.c x.h\na.o: b.c\n"; + let graph = parse(input).unwrap(); + let keys: Vec<&str> = graph.nodes.keys().map(|s| s.as_str()).collect(); + // Target first, then its deps in order, then next target and its deps + assert_eq!(keys, vec!["z.o", "y.c", "x.h", "a.o", "b.c"]); + } + + #[test] + fn parse_fixture_small() { + let input = include_str!("../../../../data/depconv/small.d"); + let graph = parse(input).unwrap(); + + // Nodes: main.o, main.c, config.h, utils.h, utils.c, config.o, config.c, utils.o + assert_eq!(graph.nodes.len(), 8); + assert!(graph.nodes.contains_key("main.o")); + assert!(graph.nodes.contains_key("main.c")); + assert!(graph.nodes.contains_key("config.h")); + assert!(graph.nodes.contains_key("utils.h")); + assert!(graph.nodes.contains_key("utils.c")); + assert!(graph.nodes.contains_key("config.o")); + assert!(graph.nodes.contains_key("config.c")); + assert!(graph.nodes.contains_key("utils.o")); + + // main.o depends on 4 files, config.o on 2, utils.o on 2 + assert_eq!(graph.edges.len(), 8); + + // Check main.o edges + let main_deps: Vec<&str> = graph + .edges + .iter() + .filter(|e| e.from == "main.o") + .map(|e| e.to.as_str()) + .collect(); + assert_eq!(main_deps, vec!["main.c", "config.h", "utils.h", "utils.c"]); + } + + #[test] + fn join_continuations_basic() { + let input = "a \\\nb \\\nc\n"; + let joined = join_continuations(input); + assert_eq!(joined, "a b c\n"); + } + + #[test] + fn join_continuations_no_backslash() { + let input = "a\nb\n"; + let joined = join_continuations(input); + assert_eq!(joined, "a\nb\n"); + } +} diff --git a/crates/deptangle-io/src/parse/dot.rs b/crates/deptangle-io/src/parse/dot.rs new file mode 100644 index 0000000..fe72973 --- /dev/null +++ b/crates/deptangle-io/src/parse/dot.rs @@ -0,0 +1,1170 @@ +use deptangle_graph::{DepGraph, Edge, NodeInfo}; +use graphviz_rust::dot_structures::{ + Attribute, EdgeTy, Graph, GraphAttributes, Id, Node as AstNode, NodeId, Stmt, + Subgraph as AstSubgraph, Vertex, +}; + +/// Decode a string value from the `graphviz-rust` AST. +/// +/// `Id::Escaped` stores quoted identifiers verbatim: the surrounding `"..."` is +/// included and escape sequences like `\"` are preserved as-is. This applies +/// uniformly to node IDs, edge endpoints, graph names, and attribute values. +/// +/// This function: +/// 1. Strips surrounding `"..."` if present. +/// 2. Unescapes `\"` -> `"`. +/// +/// We intentionally do NOT decode `\\` -> `\`. DOT uses `\n`, `\l`, `\r` as label +/// formatting directives, and decoding `\\` would make `\\n` (literal backslash + n) +/// indistinguishable from `\n` (centered newline), corrupting DOT->DOT round-trips. +pub(crate) fn unquote(s: &str) -> String { + let inner = if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') { + &s[1..s.len() - 1] + } else { + s + }; + inner.replace("\\\"", "\"") +} + +/// Extract the raw text of an `Id`, including any surrounding quotes. +fn id_to_string(id: &Id) -> String { + match id { + Id::Html(s) | Id::Escaped(s) | Id::Plain(s) | Id::Anonymous(s) => s.clone(), + } +} + +/// Convert a graph or subgraph `Id` to an optional identifier. +/// +/// graphviz-rust synthesizes a random `Id::Anonymous` token for unnamed graphs +/// and subgraphs; those map to `None`. +fn graph_id(id: &Id) -> Option { + match id { + Id::Anonymous(_) => None, + _ => Some(unquote(&id_to_string(id))), + } +} + +pub fn parse(input: &str) -> eyre::Result { + let ast_graph = graphviz_rust::parse(input).map_err(|e| eyre::eyre!("DOT parse error: {e}"))?; + let (id, stmts) = match &ast_graph { + Graph::Graph { id, stmts, .. } | Graph::DiGraph { id, stmts, .. } => (id, stmts), + }; + + let mut dep = DepGraph { + id: graph_id(id), + ..Default::default() + }; + + walk_stmts(stmts, &mut dep); + dep.nodes.sort_keys(); + + Ok(dep) +} + +/// Walk a list of AST statements, populating nodes, edges, attrs, and subgraphs +/// on the given DepGraph. +fn walk_stmts(stmts: &[Stmt], dep: &mut DepGraph) { + let mut subgraphs = Vec::new(); + + for stmt in stmts { + match stmt { + Stmt::Node(node) => { + add_node(node, dep); + } + Stmt::Edge(edge_stmt) => { + add_edges(edge_stmt, dep); + } + Stmt::GAttribute(GraphAttributes::Graph(attr_list)) => { + extract_graph_attrs(attr_list, &mut dep.attrs); + } + // `node [fontsize="12"]` and `edge [style=invis]` are default attribute statements + // attached to the graph. They're rendering aides, not semantic informat. We skip them + // intentionally. + Stmt::GAttribute(GraphAttributes::Node(_) | GraphAttributes::Edge(_)) => {} + Stmt::Attribute(Attribute(k, v)) => { + dep.attrs + .insert(unquote(&id_to_string(k)), unquote(&id_to_string(v))); + } + Stmt::Subgraph(sub) => { + subgraphs.push(collect_subgraph(sub)); + } + } + } + + dep.subgraphs = subgraphs; + remove_implicit_duplicates(dep); +} + +/// Remove nodes from this level that were implicitly created by edge processing +/// but are explicitly declared (with label or attrs) in a descendant subgraph. +/// +/// This runs bottom-up: inner subgraphs are already cleaned by their own +/// `walk_stmts` call before the parent runs this. +fn remove_implicit_duplicates(dep: &mut DepGraph) { + if dep.subgraphs.is_empty() { + return; + } + let subgraph_nodes: indexmap::IndexMap<&str, &NodeInfo> = dep + .subgraphs + .iter() + .flat_map(|sg| sg.all_nodes().iter().map(|(k, v)| (k.as_str(), v))) + .collect(); + dep.nodes.retain(|id, info| { + let is_implicit = info.label == *id && info.attrs.is_empty(); + !(is_implicit && subgraph_nodes.contains_key(id.as_str())) + }); +} + +/// Build a DepGraph from an AST subgraph. +fn collect_subgraph(sub: &AstSubgraph) -> DepGraph { + let mut dep = DepGraph { + id: graph_id(&sub.id), + ..Default::default() + }; + walk_stmts(&sub.stmts, &mut dep); + dep.nodes.sort_keys(); + dep +} + +/// Map well-known style values to semantic node types. +/// +/// Different tools use style attributes to convey semantic information: +/// - cargo-depgraph uses dashed style for optional/feature-gated dependencies +/// +/// This mapping is best-effort: it captures known conventions but isn't +/// exhaustive. Unrecognized styles are left in attrs and node_type stays None. +fn style_to_node_type(style: &str) -> Option<&'static str> { + match style { + // cargo-depgraph output + "dashed" => Some("optional"), + // Default or unknown styles + _ => None, + } +} + +/// Map well-known shape values to semantic node types. +/// +/// Different tools use different shape conventions: +/// - CMake uses a rich shape vocabulary (egg, octagon, doubleoctagon, etc.) +/// - Ninja uses ellipse for build rules +/// - cargo-depgraph uses box for workspace members (not mapped due to ambiguity) +/// - Many tools don't use shapes semantically at all +/// +/// This mapping is best-effort: it captures known conventions but isn't +/// exhaustive. Unrecognized shapes are left in attrs and node_type stays None. +/// +/// Note: `box` is NOT mapped because it's ambiguous (CMake custom-target vs +/// cargo-depgraph workspace member vs Ninja default). The shape is preserved +/// in attrs for tools that need it. +fn shape_to_node_type(shape: &str) -> Option<&'static str> { + match shape { + // CMake graphviz output + "egg" => Some("executable"), + "octagon" => Some("static-library"), + "doubleoctagon" => Some("shared-library"), + "tripleoctagon" => Some("module-library"), + "pentagon" => Some("interface-library"), + "hexagon" => Some("object-library"), + "septagon" => Some("unknown-library"), + // NOT mapping "box": too ambiguous across tools (CMake custom-target, + // cargo-depgraph workspace, Ninja file target) + // Ninja output + "ellipse" => Some("build-rule"), + // Default or unknown shapes + _ => None, + } +} + +/// Add a node from a node statement into the DepGraph, returning the unquoted node ID. +fn add_node(node: &AstNode, dep: &mut DepGraph) -> String { + let NodeId(node_id, _port) = &node.id; + let id = unquote(&id_to_string(node_id)); + let mut info = NodeInfo::new(id.clone()); + let mut explicit_type = None; + let mut shape_value = None; + let mut style_value = None; + + for Attribute(k, v) in &node.attributes { + let key = unquote(&id_to_string(k)); + let value = unquote(&id_to_string(v)); + match key.as_str() { + "label" => { + info.label = value; + } + "type" => { + explicit_type = Some(super::normalize_node_type(&value)); + } + "shape" => { + shape_value = Some(value.clone()); + info.attrs.insert(key, value); + } + "style" => { + style_value = Some(value.clone()); + info.attrs.insert(key, value); + } + _ => { + info.attrs.insert(key, value); + } + } + } + + // Priority: explicit type > style > shape (style is more specific than shape) + info.node_type = explicit_type + .or_else(|| { + style_value + .as_deref() + .and_then(style_to_node_type) + .map(String::from) + }) + .or_else(|| { + shape_value + .as_deref() + .and_then(shape_to_node_type) + .map(String::from) + }); + + dep.nodes.insert(id.clone(), info); + id +} + +/// Flatten an edge statement into individual edges and add them to the DepGraph. +/// Handles chained edges (a -> b -> c) and subgraph endpoints (a -> subgraph { b c }). +fn add_edges(edge_stmt: &graphviz_rust::dot_structures::Edge, dep: &mut DepGraph) { + // Extract edge attributes (shared across all flattened edges). + let mut edge_label = None; + let mut edge_attrs = indexmap::IndexMap::new(); + for Attribute(k, v) in &edge_stmt.attributes { + let key = unquote(&id_to_string(k)); + let value = unquote(&id_to_string(v)); + if key == "label" { + edge_label = Some(value); + } else { + edge_attrs.insert(key, value); + } + } + + // Collect all endpoints in the chain: from -> to1 -> to2 -> ... + let endpoints: Vec<&Vertex> = match &edge_stmt.ty { + EdgeTy::Pair(from, to) => vec![from, to], + EdgeTy::Chain(vertices) => vertices.iter().collect(), + }; + + // For each consecutive pair, create edges between all node IDs. + for pair in endpoints.windows(2) { + let from_ids = endpoint_node_ids(pair[0], dep); + let to_ids = endpoint_node_ids(pair[1], dep); + for from_id in &from_ids { + for to_id in &to_ids { + // Ensure implicit nodes exist. + dep.nodes + .entry(from_id.clone()) + .or_insert_with(|| NodeInfo::new(from_id.clone())); + dep.nodes + .entry(to_id.clone()) + .or_insert_with(|| NodeInfo::new(to_id.clone())); + dep.edges.push(Edge { + from: from_id.clone(), + to: to_id.clone(), + label: edge_label.clone(), + attrs: edge_attrs.clone(), + }); + } + } + } +} + +/// Extract node IDs from an edge endpoint, which may be a single node or an +/// anonymous subgraph containing multiple nodes. +fn endpoint_node_ids(endpoint: &Vertex, dep: &mut DepGraph) -> Vec { + match endpoint { + Vertex::N(NodeId(id, _port)) => vec![unquote(&id_to_string(id))], + Vertex::S(sub) => { + // Anonymous subgraph as edge endpoint: collect all node IDs. + let mut ids = Vec::new(); + collect_endpoint_ids(&sub.stmts, &mut ids, dep); + ids + } + } +} + +/// Recursively collect node IDs from statements inside an anonymous subgraph +/// used as an edge endpoint. +fn collect_endpoint_ids(stmts: &[Stmt], ids: &mut Vec, dep: &mut DepGraph) { + for stmt in stmts { + match stmt { + Stmt::Node(node) => { + ids.push(add_node(node, dep)); + } + Stmt::Edge(edge_stmt) => { + // Edges inside anonymous subgraph endpoints still define nodes. + add_edges(edge_stmt, dep); + // Collect the from-endpoint node IDs. + let from = match &edge_stmt.ty { + EdgeTy::Pair(from, _) => Some(from), + EdgeTy::Chain(vertices) => vertices.first(), + }; + if let Some(from) = from { + let mut inner_ids = endpoint_node_ids(from, dep); + ids.append(&mut inner_ids); + } + } + Stmt::Subgraph(sub) => { + collect_endpoint_ids(&sub.stmts, ids, dep); + } + _ => {} + } + } +} + +/// Extract key-value pairs from an attribute list into the attrs map. +fn extract_graph_attrs(attr_list: &[Attribute], attrs: &mut indexmap::IndexMap) { + for Attribute(k, v) in attr_list { + attrs.insert(unquote(&id_to_string(k)), unquote(&id_to_string(v))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn graphviz_rust_behavior() { + // Test the upstream parser behaviors that unquote() depends on: + // + // Quoted identifiers (Id::Escaped) keep their surrounding quotes AND their escape sequences + // verbatim, for attribute values and node IDs alike. + let graph = parse( + r#"digraph { a [label="say \"hi\"", tooltip="path\\here"]; "quoted node" -> a; }"#, + ) + .unwrap(); + + assert_eq!(graph.nodes["a"].label.as_str(), r#"say "hi""#); + assert_eq!(graph.nodes["a"].attrs["tooltip"], r"path\\here"); + assert!(graph.nodes.contains_key("quoted node")); + assert!(!graph.nodes.contains_key("\"quoted node\"")); + assert_eq!(graph.edges[0].from, "quoted node"); + } + + #[test] + fn empty_attr_lists() { + // cargo-depgraph generates empty attribute lists "[ ]", which are valid DOT. dot-parser + // rejected them and needed a preprocessing hack; graphviz-rust parses them directly. + let graph = parse("digraph { a [ ]; b []; a -> b [ ]; }").unwrap(); + assert_eq!(graph.nodes.len(), 2); + assert_eq!(graph.edges.len(), 1); + assert!(graph.nodes["a"].attrs.is_empty()); + } + + #[test] + fn subgraph_edge_endpoint() { + // graphviz-rust only accepts subgraph edge endpoints in the `a -> subgraph { ... }` form + // (bare `{ b c } -> a` fails to parse). + let graph = parse("digraph { a -> subgraph { b c }; }").unwrap(); + assert_eq!(graph.nodes.len(), 3); + let pairs: Vec<(&str, &str)> = graph + .edges + .iter() + .map(|e| (e.from.as_str(), e.to.as_str())) + .collect(); + assert_eq!(pairs, vec![("a", "b"), ("a", "c")]); + } + + #[test] + fn unquote_strips_outer_quotes() { + assert_eq!(unquote(r#""hello""#), "hello"); + } + + #[test] + fn unquote_bare_id_unchanged() { + assert_eq!(unquote("hello"), "hello"); + } + + #[test] + fn unquote_escaped_quote_with_outer_quotes() { + // Node ID case: outer quotes present + escape sequences. + assert_eq!(unquote(r#""say \"hi\"""#), r#"say "hi""#); + } + + #[test] + fn unquote_escaped_quote_without_outer_quotes() { + // Attribute value case: dot-parser already stripped outer quotes, + // but escape sequences remain. Must still unescape. + assert_eq!(unquote(r#"say \"hi\""#), r#"say "hi""#); + } + + #[test] + fn unquote_backslash_preserved() { + // Backslashes are preserved verbatim (DOT formatting directives). + assert_eq!(unquote(r#""a\\b""#), r"a\\b"); + assert_eq!(unquote(r"a\\b"), r"a\\b"); + } + + #[test] + fn unquote_formatting_directives_preserved() { + // DOT \n, \l, \r are label formatting directives and must survive. + assert_eq!(unquote(r#""line1\nline2""#), r"line1\nline2"); + assert_eq!(unquote(r"line1\nline2"), r"line1\nline2"); + assert_eq!(unquote(r"line1\lline2"), r"line1\lline2"); + assert_eq!(unquote(r"line1\rline2"), r"line1\rline2"); + } + + #[test] + fn unquote_escaped_backslash_before_quote() { + // DOT \\\" = escaped backslash + escaped quote. + // We preserve \\ but decode \" -> ", so \\\" -> \\" + assert_eq!(unquote(r#""a\\\"b""#), r#"a\\"b"#); + assert_eq!(unquote(r#"a\\\"b"#), r#"a\\"b"#); + } + + #[test] + fn empty_digraph() { + let graph = parse("digraph {}").unwrap(); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + } + + #[test] + fn named_digraph() { + let graph = parse("digraph deps {}").unwrap(); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + } + + #[test] + fn simple_edge() { + let graph = parse("digraph { a -> b; }").unwrap(); + assert_eq!(graph.nodes.len(), 2); + assert!(graph.nodes.contains_key("a")); + assert!(graph.nodes.contains_key("b")); + assert_eq!(graph.edges.len(), 1); + assert_eq!(graph.edges[0].from, "a"); + assert_eq!(graph.edges[0].to, "b"); + assert_eq!(graph.edges[0].label, None); + } + + #[test] + fn node_labels() { + let graph = parse(r#"digraph { a [label="Alpha"]; b [label="Bravo"]; a -> b; }"#).unwrap(); + assert_eq!(graph.nodes["a"].label.as_str(), "Alpha"); + assert_eq!(graph.nodes["b"].label.as_str(), "Bravo"); + } + + #[test] + fn edge_labels() { + let graph = + parse(r#"digraph { a -> b [label="depends"]; a -> c [label="uses"]; }"#).unwrap(); + assert_eq!(graph.edges.len(), 2); + assert_eq!(graph.edges[0].label.as_deref(), Some("depends")); + assert_eq!(graph.edges[1].label.as_deref(), Some("uses")); + } + + #[test] + fn type_attr_in_attrs() { + let graph = parse(r#"digraph { a [type="lib"]; }"#).unwrap(); + assert_eq!(graph.nodes["a"].node_type.as_deref(), Some("lib")); + assert!(!graph.nodes["a"].attrs.contains_key("type")); + } + + #[test] + fn shape_attr_in_attrs() { + let graph = parse(r#"digraph { a [shape=box]; }"#).unwrap(); + // shape is preserved in attrs + assert_eq!( + graph.nodes["a"].attrs.get("shape").map(|s| s.as_str()), + Some("box") + ); + // box is NOT mapped to node_type (too ambiguous) + assert_eq!(graph.nodes["a"].node_type, None); + } + + #[test] + fn type_and_shape_coexist() { + let graph = parse(r#"digraph { a [shape=box, type="lib"]; }"#).unwrap(); + // Explicit type takes precedence over shape-inferred type + assert_eq!(graph.nodes["a"].node_type.as_deref(), Some("lib")); + assert!(!graph.nodes["a"].attrs.contains_key("type")); + // shape is still preserved in attrs + assert_eq!( + graph.nodes["a"].attrs.get("shape").map(|s| s.as_str()), + Some("box") + ); + } + + #[test] + fn extra_attrs_preserved() { + let graph = parse(r#"digraph { a [label="A", color="red", style="bold"]; }"#).unwrap(); + assert_eq!(graph.nodes["a"].label.as_str(), "A"); + assert_eq!( + graph.nodes["a"].attrs.get("color").map(|s| s.as_str()), + Some("red") + ); + assert_eq!( + graph.nodes["a"].attrs.get("style").map(|s| s.as_str()), + Some("bold") + ); + } + + #[test] + fn implicit_nodes_from_edges() { + // Nodes only defined implicitly by edges should still appear in the graph. + let graph = parse("digraph { a -> b -> c; }").unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert!(graph.nodes.contains_key("a")); + assert!(graph.nodes.contains_key("b")); + assert!(graph.nodes.contains_key("c")); + } + + #[test] + fn graph_attrs_captured() { + let graph = parse(r#"digraph { rankdir=LR; a -> b; }"#).unwrap(); + assert_eq!(graph.edges.len(), 1); + assert_eq!(graph.attrs.get("rankdir").map(|s| s.as_str()), Some("LR")); + } + + #[test] + fn graph_name_captured() { + let graph = parse("digraph deps { a -> b; }").unwrap(); + assert_eq!(graph.id.as_deref(), Some("deps")); + } + + #[test] + fn quoted_ids() { + let graph = + parse(r#"digraph { "my node" [label="My Node"]; "my node" -> "other"; }"#).unwrap(); + assert!(graph.nodes.contains_key("my node")); + assert_eq!(graph.nodes["my node"].label.as_str(), "My Node"); + } + + #[test] + fn edge_attrs_captured() { + let graph = parse(r#"digraph { a -> b [style="dashed", color="red"]; }"#).unwrap(); + assert_eq!(graph.edges.len(), 1); + assert_eq!( + graph.edges[0].attrs.get("style").map(|s| s.as_str()), + Some("dashed") + ); + assert_eq!( + graph.edges[0].attrs.get("color").map(|s| s.as_str()), + Some("red") + ); + } + + #[test] + fn edge_label_and_attrs() { + let graph = parse(r#"digraph { a -> b [label="uses", style="bold"]; }"#).unwrap(); + assert_eq!(graph.edges[0].label.as_deref(), Some("uses")); + assert_eq!( + graph.edges[0].attrs.get("style").map(|s| s.as_str()), + Some("bold") + ); + } + + #[test] + fn escaped_quotes_in_label() { + // This exercises the bug path: dot-parser strips outer quotes from + // attribute values but preserves \" escape sequences. Our unquote + // must decode them so they don't get double-escaped by quote(). + let graph = parse(r#"digraph { a [label="say \"hi\""]; }"#).unwrap(); + assert_eq!(graph.nodes["a"].label.as_str(), r#"say "hi""#); + } + + #[test] + fn escaped_quotes_in_label_roundtrip() { + // Full parse->emit round-trip with escaped quotes. + let input = r#"digraph { a [label="say \"hi\""]; }"#; + let graph = parse(input).unwrap(); + let mut buf = Vec::new(); + crate::emit::dot::emit(&graph, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert_eq!(output, "digraph {\n a [label=\"say \\\"hi\\\"\"];\n}\n"); + // And parse the output again to verify it's valid. + let graph2 = parse(&output).unwrap(); + assert_eq!(graph2.nodes["a"].label.as_str(), r#"say "hi""#); + } + + #[test] + fn fixture_small_dot() { + let input = include_str!("../../../../data/depconv/small.dot"); + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert!(graph.nodes.contains_key("myapp")); + assert!(graph.nodes.contains_key("libfoo")); + assert!(graph.nodes.contains_key("libbar")); + assert_eq!(graph.nodes["myapp"].label.as_str(), "My Application"); + // shape=box stored in attrs + assert_eq!( + graph.nodes["myapp"].attrs.get("shape").map(|s| s.as_str()), + Some("box") + ); + assert_eq!(graph.edges.len(), 3); + // Graph name and rankdir captured + assert_eq!(graph.id.as_deref(), Some("deps")); + assert_eq!(graph.attrs.get("rankdir").map(|s| s.as_str()), Some("LR")); + } + + #[test] + fn subgraph_basic() { + let graph = parse( + r#"digraph { + top; + subgraph cluster0 { + label = "Group A"; + a; + b; + } + }"#, + ) + .unwrap(); + // Top-level has only the standalone node. + assert_eq!(graph.nodes.len(), 1); + assert!(graph.nodes.contains_key("top")); + // One subgraph. + assert_eq!(graph.subgraphs.len(), 1); + assert_eq!(graph.subgraphs[0].id.as_deref(), Some("cluster0")); + assert_eq!( + graph.subgraphs[0].attrs.get("label").map(|s| s.as_str()), + Some("Group A") + ); + assert_eq!(graph.subgraphs[0].nodes.len(), 2); + assert!(graph.subgraphs[0].nodes.contains_key("a")); + assert!(graph.subgraphs[0].nodes.contains_key("b")); + } + + #[test] + fn subgraph_nested() { + let graph = parse( + r#"digraph { + subgraph outer { + x; + subgraph inner { + y; + } + } + }"#, + ) + .unwrap(); + assert_eq!(graph.subgraphs.len(), 1); + let outer = &graph.subgraphs[0]; + assert_eq!(outer.id.as_deref(), Some("outer")); + assert_eq!(outer.nodes.len(), 1); + assert!(outer.nodes.contains_key("x")); + assert_eq!(outer.subgraphs.len(), 1); + let inner = &outer.subgraphs[0]; + assert_eq!(inner.id.as_deref(), Some("inner")); + assert_eq!(inner.nodes.len(), 1); + assert!(inner.nodes.contains_key("y")); + } + + #[test] + fn subgraph_edges_stay_local() { + let graph = parse( + r#"digraph { + a -> b; + subgraph cluster0 { + c -> d; + } + }"#, + ) + .unwrap(); + // Parent-level edges only. + assert_eq!(graph.edges.len(), 1); + assert_eq!(graph.edges[0].from, "a"); + assert_eq!(graph.edges[0].to, "b"); + // Subgraph edges only. + assert_eq!(graph.subgraphs[0].edges.len(), 1); + assert_eq!(graph.subgraphs[0].edges[0].from, "c"); + assert_eq!(graph.subgraphs[0].edges[0].to, "d"); + } + + #[test] + fn cross_subgraph_edge_no_duplicate_node() { + // Edge at top level references node declared in subgraph. + // The implicit default at top level should be removed. + let graph = parse( + r#"digraph { + a -> b; + subgraph cluster0 { + b [label="B"]; + } + }"#, + ) + .unwrap(); + // `a` stays at top level (only defined here). + // `b` should NOT be at top level -- it's in the subgraph. + assert_eq!(graph.nodes.len(), 1); + assert!(graph.nodes.contains_key("a")); + assert!(!graph.nodes.contains_key("b")); + // `b` lives in the subgraph with its label. + assert_eq!(graph.subgraphs[0].nodes.len(), 1); + assert_eq!(graph.subgraphs[0].nodes["b"].label.as_str(), "B"); + // Flattened view still has both nodes. + let all = graph.all_nodes(); + assert_eq!(all.len(), 2); + assert!(all.contains_key("a")); + assert!(all.contains_key("b")); + } + + #[test] + fn cross_subgraph_edge_forward_reference() { + // Edge appears before the subgraph that declares the node. + let graph = parse( + r#"digraph { + subgraph cluster0 { + a [label="A"]; + } + a -> b; + }"#, + ) + .unwrap(); + assert_eq!(graph.nodes.len(), 1); + assert!(graph.nodes.contains_key("b")); + assert!(!graph.nodes.contains_key("a")); + assert_eq!(graph.subgraphs[0].nodes["a"].label.as_str(), "A"); + } + + #[test] + fn cross_subgraph_edge_nested_dedup() { + // Node declared in deeply nested subgraph, edges at multiple levels. + let graph = parse( + r#"digraph { + a -> b; + subgraph outer { + b -> c; + subgraph inner { + b [label="B"]; + c [label="C"]; + } + } + }"#, + ) + .unwrap(); + // Top level: only `a` (b was deduped). + assert_eq!(graph.nodes.len(), 1); + assert!(graph.nodes.contains_key("a")); + // Outer: b and c were deduped (implicit defaults, declared in inner). + assert_eq!(graph.subgraphs[0].nodes.len(), 0); + // Inner: b and c with labels. + let inner = &graph.subgraphs[0].subgraphs[0]; + assert_eq!(inner.nodes.len(), 2); + assert_eq!(inner.nodes["b"].label.as_str(), "B"); + assert_eq!(inner.nodes["c"].label.as_str(), "C"); + // Flattened view has all three. + let all = graph.all_nodes(); + assert_eq!(all.len(), 3); + } + + #[test] + fn explicit_top_level_node_not_deduped() { + // Node explicitly declared with attrs at top level AND in subgraph. + // Both should be kept (no data loss). + let graph = parse( + r#"digraph { + a [color="red"]; + subgraph cluster0 { + a [label="A"]; + } + }"#, + ) + .unwrap(); + assert_eq!(graph.nodes.len(), 1); + assert!(graph.nodes.contains_key("a")); + assert_eq!( + graph.nodes["a"].attrs.get("color").map(|s| s.as_str()), + Some("red") + ); + assert_eq!(graph.subgraphs[0].nodes.len(), 1); + assert_eq!(graph.subgraphs[0].nodes["a"].label.as_str(), "A"); + } + + #[test] + fn fixture_cmake_geos_subgraph() { + let input = include_str!("../../../../data/depconv/cmake.geos.dot"); + let graph = parse(input).unwrap(); + + assert_eq!(graph.id.as_deref(), Some("GEOS")); + + // One subgraph: clusterLegend. + assert_eq!(graph.subgraphs.len(), 1); + let legend = &graph.subgraphs[0]; + assert_eq!(legend.id.as_deref(), Some("clusterLegend")); + assert_eq!( + legend.attrs.get("label").map(|s| s.as_str()), + Some("Legend") + ); + assert_eq!(legend.attrs.get("color").map(|s| s.as_str()), Some("black")); + + // Legend subgraph: 8 nodes (legendNode0-7), 7 edges. + assert_eq!(legend.nodes.len(), 8); + assert!(legend.nodes.contains_key("legendNode0")); + assert!(legend.nodes.contains_key("legendNode7")); + assert_eq!(legend.edges.len(), 7); + + // Parent: 11 nodes (node0-node10), 13 edges. + assert_eq!(graph.nodes.len(), 11); + assert!(graph.nodes.contains_key("node0")); + assert!(graph.nodes.contains_key("node8")); + assert!(graph.nodes.contains_key("node10")); + // node8's label is "Threads::Threads". + assert_eq!(graph.nodes["node8"].label.as_str(), "Threads::Threads"); + assert_eq!(graph.edges.len(), 13); + + // No legend attributes leaked into parent. + assert_eq!(graph.attrs.get("label"), None); + assert_eq!(graph.attrs.get("color"), None); + } + + #[test] + fn all_nodes_flattens() { + let graph = parse( + r#"digraph { + a; + subgraph s1 { + b; + subgraph s2 { + c; + } + } + }"#, + ) + .unwrap(); + let all = graph.all_nodes(); + assert_eq!(all.len(), 3); + assert!(all.contains_key("a")); + assert!(all.contains_key("b")); + assert!(all.contains_key("c")); + } + + #[test] + fn all_edges_flattens() { + let graph = parse( + r#"digraph { + a -> b; + subgraph s1 { + c -> d; + } + }"#, + ) + .unwrap(); + let all = graph.all_edges(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].from, "a"); + assert_eq!(all[0].to, "b"); + assert_eq!(all[1].from, "c"); + assert_eq!(all[1].to, "d"); + } + + #[test] + fn adjacency_list_across_subgraphs() { + let graph = parse( + r#"digraph { + a -> b; + subgraph s1 { + b -> c; + c -> d; + } + }"#, + ) + .unwrap(); + let adj = graph.adjacency_list(); + assert_eq!(adj["a"], ["b"]); + assert_eq!(adj["b"], ["c"]); + assert_eq!(adj["c"], ["d"]); + } + + #[test] + fn cmake_shapes_to_node_type() { + let graph = parse( + r#"digraph { + a [shape=egg]; + b [shape=octagon]; + c [shape=doubleoctagon]; + d [shape=tripleoctagon]; + e [shape=pentagon]; + f [shape=hexagon]; + g [shape=septagon]; + h [shape=box]; + }"#, + ) + .unwrap(); + assert_eq!(graph.nodes["a"].node_type.as_deref(), Some("executable")); + assert_eq!( + graph.nodes["b"].node_type.as_deref(), + Some("static-library") + ); + assert_eq!( + graph.nodes["c"].node_type.as_deref(), + Some("shared-library") + ); + assert_eq!( + graph.nodes["d"].node_type.as_deref(), + Some("module-library") + ); + assert_eq!( + graph.nodes["e"].node_type.as_deref(), + Some("interface-library") + ); + assert_eq!( + graph.nodes["f"].node_type.as_deref(), + Some("object-library") + ); + assert_eq!( + graph.nodes["g"].node_type.as_deref(), + Some("unknown-library") + ); + // box is not mapped (ambiguous across tools) + assert_eq!(graph.nodes["h"].node_type, None); + } + + #[test] + fn ninja_shapes_to_node_type() { + let graph = parse( + r#"digraph { + a [label="phony", shape=ellipse]; + b [label="file.o", shape=box]; + }"#, + ) + .unwrap(); + assert_eq!(graph.nodes["a"].node_type.as_deref(), Some("build-rule")); + // box is not mapped (ambiguous) + assert_eq!(graph.nodes["b"].node_type, None); + } + + #[test] + fn unknown_shape_no_node_type() { + let graph = parse(r#"digraph { a [shape=triangle]; }"#).unwrap(); + assert_eq!(graph.nodes["a"].node_type, None); + assert_eq!( + graph.nodes["a"].attrs.get("shape").map(|s| s.as_str()), + Some("triangle") + ); + } + + #[test] + fn explicit_type_overrides_shape() { + let graph = parse(r#"digraph { a [shape=egg, type="special"]; }"#).unwrap(); + // Explicit type wins + assert_eq!(graph.nodes["a"].node_type.as_deref(), Some("special")); + // Shape still preserved + assert_eq!( + graph.nodes["a"].attrs.get("shape").map(|s| s.as_str()), + Some("egg") + ); + } + + #[test] + fn fixture_cmake_geos_node_types() { + let input = include_str!("../../../../data/depconv/cmake.geos.dot"); + let graph = parse(input).unwrap(); + + // Executables (egg shape) + assert_eq!( + graph.nodes["node5"].node_type.as_deref(), + Some("executable") + ); + assert_eq!( + graph.nodes["node6"].node_type.as_deref(), + Some("executable") + ); + + // Shared libraries (doubleoctagon) + assert_eq!( + graph.nodes["node0"].node_type.as_deref(), + Some("shared-library") + ); + assert_eq!( + graph.nodes["node4"].node_type.as_deref(), + Some("shared-library") + ); + + // Interface libraries (pentagon) + assert_eq!( + graph.nodes["node1"].node_type.as_deref(), + Some("interface-library") + ); + assert_eq!( + graph.nodes["node8"].node_type.as_deref(), + Some("interface-library") + ); + + // Object library (hexagon) + assert_eq!( + graph.nodes["node3"].node_type.as_deref(), + Some("object-library") + ); + + // Static library (octagon) + assert_eq!( + graph.nodes["node10"].node_type.as_deref(), + Some("static-library") + ); + + // Legend nodes should have types for mapped shapes + assert_eq!( + graph.subgraphs[0].nodes["legendNode0"].node_type.as_deref(), + Some("executable") + ); + // legendNode7 is box (custom target) - not mapped due to ambiguity + assert_eq!(graph.subgraphs[0].nodes["legendNode7"].node_type, None); + } + + #[test] + fn cargo_depgraph_style_to_node_type() { + let graph = parse( + r#"digraph { + a [label="deptangle-depgraph", shape=box]; + b [label="dot-parser", style=dashed]; + c [label="serde"]; + }"#, + ) + .unwrap(); + // Workspace crate (box shape) - shape not mapped, but preserved in attrs + assert_eq!(graph.nodes["a"].node_type, None); + assert_eq!( + graph.nodes["a"].attrs.get("shape").map(|s| s.as_str()), + Some("box") + ); + // Optional dependency (dashed style) + assert_eq!(graph.nodes["b"].node_type.as_deref(), Some("optional")); + // Regular dependency (no attrs) + assert_eq!(graph.nodes["c"].node_type, None); + } + + #[test] + fn style_overrides_shape() { + let graph = parse(r#"digraph { a [shape=egg, style=dashed]; }"#).unwrap(); + // style takes precedence over shape + assert_eq!(graph.nodes["a"].node_type.as_deref(), Some("optional")); + // Both preserved in attrs + assert_eq!( + graph.nodes["a"].attrs.get("shape").map(|s| s.as_str()), + Some("egg") + ); + assert_eq!( + graph.nodes["a"].attrs.get("style").map(|s| s.as_str()), + Some("dashed") + ); + } + + #[test] + fn unknown_style_no_node_type() { + let graph = parse(r#"digraph { a [style=dotted]; }"#).unwrap(); + assert_eq!(graph.nodes["a"].node_type, None); + assert_eq!( + graph.nodes["a"].attrs.get("style").map(|s| s.as_str()), + Some("dotted") + ); + } + + #[test] + fn fixture_cargo_depgraph() { + let input = include_str!("../../../../data/depconv/cargo-depgraph.dot"); + let graph = parse(input).unwrap(); + + // Workspace crates have shape=box (not mapped, but preserved) + assert_eq!(graph.nodes["0"].node_type, None); + assert_eq!( + graph.nodes["0"].attrs.get("shape").map(|s| s.as_str()), + Some("box") + ); + assert_eq!(graph.nodes["4"].node_type, None); + assert_eq!( + graph.nodes["4"].attrs.get("shape").map(|s| s.as_str()), + Some("box") + ); + + // Optional dependencies have style=dashed + assert_eq!( + graph.nodes["19"].node_type.as_deref(), + Some("optional"), + "dot-parser should be optional" + ); + assert_eq!( + graph.nodes["32"].node_type.as_deref(), + Some("optional"), + "color-spantrace should be optional" + ); + + // Regular external dependencies have no type + assert_eq!( + graph.nodes["7"].node_type, None, + "byteorder should have no type" + ); + assert_eq!(graph.nodes["8"].node_type, None, "clap should have no type"); + } + + #[test] + fn fixture_ninja_gv() { + let input = include_str!("../../../../data/depconv/ninja.gv"); + let graph = parse(input).unwrap(); + + // ninja.gv is a small ninja build graph from the graphviz gallery + assert_eq!(graph.nodes.len(), 125); + assert_eq!(graph.edges.len(), 131); + assert_eq!(graph.id.as_deref(), Some("ninja")); + + // "all" target node + assert_eq!(graph.nodes["0x7fe58d50f070"].label.as_str(), "all"); + // "phony" build-rule nodes have shape=ellipse + assert_eq!(graph.nodes["0x7fe58d50eeb0"].label.as_str(), "phony"); + assert_eq!( + graph.nodes["0x7fe58d50eeb0"] + .attrs + .get("shape") + .map(|s| s.as_str()), + Some("ellipse") + ); + // Source file node + assert_eq!(graph.nodes["0x7fe58d508c50"].label.as_str(), "src/ninja.cc"); + } + + #[test] + fn fixture_ninja_geos() { + let input = include_str!("../../../../data/depconv/ninja.geos.dot"); + let graph = parse(input).unwrap(); + + // ninja.geos.dot is a large ninja build graph from a GEOS CMake build + assert_eq!(graph.nodes.len(), 2451); + assert_eq!(graph.edges.len(), 3281); + assert_eq!(graph.id.as_deref(), Some("ninja")); + + // "all" target node + assert_eq!(graph.nodes["0x55b5eb08a840"].label.as_str(), "all"); + // Shared library output + assert_eq!( + graph.nodes["0x55b5eb07a950"].label.as_str(), + "lib/libgeos.so" + ); + // Build-rule nodes have shape=ellipse + assert_eq!(graph.nodes["0x55b5eb1b6210"].label.as_str(), "phony"); + assert_eq!( + graph.nodes["0x55b5eb1b6210"] + .attrs + .get("shape") + .map(|s| s.as_str()), + Some("ellipse") + ); + } + + #[test] + fn fixture_bitbake_task_depends() { + let input = include_str!("../../../../data/depconv/bitbake.curl.task-depends.dot"); + let graph = parse(input).unwrap(); + + // bitbake task-depends.dot is a large BitBake task dependency graph + assert_eq!(graph.nodes.len(), 2546); + assert_eq!(graph.edges.len(), 7597); + assert_eq!(graph.id.as_deref(), Some("depends")); + + // Labels contain \n formatting directives (preserved, not decoded) + let label = graph.nodes["acl-native.do_fetch"].label.as_str(); + assert!( + label.contains("\\n"), + "bitbake labels should preserve \\n formatting directives" + ); + // Verify a known task node + assert!( + graph.nodes.contains_key("acl-native.do_collect_spdx_deps"), + "should contain acl-native.do_collect_spdx_deps task" + ); + } +} diff --git a/crates/deptangle-io/src/parse/mermaid.rs b/crates/deptangle-io/src/parse/mermaid.rs new file mode 100644 index 0000000..0632f2c --- /dev/null +++ b/crates/deptangle-io/src/parse/mermaid.rs @@ -0,0 +1,254 @@ +use deptangle_graph::{DepGraph, Edge, NodeInfo}; +use indexmap::IndexMap; +use mermaid_rs_renderer::ir::{Direction, EdgeStyle, NodeShape}; +use mermaid_rs_renderer::parse_mermaid; + +fn map_direction(d: Direction) -> &'static str { + match d { + Direction::TopDown => "TD", + Direction::LeftRight => "LR", + Direction::BottomTop => "BT", + Direction::RightLeft => "RL", + } +} + +fn map_shape(s: NodeShape) -> Option<&'static str> { + match s { + NodeShape::Rectangle + | NodeShape::ForkJoin + | NodeShape::ActorBox + | NodeShape::MindmapDefault => None, + NodeShape::RoundRect => Some("rounded"), + NodeShape::Stadium => Some("stadium"), + NodeShape::Subroutine => Some("subroutine"), + NodeShape::Cylinder => Some("cylinder"), + NodeShape::Circle => Some("circle"), + NodeShape::DoubleCircle => Some("doublecircle"), + NodeShape::Diamond => Some("diamond"), + NodeShape::Hexagon => Some("hexagon"), + NodeShape::Parallelogram => Some("parallelogram"), + NodeShape::ParallelogramAlt => Some("parallelogram-alt"), + NodeShape::Trapezoid => Some("trapezoid"), + NodeShape::TrapezoidAlt => Some("trapezoid-alt"), + NodeShape::Asymmetric => Some("asymmetric"), + NodeShape::Text => Some("plaintext"), + } +} + +fn map_edge_style(s: EdgeStyle) -> Option<&'static str> { + match s { + EdgeStyle::Solid => None, + EdgeStyle::Dotted => Some("dotted"), + EdgeStyle::Thick => Some("thick"), + } +} + +pub fn parse(input: &str) -> eyre::Result { + let parsed = parse_mermaid(input).map_err(|e| eyre::eyre!("mermaid parse error: {e}"))?; + let graph = &parsed.graph; + + let mut result = DepGraph::default(); + result.attrs.insert( + "direction".to_string(), + map_direction(graph.direction).to_string(), + ); + + // Sort nodes by their insertion order (node_order) for deterministic output. + let mut ordered_ids: Vec<&String> = graph.nodes.keys().collect(); + ordered_ids.sort_by_key(|id| { + graph + .node_order + .get(id.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + + // Collect which nodes belong to subgraphs so we can partition them. + let mut subgraph_node_ids = std::collections::HashSet::new(); + for sg in &graph.subgraphs { + for node_id in &sg.nodes { + subgraph_node_ids.insert(node_id.as_str()); + } + } + + // Build subgraphs first, moving matching nodes into them. + for sg in &graph.subgraphs { + let mut sub = DepGraph { + id: sg.id.clone(), + ..Default::default() + }; + if sg.label != sg.id.as_deref().unwrap_or("") { + sub.attrs.insert("label".to_string(), sg.label.clone()); + } + if let Some(dir) = sg.direction { + sub.attrs + .insert("direction".to_string(), map_direction(dir).to_string()); + } + for node_id in &sg.nodes { + if let Some(node) = graph.nodes.get(node_id) { + sub.nodes.insert(node.id.clone(), convert_node(node)); + } + } + result.subgraphs.push(sub); + } + + // Add top-level nodes (those not in any subgraph). + for id in &ordered_ids { + if !subgraph_node_ids.contains(id.as_str()) + && let Some(node) = graph.nodes.get(id.as_str()) + { + result.nodes.insert(node.id.clone(), convert_node(node)); + } + } + + // Convert edges. All edges stay at top level. + for edge in &graph.edges { + let mut e = Edge { + from: edge.from.clone(), + to: edge.to.clone(), + label: edge.label.clone(), + ..Default::default() + }; + if let Some(style) = map_edge_style(edge.style) { + e.attrs.insert("style".to_string(), style.to_string()); + } + result.edges.push(e); + } + + Ok(result) +} + +fn convert_node(node: &mermaid_rs_renderer::ir::Node) -> NodeInfo { + let label = if node.label != node.id { + node.label.clone() + } else { + node.id.clone() + }; + let mut attrs = IndexMap::new(); + if let Some(shape) = map_shape(node.shape) { + attrs.insert("shape".to_string(), shape.to_string()); + } + NodeInfo { + label, + node_type: None, + attrs, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_flowchart() { + let graph = parse("flowchart LR\n").unwrap(); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + assert_eq!(graph.attrs.get("direction").unwrap(), "LR"); + } + + #[test] + fn simple_nodes_and_edges() { + let input = include_str!("../../../../data/depconv/flowchart.mmd"); + let graph = parse(input).unwrap(); + + assert_eq!(graph.attrs.get("direction").unwrap(), "LR"); + assert_eq!(graph.nodes.len(), 3); + + assert_eq!(graph.nodes["A"].label.as_str(), "myapp"); + assert_eq!(graph.nodes["B"].label.as_str(), "libfoo"); + assert_eq!(graph.nodes["C"].label.as_str(), "libbar"); + + assert_eq!(graph.edges.len(), 3); + assert_eq!(graph.edges[0].from, "A"); + assert_eq!(graph.edges[0].to, "B"); + assert_eq!(graph.edges[0].label.as_deref(), Some("static")); + assert_eq!(graph.edges[1].from, "A"); + assert_eq!(graph.edges[1].to, "C"); + assert_eq!(graph.edges[1].label.as_deref(), Some("dynamic")); + assert_eq!(graph.edges[2].from, "B"); + assert_eq!(graph.edges[2].to, "C"); + assert_eq!(graph.edges[2].label, None); + } + + #[test] + fn subgraphs() { + let input = include_str!("../../../../data/depconv/subgraph.mmd"); + let graph = parse(input).unwrap(); + + assert_eq!(graph.attrs.get("direction").unwrap(), "TD"); + assert_eq!(graph.subgraphs.len(), 2); + + let backend = &graph.subgraphs[0]; + assert_eq!(backend.id.as_deref(), Some("backend")); + assert_eq!(backend.nodes.len(), 3); + assert!(backend.nodes.contains_key("api")); + assert!(backend.nodes.contains_key("db")); + assert!(backend.nodes.contains_key("cache")); + assert_eq!(backend.nodes["api"].label.as_str(), "API Server"); + + let frontend = &graph.subgraphs[1]; + assert_eq!(frontend.id.as_deref(), Some("frontend")); + assert_eq!(frontend.nodes.len(), 2); + assert!(frontend.nodes.contains_key("web")); + assert!(frontend.nodes.contains_key("mobile")); + + // Nodes in subgraphs should not be at top level + assert!(graph.nodes.is_empty()); + + // All edges remain at top level + assert_eq!(graph.edges.len(), 4); + } + + #[test] + fn node_shapes() { + // (( )) = doublecircle, { } = diamond, {{ }} = hexagon, [ ] = rectangle + let input = "flowchart LR\n A((dcircle))\n B{diamond}\n C{{hexagon}}\n D[rectangle]\n E([stadium])\n"; + let graph = parse(input).unwrap(); + + assert_eq!(graph.nodes["A"].attrs.get("shape").unwrap(), "doublecircle"); + assert_eq!(graph.nodes["B"].attrs.get("shape").unwrap(), "diamond"); + assert_eq!(graph.nodes["C"].attrs.get("shape").unwrap(), "hexagon"); + // Rectangle is default -- no shape attr + assert!(graph.nodes["D"].attrs.get("shape").is_none()); + assert_eq!(graph.nodes["E"].attrs.get("shape").unwrap(), "stadium"); + } + + #[test] + fn edge_labels() { + let input = "flowchart LR\n A -->|uses| B\n A --> C\n"; + let graph = parse(input).unwrap(); + + assert_eq!(graph.edges[0].label.as_deref(), Some("uses")); + assert_eq!(graph.edges[1].label, None); + } + + #[test] + fn edge_styles() { + let input = "flowchart LR\n A --> B\n A -.-> C\n A ==> D\n"; + let graph = parse(input).unwrap(); + + assert!(graph.edges[0].attrs.get("style").is_none()); + assert_eq!(graph.edges[1].attrs.get("style").unwrap(), "dotted"); + assert_eq!(graph.edges[2].attrs.get("style").unwrap(), "thick"); + } + + #[test] + fn direction_variants() { + for (input_dir, expected) in [ + ("TD", "TD"), + ("TB", "TD"), // TB and TD both map to TopDown + ("LR", "LR"), + ("RL", "RL"), + ("BT", "BT"), + ] { + let input = format!("flowchart {input_dir}\n A --> B\n"); + let graph = parse(&input).unwrap(); + assert_eq!( + graph.attrs.get("direction").unwrap(), + expected, + "direction mismatch for {input_dir}" + ); + } + } +} diff --git a/crates/deptangle-io/src/parse/mod.rs b/crates/deptangle-io/src/parse/mod.rs new file mode 100644 index 0000000..2d9711b --- /dev/null +++ b/crates/deptangle-io/src/parse/mod.rs @@ -0,0 +1,118 @@ +mod cargo_metadata; +mod cargo_tree; +mod depfile; +pub(crate) mod dot; +mod mermaid; +mod pathlist; +mod style; +mod tgf; +mod tree; + +use std::fmt; +use std::path::Path; + +use clap::ValueEnum; +use deptangle_graph::DepGraph; + +/// Variant order defines content-detection priority (most specific first). +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum InputFormat { + CargoMetadata, + Mermaid, + Dot, + Tgf, + Depfile, + CargoTree, + Tree, + Pathlist, +} + +impl fmt::Display for InputFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.to_possible_value().unwrap().get_name()) + } +} + +impl TryFrom<&Path> for InputFormat { + type Error = eyre::Report; + + fn try_from(path: &Path) -> Result { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .ok_or_else(|| eyre::eyre!("no file extension: {}", path.display()))?; + match ext { + "dot" | "gv" => Ok(Self::Dot), + "mmd" | "mermaid" => Ok(Self::Mermaid), + "tgf" => Ok(Self::Tgf), + "d" => Ok(Self::Depfile), + "json" => Ok(Self::CargoMetadata), + _ => eyre::bail!("unrecognized dependency graph file extension: .{ext}"), + } + } +} + +/// Normalize a node type string to a canonical form. +/// +/// Converts format-specific type names to standardized equivalents: +/// - `"custom-build"` -> `"build-script"` +/// - `"rlib"`, `"cdylib"`, `"dylib"`, `"staticlib"` -> `"lib"` +/// - Already canonical types (`"proc-macro"`, `"bin"`, `"test"`, etc.) pass through +fn normalize_node_type(raw: &str) -> String { + match raw { + "custom-build" => "build-script".to_string(), + "rlib" | "cdylib" | "dylib" | "staticlib" => "lib".to_string(), + _ => raw.to_string(), + } +} + +/// Resolve input format using explicit flag, file extension, or content detection. +/// +/// Resolution order: +/// 1. Explicit flag if provided +/// 2. File extension if path is available +/// 3. Content detection from input string +/// +/// Returns an error if format cannot be determined. +pub fn resolve_input_format( + flag: Option, + path: Option<&Path>, + input: &str, +) -> eyre::Result { + if let Some(f) = flag { + return Ok(f); + } + let ext_err = match path.map(InputFormat::try_from) { + Some(Ok(f)) => { + tracing::info!("Detected input format: {f:?} from file extension"); + return Ok(f); + } + Some(Err(e)) => Some(e), + None => None, + }; + if let Some(f) = crate::detect::detect(input) { + tracing::info!("Detected input format: {f:?} from content"); + return Ok(f); + } + match ext_err { + Some(e) => Err(e.wrap_err("cannot detect input format; use --input-format")), + None => eyre::bail!("cannot detect input format; use --input-format"), + } +} + +pub fn parse(format: InputFormat, input: &str) -> eyre::Result { + let mut graph = match format { + InputFormat::Dot => dot::parse(input), + InputFormat::Tgf => tgf::parse(input), + InputFormat::Depfile => depfile::parse(input), + InputFormat::Pathlist => pathlist::parse(input), + InputFormat::Tree => tree::parse(input), + InputFormat::CargoTree => cargo_tree::parse(input), + InputFormat::CargoMetadata => cargo_metadata::parse(input), + InputFormat::Mermaid => mermaid::parse(input), + }?; + + style::apply_default_styles(&mut graph); + + Ok(graph) +} diff --git a/crates/deptangle-io/src/parse/pathlist.rs b/crates/deptangle-io/src/parse/pathlist.rs new file mode 100644 index 0000000..c072f81 --- /dev/null +++ b/crates/deptangle-io/src/parse/pathlist.rs @@ -0,0 +1,216 @@ +use deptangle_graph::{DepGraph, Edge, NodeInfo}; + +pub fn parse(input: &str) -> eyre::Result { + let mut graph = DepGraph::default(); + + for line in input.lines() { + // Strip tab-separated trailing markers (e.g. "path/\t(*)" or "path/\t(cycle)") + let line = match line.split_once('\t') { + Some((path, _marker)) => path, + None => line, + }; + + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Strip leading "./" and trailing "/" + let path = line.strip_prefix("./").unwrap_or(line); + let path = path.strip_suffix('/').unwrap_or(path); + if path.is_empty() { + continue; + } + + let mut current = String::new(); + for (i, component) in path.split('/').enumerate() { + let parent = if i > 0 { Some(current.clone()) } else { None }; + + if i > 0 { + current.push('/'); + } + current.push_str(component); + + // Each unique path is inserted once; edge added with the new node. + if !graph.nodes.contains_key(¤t) { + graph + .nodes + .insert(current.clone(), NodeInfo::new(component.to_string())); + if let Some(parent) = parent { + graph.edges.push(Edge { + from: parent, + to: current.clone(), + ..Default::default() + }); + } + } + } + } + + Ok(graph) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input() { + let graph = parse("").unwrap(); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + } + + #[test] + fn single_path() { + let graph = parse("src/main.rs\n").unwrap(); + assert_eq!(graph.nodes.len(), 2); + assert_eq!(graph.nodes["src"].label.as_str(), "src"); + assert_eq!(graph.nodes["src/main.rs"].label.as_str(), "main.rs"); + assert_eq!(graph.edges.len(), 1); + assert_eq!(graph.edges[0].from, "src"); + assert_eq!(graph.edges[0].to, "src/main.rs"); + } + + #[test] + fn shared_prefix() { + let graph = parse("src/a.rs\nsrc/b.rs\n").unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.nodes.get_index(0).unwrap().0, "src"); + assert_eq!(graph.nodes.get_index(1).unwrap().0, "src/a.rs"); + assert_eq!(graph.nodes.get_index(2).unwrap().0, "src/b.rs"); + assert_eq!(graph.edges.len(), 2); + assert_eq!(graph.edges[0].from, "src"); + assert_eq!(graph.edges[0].to, "src/a.rs"); + assert_eq!(graph.edges[1].from, "src"); + assert_eq!(graph.edges[1].to, "src/b.rs"); + } + + #[test] + fn nested_paths() { + let graph = parse("a/b/c\n").unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.nodes["a"].label.as_str(), "a"); + assert_eq!(graph.nodes["a/b"].label.as_str(), "b"); + assert_eq!(graph.nodes["a/b/c"].label.as_str(), "c"); + assert_eq!(graph.edges.len(), 2); + assert_eq!(graph.edges[0].from, "a"); + assert_eq!(graph.edges[0].to, "a/b"); + assert_eq!(graph.edges[1].from, "a/b"); + assert_eq!(graph.edges[1].to, "a/b/c"); + } + + #[test] + fn strips_leading_dot_slash() { + let graph = parse("./src/main.rs\n").unwrap(); + assert_eq!(graph.nodes.len(), 2); + assert!(graph.nodes.contains_key("src")); + assert!(graph.nodes.contains_key("src/main.rs")); + } + + #[test] + fn strips_trailing_slash() { + let graph = parse("src/dir/\n").unwrap(); + assert_eq!(graph.nodes.len(), 2); + assert!(graph.nodes.contains_key("src")); + assert!(graph.nodes.contains_key("src/dir")); + } + + #[test] + fn blank_lines_ignored() { + let graph = parse("\nsrc/a.rs\n\nsrc/b.rs\n\n").unwrap(); + assert_eq!(graph.nodes.len(), 3); + } + + #[test] + fn dot_slash_only_skipped() { + let graph = parse("./\n").unwrap(); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + } + + #[test] + fn single_component() { + let graph = parse("README.md\n").unwrap(); + assert_eq!(graph.nodes.len(), 1); + assert_eq!(graph.nodes["README.md"].label.as_str(), "README.md"); + assert!(graph.edges.is_empty()); + } + + #[test] + fn dot_slash_and_no_dot_slash_merge() { + let graph = parse("./src/a.rs\nsrc/b.rs\n").unwrap(); + // "src" node should be shared + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.edges.len(), 2); + } + + #[test] + fn preserves_insertion_order() { + let graph = parse("z/b\na/c\n").unwrap(); + let keys: Vec<&str> = graph.nodes.keys().map(|s| s.as_str()).collect(); + assert_eq!(keys, vec!["z", "z/b", "a", "a/c"]); + } + + #[test] + fn no_attrs() { + let graph = parse("src/main.rs\n").unwrap(); + assert!(graph.nodes["src"].attrs.is_empty()); + assert!(graph.nodes["src/main.rs"].attrs.is_empty()); + } + + #[test] + fn strips_star_marker() { + let graph = parse("a/b\na/c/\t(*)\n").unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert!(graph.nodes.contains_key("a")); + assert!(graph.nodes.contains_key("a/b")); + assert!(graph.nodes.contains_key("a/c")); + assert_eq!(graph.edges.len(), 2); + } + + #[test] + fn strips_cycle_marker() { + let graph = parse("a/b\na/b/a/\t(cycle)\n").unwrap(); + assert!(graph.nodes.contains_key("a/b/a")); + } + + #[test] + fn fixture_gitfiles() { + let input = include_str!("../../../../data/depconv/gitfiles.txt"); + let graph = parse(input).unwrap(); + assert!(graph.nodes.contains_key("crates")); + assert!(graph.nodes.contains_key("crates/deptangle-can")); + assert!(graph.nodes.contains_key("crates/deptangle-can/Cargo.toml")); + assert_eq!( + graph.nodes["crates/deptangle-can/Cargo.toml"] + .label + .as_str(), + "Cargo.toml" + ); + // Multiple crates share the "crates" prefix -- only one "crates" node + let crates_children: Vec<&Edge> = + graph.edges.iter().filter(|e| e.from == "crates").collect(); + assert!(crates_children.len() > 1); + } + + #[test] + fn fixture_find() { + let input = include_str!("../../../../data/depconv/find.txt"); + let graph = parse(input).unwrap(); + // find output uses "./" prefix -- should be stripped + assert!(!graph.nodes.contains_key(".")); + assert!(graph.nodes.contains_key("crates")); + assert!( + graph + .nodes + .contains_key("crates/deptangle-can/src/bin/can2csv.rs") + ); + assert_eq!( + graph.nodes["crates/deptangle-can/src/bin/can2csv.rs"] + .label + .as_str(), + "can2csv.rs" + ); + } +} diff --git a/crates/deptangle-io/src/parse/style.rs b/crates/deptangle-io/src/parse/style.rs new file mode 100644 index 0000000..484e44c --- /dev/null +++ b/crates/deptangle-io/src/parse/style.rs @@ -0,0 +1,339 @@ +use deptangle_graph::DepGraph; +use indexmap::IndexMap; + +/// Apply default visual styles based on semantic metadata. +/// +/// Populates `attrs` with visual defaults (shape, style, color) based on +/// `node_type` and edge `kind`. Only sets attrs that are not already present, +/// so explicit styling takes priority over defaults. +/// +/// This runs once between parse and emit, centralizing the mapping from +/// semantic metadata to visual attributes. +pub fn apply_default_styles(graph: &mut DepGraph) { + for (_id, info) in &mut graph.nodes { + if let Some(node_type) = &info.node_type { + match node_type.as_str() { + "proc-macro" => set_default(&mut info.attrs, "shape", "diamond"), + "bin" => set_default(&mut info.attrs, "shape", "box"), + "build-script" => set_default(&mut info.attrs, "shape", "note"), + "optional" => set_default(&mut info.attrs, "style", "dashed"), + "lib" => set_default(&mut info.attrs, "shape", "ellipse"), + "test" => set_default(&mut info.attrs, "shape", "hexagon"), + _ => {} + } + } + } + + for edge in &mut graph.edges { + if let Some(kind) = edge.attrs.get("kind").cloned() { + let kinds: Vec<&str> = kind.split(',').map(|k| k.trim()).collect(); + if kinds.contains(&"dev") { + set_default(&mut edge.attrs, "style", "dashed"); + set_default(&mut edge.attrs, "color", "gray60"); + } else if kinds.contains(&"build") { + set_default(&mut edge.attrs, "style", "dashed"); + } + } + } + + for sg in &mut graph.subgraphs { + apply_default_styles(sg); + } +} + +fn set_default(attrs: &mut IndexMap, key: &str, val: &str) { + if !attrs.contains_key(key) { + attrs.insert(key.to_string(), val.to_string()); + } +} + +#[cfg(test)] +mod tests { + use deptangle_graph::{Edge, NodeInfo}; + use indexmap::IndexMap; + + use super::*; + + #[test] + fn proc_macro_gets_diamond() { + let mut graph = DepGraph { + nodes: IndexMap::from([( + "pm".into(), + NodeInfo { + label: "pm".into(), + node_type: Some("proc-macro".into()), + attrs: Default::default(), + }, + )]), + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.nodes["pm"].attrs.get("shape").unwrap(), "diamond"); + } + + #[test] + fn bin_gets_box() { + let mut graph = DepGraph { + nodes: IndexMap::from([( + "b".into(), + NodeInfo { + label: String::new(), + node_type: Some("bin".into()), + attrs: Default::default(), + }, + )]), + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.nodes["b"].attrs.get("shape").unwrap(), "box"); + } + + #[test] + fn build_script_gets_note() { + let mut graph = DepGraph { + nodes: IndexMap::from([( + "bs".into(), + NodeInfo { + label: String::new(), + node_type: Some("build-script".into()), + attrs: Default::default(), + }, + )]), + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.nodes["bs"].attrs.get("shape").unwrap(), "note"); + } + + #[test] + fn optional_gets_dashed() { + let mut graph = DepGraph { + nodes: IndexMap::from([( + "opt".into(), + NodeInfo { + label: String::new(), + node_type: Some("optional".into()), + attrs: Default::default(), + }, + )]), + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.nodes["opt"].attrs.get("style").unwrap(), "dashed"); + } + + #[test] + fn lib_gets_ellipse() { + let mut graph = DepGraph { + nodes: IndexMap::from([( + "l".into(), + NodeInfo { + label: String::new(), + node_type: Some("lib".into()), + attrs: Default::default(), + }, + )]), + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.nodes["l"].attrs.get("shape").unwrap(), "ellipse"); + } + + #[test] + fn test_gets_hexagon() { + let mut graph = DepGraph { + nodes: IndexMap::from([( + "t".into(), + NodeInfo { + label: String::new(), + node_type: Some("test".into()), + attrs: Default::default(), + }, + )]), + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.nodes["t"].attrs.get("shape").unwrap(), "hexagon"); + } + + #[test] + fn no_override_existing_shape() { + let mut graph = DepGraph { + nodes: IndexMap::from([( + "pm".into(), + NodeInfo { + label: String::new(), + node_type: Some("proc-macro".into()), + attrs: IndexMap::from([("shape".into(), "box".into())]), + }, + )]), + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.nodes["pm"].attrs.get("shape").unwrap(), "box"); + } + + #[test] + fn no_override_existing_style() { + let mut graph = DepGraph { + nodes: IndexMap::from([( + "opt".into(), + NodeInfo { + label: String::new(), + node_type: Some("optional".into()), + attrs: IndexMap::from([("style".into(), "bold".into())]), + }, + )]), + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.nodes["opt"].attrs.get("style").unwrap(), "bold"); + } + + #[test] + fn unknown_type_no_attrs() { + let mut graph = DepGraph { + nodes: IndexMap::from([( + "x".into(), + NodeInfo { + label: String::new(), + node_type: Some("unknown-thing".into()), + attrs: Default::default(), + }, + )]), + ..Default::default() + }; + apply_default_styles(&mut graph); + assert!(graph.nodes["x"].attrs.is_empty()); + } + + #[test] + fn no_type_no_attrs() { + let mut graph = DepGraph { + nodes: IndexMap::from([("x".into(), NodeInfo::new("x"))]), + ..Default::default() + }; + apply_default_styles(&mut graph); + assert!(graph.nodes["x"].attrs.is_empty()); + } + + #[test] + fn edge_dev_kind() { + let mut graph = DepGraph { + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + attrs: IndexMap::from([("kind".into(), "dev".into())]), + ..Default::default() + }], + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.edges[0].attrs.get("style").unwrap(), "dashed"); + assert_eq!(graph.edges[0].attrs.get("color").unwrap(), "gray60"); + } + + #[test] + fn edge_build_kind() { + let mut graph = DepGraph { + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + attrs: IndexMap::from([("kind".into(), "build".into())]), + ..Default::default() + }], + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.edges[0].attrs.get("style").unwrap(), "dashed"); + assert!(graph.edges[0].attrs.get("color").is_none()); + } + + #[test] + fn edge_normal_kind_no_styling() { + let mut graph = DepGraph { + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + attrs: IndexMap::from([("kind".into(), "normal".into())]), + ..Default::default() + }], + ..Default::default() + }; + apply_default_styles(&mut graph); + assert!(graph.edges[0].attrs.get("style").is_none()); + assert!(graph.edges[0].attrs.get("color").is_none()); + } + + #[test] + fn edge_mixed_kind_with_dev() { + let mut graph = DepGraph { + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + attrs: IndexMap::from([("kind".into(), "normal,dev".into())]), + ..Default::default() + }], + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.edges[0].attrs.get("style").unwrap(), "dashed"); + assert_eq!(graph.edges[0].attrs.get("color").unwrap(), "gray60"); + } + + #[test] + fn edge_no_override_existing_style() { + let mut graph = DepGraph { + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + attrs: IndexMap::from([ + ("kind".into(), "dev".into()), + ("style".into(), "bold".into()), + ]), + ..Default::default() + }], + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!(graph.edges[0].attrs.get("style").unwrap(), "bold"); + // color still gets added since it wasn't present + assert_eq!(graph.edges[0].attrs.get("color").unwrap(), "gray60"); + } + + #[test] + fn subgraph_recursion() { + let mut graph = DepGraph { + subgraphs: vec![DepGraph { + nodes: IndexMap::from([( + "inner".into(), + NodeInfo { + label: String::new(), + node_type: Some("bin".into()), + attrs: Default::default(), + }, + )]), + edges: vec![Edge { + from: "a".into(), + to: "b".into(), + attrs: IndexMap::from([("kind".into(), "dev".into())]), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + apply_default_styles(&mut graph); + assert_eq!( + graph.subgraphs[0].nodes["inner"] + .attrs + .get("shape") + .unwrap(), + "box" + ); + assert_eq!( + graph.subgraphs[0].edges[0].attrs.get("style").unwrap(), + "dashed" + ); + } +} diff --git a/crates/deptangle-io/src/parse/tgf.rs b/crates/deptangle-io/src/parse/tgf.rs new file mode 100644 index 0000000..e4f6851 --- /dev/null +++ b/crates/deptangle-io/src/parse/tgf.rs @@ -0,0 +1,182 @@ +use deptangle_graph::{DepGraph, Edge, NodeInfo}; + +fn join_rest(parts: &mut std::str::SplitWhitespace) -> Option { + let rest: Vec<&str> = parts.collect(); + if rest.is_empty() { + None + } else { + Some(rest.join(" ")) + } +} + +pub fn parse(input: &str) -> eyre::Result { + let mut graph = DepGraph::default(); + let mut in_edges = false; + + for line in input.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if line == "#" { + in_edges = true; + continue; + } + + let mut parts = line.split_whitespace(); + + if in_edges { + // Edge line: "from to [label...]" + let from = parts + .next() + .ok_or_else(|| eyre::eyre!("invalid edge line: {line:?}"))?; + let to = parts + .next() + .ok_or_else(|| eyre::eyre!("invalid edge line: {line:?}"))?; + graph.edges.push(Edge { + from: from.to_string(), + to: to.to_string(), + label: join_rest(&mut parts), + ..Default::default() + }); + } else { + // Node line: "id [label...]" + let id = parts + .next() + .ok_or_else(|| eyre::eyre!("invalid node line: {line:?}"))?; + graph.nodes.insert( + id.to_string(), + NodeInfo::new(join_rest(&mut parts).unwrap_or_else(|| id.to_string())), + ); + } + } + + Ok(graph) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input() { + let graph = parse("").unwrap(); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + } + + #[test] + fn just_separator() { + let graph = parse("#\n").unwrap(); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + } + + #[test] + fn nodes_with_labels() { + let graph = parse("1 libfoo\n2 libbar\n#\n").unwrap(); + assert_eq!(graph.nodes.len(), 2); + assert_eq!(graph.nodes["1"].label.as_str(), "libfoo"); + assert_eq!(graph.nodes["2"].label.as_str(), "libbar"); + } + + #[test] + fn nodes_without_labels() { + let graph = parse("a\nb\n#\n").unwrap(); + assert_eq!(graph.nodes.len(), 2); + assert_eq!(graph.nodes["a"].label, "a"); + assert_eq!(graph.nodes["b"].label, "b"); + } + + #[test] + fn edges_with_labels() { + let graph = parse("a\nb\n#\na b depends on\n").unwrap(); + assert_eq!(graph.edges.len(), 1); + assert_eq!(graph.edges[0].from, "a"); + assert_eq!(graph.edges[0].to, "b"); + assert_eq!(graph.edges[0].label.as_deref(), Some("depends on")); + } + + #[test] + fn edges_without_labels() { + let graph = parse("a\nb\n#\na b\n").unwrap(); + assert_eq!(graph.edges.len(), 1); + assert_eq!(graph.edges[0].from, "a"); + assert_eq!(graph.edges[0].to, "b"); + assert_eq!(graph.edges[0].label, None); + } + + #[test] + fn tab_separated() { + let graph = parse("1\tlibfoo\n2\tlibbar\n#\n1\t2\tdepends on\n").unwrap(); + assert_eq!(graph.nodes["1"].label.as_str(), "libfoo"); + assert_eq!(graph.edges[0].label.as_deref(), Some("depends on")); + } + + #[test] + fn multiple_whitespace() { + let graph = parse("1 libfoo\n2\t\tlibbar\n#\n1 2\n").unwrap(); + assert_eq!(graph.nodes["1"].label.as_str(), "libfoo"); + assert_eq!(graph.nodes["2"].label.as_str(), "libbar"); + assert_eq!(graph.edges[0].from, "1"); + assert_eq!(graph.edges[0].to, "2"); + } + + #[test] + fn blank_lines_ignored() { + let graph = parse("\n1 libfoo\n\n2 libbar\n\n#\n\n1 2\n\n").unwrap(); + assert_eq!(graph.nodes.len(), 2); + assert_eq!(graph.edges.len(), 1); + } + + #[test] + fn preserves_node_order() { + let graph = parse("c C\na A\nb B\n#\n").unwrap(); + let keys: Vec<&str> = graph.nodes.keys().map(|s| s.as_str()).collect(); + assert_eq!(keys, vec!["c", "a", "b"]); + } + + #[test] + fn attrs_empty() { + let graph = parse("1 libfoo\n#\n").unwrap(); + assert!(graph.nodes["1"].attrs.is_empty()); + } + + #[test] + fn parse_fixture_small() { + let input = include_str!("../../../../data/depconv/small.tgf"); + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.nodes["1"].label.as_str(), "libfoo"); + assert_eq!(graph.nodes["2"].label.as_str(), "libbar"); + assert_eq!(graph.nodes["3"].label.as_str(), "myapp"); + assert_eq!(graph.edges.len(), 3); + assert_eq!(graph.edges[0].from, "3"); + assert_eq!(graph.edges[0].to, "1"); + assert_eq!(graph.edges[0].label, None); + } + + #[test] + fn parse_fixture_nodes_only() { + let input = include_str!("../../../../data/depconv/nodes-only.tgf"); + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.nodes["a"].label.as_str(), "alpha"); + assert_eq!(graph.nodes["b"].label.as_str(), "bravo"); + assert_eq!(graph.nodes["c"].label.as_str(), "charlie"); + assert!(graph.edges.is_empty()); + } + + #[test] + fn parse_fixture_edge_labels() { + let input = include_str!("../../../../data/depconv/edge-labels.tgf"); + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 4); + assert_eq!(graph.nodes["fmt"].label.as_str(), "deptangle-fmt"); + assert_eq!(graph.edges.len(), 4); + assert_eq!(graph.edges[0].from, "depgraph"); + assert_eq!(graph.edges[0].to, "utils"); + assert_eq!(graph.edges[0].label.as_deref(), Some("normal")); + } +} diff --git a/crates/deptangle-io/src/parse/tree.rs b/crates/deptangle-io/src/parse/tree.rs new file mode 100644 index 0000000..70c6f62 --- /dev/null +++ b/crates/deptangle-io/src/parse/tree.rs @@ -0,0 +1,369 @@ +use deptangle_graph::{DepGraph, Edge, NodeInfo}; + +/// Parse a line of `tree` output into (depth, name), or None for blank/summary lines. +fn parse_line(line: &str) -> Option<(usize, &str)> { + let mut depth = 0; + let mut rest = line; + + loop { + // Unicode branch markers (last decoration before the name) + if rest.starts_with("├── ") || rest.starts_with("└── ") { + // 10 bytes: 3 (box char) + 3 + 3 (dashes) + 1 (space) + rest = &rest[10..]; + depth += 1; + break; + } + // Unicode continuation + if rest.starts_with("│ ") { + // 6 bytes: 3 (box char) + 3 (spaces) + rest = &rest[6..]; + depth += 1; + continue; + } + // ASCII branch markers + if rest.starts_with("|-- ") || rest.starts_with("`-- ") || rest.starts_with("\\-- ") { + rest = &rest[4..]; + depth += 1; + break; + } + // ASCII continuation or blank continuation (last-child ancestor) + if rest.starts_with("| ") || rest.starts_with(" ") { + rest = &rest[4..]; + depth += 1; + continue; + } + break; + } + + let name = rest.trim_end(); + // Strip trailing markers emitted by tree/cargo-tree for revisited or cyclic nodes. + let name = name + .strip_suffix("(*)") + .or_else(|| name.strip_suffix("(cycle)")) + .map(|n| n.trim_end()) + .unwrap_or(name); + if name.is_empty() { + None + } else { + Some((depth, name)) + } +} + +/// Detect the summary line that `tree` appends (e.g. "26 directories, 40 files"). +fn is_summary(line: &str) -> bool { + let t = line.trim(); + t.starts_with(|c: char| c.is_ascii_digit()) && t.contains("director") && t.contains("file") +} + +pub fn parse(input: &str) -> eyre::Result { + let mut graph = DepGraph::default(); + // stack[i] = node ID (full path) of the most recent node at depth i + let mut stack = Vec::new(); + + for raw_line in input.lines() { + // Some `tree` builds use NO-BREAK SPACE (U+00A0) in continuation prefixes. + // Normalize to ASCII space so the fixed-width group matching works. + let owned; + let line = if raw_line.contains('\u{a0}') { + owned = raw_line.replace('\u{a0}', " "); + owned.as_str() + } else { + raw_line + }; + + if line.trim().is_empty() || is_summary(line) { + continue; + } + + let (depth, name) = match parse_line(line) { + Some(pair) => pair, + None => continue, + }; + + let id = if depth == 0 { + name.to_string() + } else if depth <= stack.len() { + format!("{}/{}", stack[depth - 1], name) + } else { + eyre::bail!("unexpected depth jump at line: {line:?}"); + }; + + stack.truncate(depth); + stack.push(id.clone()); + + graph + .nodes + .insert(id.clone(), NodeInfo::new(name.to_string())); + + if depth > 0 { + graph.edges.push(Edge { + from: stack[depth - 1].clone(), + to: id, + ..Default::default() + }); + } + } + + Ok(graph) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input() { + let graph = parse("").unwrap(); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + } + + #[test] + fn root_only() { + let graph = parse("mydir\n").unwrap(); + assert_eq!(graph.nodes.len(), 1); + assert_eq!(graph.nodes["mydir"].label.as_str(), "mydir"); + assert!(graph.edges.is_empty()); + } + + #[test] + fn unicode_simple() { + let input = "\ +root +├── alpha +└── bravo +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.nodes["root"].label.as_str(), "root"); + assert_eq!(graph.nodes["root/alpha"].label.as_str(), "alpha"); + assert_eq!(graph.nodes["root/bravo"].label.as_str(), "bravo"); + assert_eq!(graph.edges.len(), 2); + assert_eq!(graph.edges[0].from, "root"); + assert_eq!(graph.edges[0].to, "root/alpha"); + assert_eq!(graph.edges[1].from, "root"); + assert_eq!(graph.edges[1].to, "root/bravo"); + } + + #[test] + fn unicode_nested() { + let input = "\ +root +├── a +│ └── b +└── c +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 4); + assert!(graph.nodes.contains_key("root/a/b")); + assert_eq!(graph.edges.len(), 3); + assert_eq!(graph.edges[1].from, "root/a"); + assert_eq!(graph.edges[1].to, "root/a/b"); + } + + #[test] + fn ascii_simple() { + let input = "\ +root +|-- alpha +`-- bravo +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.nodes["root/alpha"].label.as_str(), "alpha"); + assert_eq!(graph.nodes["root/bravo"].label.as_str(), "bravo"); + assert_eq!(graph.edges.len(), 2); + } + + #[test] + fn ascii_nested() { + let input = "\ +root +|-- a +| `-- b +`-- c +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 4); + assert!(graph.nodes.contains_key("root/a/b")); + assert_eq!(graph.edges.len(), 3); + } + + #[test] + fn ascii_backslash_last_child() { + let input = "\ +root +\\-- only +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 2); + assert_eq!(graph.nodes["root/only"].label.as_str(), "only"); + } + + #[test] + fn blank_continuation() { + // When parent is last child, its children use " " instead of "| " + let input = "\ +root +└── parent + └── child +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 3); + assert!(graph.nodes.contains_key("root/parent/child")); + assert_eq!(graph.edges[1].from, "root/parent"); + assert_eq!(graph.edges[1].to, "root/parent/child"); + } + + #[test] + fn summary_line_skipped() { + let input = "\ +root +└── file.txt + +1 directory, 1 file +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 2); + assert!(!graph.nodes.contains_key("1 directory, 1 file")); + } + + #[test] + fn summary_plural_skipped() { + let input = "\ +root +├── a +└── b + +2 directories, 3 files +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 3); + } + + #[test] + fn depth_returns_to_root_sibling() { + let input = "\ +root +├── a +│ ├── deep1 +│ └── deep2 +└── b +"; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes.len(), 5); + assert_eq!(graph.edges[3].from, "root"); + assert_eq!(graph.edges[3].to, "root/b"); + } + + #[test] + fn no_attrs() { + let input = "\ +root +└── child +"; + let graph = parse(input).unwrap(); + assert!(graph.nodes["root"].attrs.is_empty()); + assert!(graph.nodes["root/child"].attrs.is_empty()); + } + + #[test] + fn fixture_tree_unicode() { + let input = include_str!("../../../../data/depconv/tree.txt"); + let graph = parse(input).unwrap(); + assert!(graph.nodes.contains_key("crates")); + assert!(graph.nodes.contains_key("crates/deptangle-can")); + assert!(graph.nodes.contains_key("crates/deptangle-can/Cargo.toml")); + assert!(graph.nodes.contains_key("crates/deptangle-can/src/bin")); + assert!( + graph + .nodes + .contains_key("crates/deptangle-utils/src/stdio.rs") + ); + assert_eq!( + graph.nodes["crates/deptangle-can/Cargo.toml"] + .label + .as_str(), + "Cargo.toml" + ); + // "crates" is the root -- no incoming edges + assert!(!graph.edges.iter().any(|e| e.to == "crates")); + // Spot-check a few edges + assert!( + graph + .edges + .iter() + .any(|e| e.from == "crates" && e.to == "crates/deptangle-can") + ); + assert!(graph.edges.iter().any( + |e| e.from == "crates/deptangle-can/src" && e.to == "crates/deptangle-can/src/bin" + )); + } + + #[test] + fn fixture_tree_ascii() { + let input = include_str!("../../../../data/depconv/tree-ascii.txt"); + let graph = parse(input).unwrap(); + assert!(graph.nodes.contains_key("crates")); + assert!(graph.nodes.contains_key("crates/deptangle-can")); + assert!(graph.nodes.contains_key("crates/deptangle-can/Cargo.toml")); + assert!( + graph + .nodes + .contains_key("crates/deptangle-utils/src/stdio.rs") + ); + // ASCII fixture has more entries (detect.rs, emit, parse dirs) + assert!( + graph + .nodes + .contains_key("crates/deptangle-depgraph/src/detect.rs") + ); + } + + #[test] + fn strips_star_marker() { + let input = "\ +root +├── a +│ └── shared +├── b +│ └── shared (*) +"; + let graph = parse(input).unwrap(); + // "shared" under b should resolve to the same name (without marker) + assert!(graph.nodes.contains_key("root/a/shared")); + assert!(graph.nodes.contains_key("root/b/shared")); + assert_eq!(graph.nodes["root/b/shared"].label.as_str(), "shared"); + } + + #[test] + fn strips_cycle_marker() { + let input = "\ +root +├── a +│ └── root (cycle) +"; + let graph = parse(input).unwrap(); + assert!(graph.nodes.contains_key("root/a/root")); + assert_eq!(graph.nodes["root/a/root"].label.as_str(), "root"); + } + + #[test] + fn parse_line_depth() { + assert_eq!(parse_line("root"), Some((0, "root"))); + assert_eq!(parse_line("├── child"), Some((1, "child"))); + assert_eq!(parse_line("│ └── grandchild"), Some((2, "grandchild"))); + assert_eq!(parse_line("|-- child"), Some((1, "child"))); + assert_eq!(parse_line("| `-- grandchild"), Some((2, "grandchild"))); + assert_eq!(parse_line(""), None); + } + + #[test] + fn parse_line_strips_markers() { + assert_eq!(parse_line("├── node (*)"), Some((1, "node"))); + assert_eq!(parse_line("└── node (cycle)"), Some((1, "node"))); + assert_eq!(parse_line("├── node (*)"), Some((1, "node"))); + // No marker -- name preserved as-is + assert_eq!(parse_line("├── node"), Some((1, "node"))); + } +} diff --git a/crates/deptangle-minpath/Cargo.toml b/crates/deptangle-minpath/Cargo.toml new file mode 100644 index 0000000..c57eb47 --- /dev/null +++ b/crates/deptangle-minpath/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "deptangle-minpath" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Path shortening utilities" + +[dependencies] +eyre.workspace = true +globset.workspace = true +indexmap.workspace = true +pathdiff.workspace = true +tracing.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true diff --git a/crates/deptangle-minpath/src/abbreviate.rs b/crates/deptangle-minpath/src/abbreviate.rs new file mode 100644 index 0000000..37cc8a2 --- /dev/null +++ b/crates/deptangle-minpath/src/abbreviate.rs @@ -0,0 +1,90 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::transform::LocalTransform; + +pub struct SmartAbbreviate { + abbreviations: HashMap<&'static str, &'static str>, +} + +impl Default for SmartAbbreviate { + fn default() -> Self { + Self::new() + } +} + +impl SmartAbbreviate { + pub fn new() -> Self { + Self { + abbreviations: HashMap::from([ + ("application", "app"), + ("configuration", "config"), + ("configurations", "configs"), + ("dependencies", "deps"), + ("documents", "docs"), + ("downloads", "dl"), + ("libraries", "libs"), + ("library", "lib"), + ("pictures", "pics"), + ("production", "prod"), + ("repository", "repo"), + ("source", "src"), + ("sources", "src"), + ]), + } + } + + fn abbreviate_component(&self, component: &str) -> Option<&str> { + self.abbreviations + .get(component.to_lowercase().as_str()) + .copied() + } +} + +impl LocalTransform for SmartAbbreviate { + fn transform(&self, input: &Path) -> PathBuf { + input + .iter() + .map(|component| { + let s = component.to_string_lossy(); + match self.abbreviate_component(&s) { + Some(abbrev) => abbrev.into(), + None => component.to_os_string(), + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn abbreviates_documents() { + let tr = SmartAbbreviate::new(); + let result = tr.transform(Path::new("/home/user/documents/file.txt")); + assert_eq!(result, Path::new("/home/user/docs/file.txt")); + } + + #[test] + fn abbreviates_case_insensitive() { + let tr = SmartAbbreviate::new(); + let result = tr.transform(Path::new("/home/user/DOCUMENTS/file.txt")); + assert_eq!(result, Path::new("/home/user/docs/file.txt")); + } + + #[test] + fn abbreviates_multiple_components() { + let tr = SmartAbbreviate::new(); + let result = tr.transform(Path::new("/home/user/Documents/Source/lib.rs")); + assert_eq!(result, Path::new("/home/user/docs/src/lib.rs")); + } + + #[test] + fn leaves_unknown_components_unchanged() { + let tr = SmartAbbreviate::new(); + let result = tr.transform(Path::new("/home/user/projects/foo/bar.rs")); + assert_eq!(result, Path::new("/home/user/projects/foo/bar.rs")); + } +} diff --git a/crates/deptangle-minpath/src/common_prefix.rs b/crates/deptangle-minpath/src/common_prefix.rs new file mode 100644 index 0000000..bc069f4 --- /dev/null +++ b/crates/deptangle-minpath/src/common_prefix.rs @@ -0,0 +1,121 @@ +use std::path::{Path, PathBuf}; + +use crate::transform::GlobalTransform; + +pub struct StripCommonPrefix; + +impl StripCommonPrefix { + fn common_prefix<'a>(paths: impl Iterator) -> PathBuf { + let mut paths = paths.peekable(); + let Some(first) = paths.next() else { + return PathBuf::new(); + }; + + // Single path has no common prefix to strip + if paths.peek().is_none() { + return PathBuf::new(); + } + + let mut prefix: PathBuf = first.components().collect(); + + for path in paths { + // Shorten prefix until it matches this path + while !path.starts_with(&prefix) { + if !prefix.pop() { + return PathBuf::new(); + } + } + } + + prefix + } +} + +impl GlobalTransform for StripCommonPrefix { + fn transform(&self, inputs: &[PathBuf]) -> Vec { + let prefix = Self::common_prefix(inputs.iter().map(|p| p.as_path())); + + inputs + .iter() + .map(|p| p.strip_prefix(&prefix).unwrap_or(p).to_path_buf()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_common_absolute_prefix() { + let tr = StripCommonPrefix; + let inputs: Vec = vec![ + "/home/user/project/src/main.rs".into(), + "/home/user/project/src/lib.rs".into(), + "/home/user/project/tests/test.rs".into(), + ]; + let result = tr.transform(&inputs); + assert_eq!( + result, + vec![ + PathBuf::from("src/main.rs"), + PathBuf::from("src/lib.rs"), + PathBuf::from("tests/test.rs"), + ] + ); + } + + #[test] + fn strips_common_relative_prefix() { + let tr = StripCommonPrefix; + let inputs: Vec = vec![ + "project/src/main.rs".into(), + "project/src/lib.rs".into(), + "project/tests/test.rs".into(), + ]; + let result = tr.transform(&inputs); + assert_eq!( + result, + vec![ + PathBuf::from("src/main.rs"), + PathBuf::from("src/lib.rs"), + PathBuf::from("tests/test.rs"), + ] + ); + } + + #[test] + fn mixed_absolute_and_relative_unchanged() { + let tr = StripCommonPrefix; + let inputs: Vec = vec![ + "/home/user/project/src/main.rs".into(), + "local/src/foo.rs".into(), + ]; + let result = tr.transform(&inputs); + // No common prefix between absolute and relative paths + assert_eq!(result, inputs); + } + + #[test] + fn no_common_prefix_unchanged() { + let tr = StripCommonPrefix; + let inputs: Vec = vec!["/home/alice/file.rs".into(), "/opt/bob/file.rs".into()]; + let result = tr.transform(&inputs); + // Only "/" is common, which we preserve + assert_eq!( + result, + vec![ + PathBuf::from("home/alice/file.rs"), + PathBuf::from("opt/bob/file.rs"), + ] + ); + } + + #[test] + fn single_path_unchanged() { + let tr = StripCommonPrefix; + let inputs: Vec = vec!["/home/user/project/src/main.rs".into()]; + let result = tr.transform(&inputs); + assert_eq!(result, inputs); + } +} diff --git a/crates/deptangle-minpath/src/homedir.rs b/crates/deptangle-minpath/src/homedir.rs new file mode 100644 index 0000000..75ea214 --- /dev/null +++ b/crates/deptangle-minpath/src/homedir.rs @@ -0,0 +1,53 @@ +use std::path::{Component, Path, PathBuf}; + +use crate::transform::LocalTransform; + +/// Replace `/home/` with `~` +pub struct HomeDir; + +impl LocalTransform for HomeDir { + fn transform(&self, input: &Path) -> PathBuf { + let mut components = input.components(); + if let Some(Component::RootDir) = components.next() + && let Some(Component::Normal(home_dir)) = components.next() + && home_dir == "home" + && let Some(Component::Normal(_username)) = components.next() + { + // Collect remaining components + let remaining: PathBuf = components.collect(); + let mut result = PathBuf::from("~"); + result.push(remaining); + return result; + } + + input.to_path_buf() + } +} + +#[cfg(test)] +mod tests { + use crate::{PathTransforms, assert_paths_eq}; + + #[test] + fn homedir_transform() { + let t = PathTransforms::new().home_dir(true); + let inputs = [ + "home//", + "/home/alice/documents", + "/home/bob/.local/share", + "/etc/config", + "/opt/foo/bar", + ]; + + let shortened = t.build(inputs); + let output: Vec<_> = shortened.shortened().collect(); + let expected = [ + "home//", // Relative paths are left unchanged + "~/documents", + "~/.local/share", + "/etc/config", + "/opt/foo/bar", + ]; + assert_paths_eq(expected, output); + } +} diff --git a/crates/deptangle-minpath/src/lib.rs b/crates/deptangle-minpath/src/lib.rs new file mode 100644 index 0000000..7347044 --- /dev/null +++ b/crates/deptangle-minpath/src/lib.rs @@ -0,0 +1,24 @@ +mod abbreviate; +mod common_prefix; +mod homedir; +mod normalize; +mod prefix; +mod single_letter; +mod transform; +mod unique_suffix; + +pub use transform::{PathTransforms, ShortenedPaths}; + +#[cfg(test)] +#[track_caller] +pub fn assert_paths_eq(expected: I1, actual: I2) +where + I1: IntoIterator, + P1: AsRef, + I2: IntoIterator, + P2: AsRef, +{ + for (e, a) in expected.into_iter().zip(actual) { + pretty_assertions::assert_eq!(e.as_ref(), a.as_ref()); + } +} diff --git a/crates/deptangle-minpath/src/normalize.rs b/crates/deptangle-minpath/src/normalize.rs new file mode 100644 index 0000000..659d0b5 --- /dev/null +++ b/crates/deptangle-minpath/src/normalize.rs @@ -0,0 +1,149 @@ +use std::path::{Component, Path, PathBuf}; + +use crate::transform::LocalTransform; + +// Implementation taken from Path::normalize_lexically, which is unstable, and converted to use +// eyre::Result. +fn normalize(path: &Path) -> eyre::Result { + let mut lexical = PathBuf::new(); + let mut iter = path.components().peekable(); + + // Find the root, if any, and add it to the lexical path. + // Here we treat the Windows path "C:\" as a single "root" even though + // `components` splits it into two: (Prefix, RootDir). + let root = match iter.peek() { + Some(Component::ParentDir) => eyre::bail!("Can't normalize paths starting with ../"), + Some(p @ Component::RootDir) | Some(p @ Component::CurDir) => { + lexical.push(p); + iter.next(); + lexical.as_os_str().len() + } + Some(Component::Prefix(prefix)) => { + lexical.push(prefix.as_os_str()); + iter.next(); + if let Some(p @ Component::RootDir) = iter.peek() { + lexical.push(p); + iter.next(); + } + lexical.as_os_str().len() + } + None => return Ok(PathBuf::new()), + Some(Component::Normal(_)) => 0, + }; + + for component in iter { + match component { + Component::RootDir => unreachable!(), + Component::Prefix(_) => eyre::bail!("Unexpected Windows path prefix"), + Component::CurDir => continue, + Component::ParentDir => { + // It's an error if ParentDir causes us to go above the "root". + if lexical.as_os_str().len() == root { + eyre::bail!("Can't normalize paths that go above the root"); + } else { + lexical.pop(); + } + } + Component::Normal(path) => lexical.push(path), + } + } + Ok(lexical) +} + +pub struct ResolveRelative; +impl LocalTransform for ResolveRelative { + fn transform(&self, input: &Path) -> PathBuf { + normalize(input).unwrap_or_else(|_| { + tracing::warn!("Failed to normalize path {input:?}"); + input.to_path_buf() + }) + } +} + +pub struct RelativeTo { + base: PathBuf, +} + +impl RelativeTo { + pub fn new>(base: P) -> Self { + Self { + base: base.as_ref().to_path_buf(), + } + } +} + +impl LocalTransform for RelativeTo { + fn transform(&self, input: &Path) -> PathBuf { + // For relative paths, only compute relative path if input starts with base, + // otherwise we can't verify the relationship without filesystem access. + // Absolute paths share a common root so pathdiff can always compute correctly. + // + // pathdiff assumes that if both the base and the input are relative, they are siblings of + // each other. + if self.base.is_relative() && !input.starts_with(&self.base) { + return input.to_path_buf(); + } + pathdiff::diff_paths(input, &self.base).unwrap_or_else(|| { + tracing::warn!("Failed to resolve {input:?} relative to {:?}", self.base); + input.to_path_buf() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_relative_normalizes_path() { + let tr = ResolveRelative; + let result = tr.transform(Path::new("./foo/../bar")); + assert_eq!(result, PathBuf::from("./bar")); + } + + #[test] + fn resolve_relative_falls_back_on_input() { + let tr = ResolveRelative; + let input = Path::new("../invalid"); + let result = tr.transform(input); + // Can't resolve, so return input path + assert_eq!(result, input); + } + + #[test] + fn relative_to_absolute_shared_prefix() { + let tr = RelativeTo::new("/home/user/project"); + let result = tr.transform(Path::new("/home/user/project/src/main.rs")); + assert_eq!(result, Path::new("src/main.rs")); + } + + #[test] + fn relative_to_absolute_sibling() { + let tr = RelativeTo::new("/home/user/project/src"); + let result = tr.transform(Path::new("/home/user/project/tests/test.rs")); + assert_eq!(result, Path::new("../tests/test.rs")); + } + + #[test] + fn relative_to_absolute_relative_input() { + let tr = RelativeTo::new("/home/user/project/src"); + let result = tr.transform(Path::new("tests/test.rs")); + // Can't resolve, so return input path + assert_eq!(result, Path::new("tests/test.rs")); + } + + #[test] + fn relative_to_relative_base() { + let tr = RelativeTo::new("src"); + let result = tr.transform(Path::new("src/main.rs")); + assert_eq!(result, Path::new("main.rs")); + } + + #[test] + fn relative_to_unrelated_non_ancestor() { + let tr = RelativeTo::new("src"); + let result = tr.transform(Path::new("tests/test.rs")); + // Can't resolve, so return input path + assert_eq!(result, Path::new("tests/test.rs")); + } +} diff --git a/crates/deptangle-minpath/src/prefix.rs b/crates/deptangle-minpath/src/prefix.rs new file mode 100644 index 0000000..1083f00 --- /dev/null +++ b/crates/deptangle-minpath/src/prefix.rs @@ -0,0 +1,50 @@ +use std::path::{Path, PathBuf}; + +use crate::transform::LocalTransform; + +pub struct StripPrefix { + prefixes: Vec, +} + +impl StripPrefix { + pub fn new(prefixes: Vec) -> Self { + Self { prefixes } + } +} + +impl LocalTransform for StripPrefix { + fn transform(&self, input: &Path) -> PathBuf { + for prefix in &self.prefixes { + if let Ok(stripped) = input.strip_prefix(prefix) { + return stripped.to_path_buf(); + } + } + input.to_path_buf() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_matching_prefix() { + let tr = StripPrefix::new(vec![PathBuf::from("/home/user")]); + let result = tr.transform(Path::new("/home/user/project/src/main.rs")); + assert_eq!(result, Path::new("project/src/main.rs")); + } + + #[test] + fn strip_first_matching_prefix() { + let tr = StripPrefix::new(vec![PathBuf::from("/home"), PathBuf::from("/home/user")]); + let result = tr.transform(Path::new("/home/user/project/src/main.rs")); + assert_eq!(result, Path::new("user/project/src/main.rs")); + } + + #[test] + fn no_matching_prefix() { + let tr = StripPrefix::new(vec![PathBuf::from("/opt")]); + let result = tr.transform(Path::new("/home/user/project/src/main.rs")); + assert_eq!(result, Path::new("/home/user/project/src/main.rs")); + } +} diff --git a/crates/deptangle-minpath/src/single_letter.rs b/crates/deptangle-minpath/src/single_letter.rs new file mode 100644 index 0000000..526703f --- /dev/null +++ b/crates/deptangle-minpath/src/single_letter.rs @@ -0,0 +1,78 @@ +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use crate::transform::GlobalTransform; + +pub struct SingleLetter; + +impl SingleLetter { + fn transform_one(&self, input: &Path) -> PathBuf { + let components: Vec<_> = input.iter().collect(); + if components.is_empty() { + return PathBuf::new(); + } + + let last = components.len() - 1; + components + .into_iter() + .enumerate() + .map(|(i, c)| { + if i < last { + // Abbreviate directory to first character + let first = c.to_string_lossy().chars().next().unwrap_or_default(); + OsString::from(first.to_string()) + } else { + // Keep filename as-is + c.to_os_string() + } + }) + .collect() + } +} + +impl GlobalTransform for SingleLetter { + fn transform(&self, inputs: &[PathBuf]) -> Vec { + inputs.iter().map(|p| self.transform_one(p)).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn abbreviates_directories() { + let tr = SingleLetter; + assert_eq!( + tr.transform_one(Path::new("src/utils/parse.rs")), + PathBuf::from("s/u/parse.rs") + ); + } + + #[test] + fn preserves_filename() { + let tr = SingleLetter; + assert_eq!( + tr.transform_one(Path::new("src/main.rs")), + PathBuf::from("s/main.rs") + ); + } + + #[test] + fn single_component_unchanged() { + let tr = SingleLetter; + assert_eq!( + tr.transform_one(Path::new("main.rs")), + PathBuf::from("main.rs") + ); + } + + #[test] + fn handles_absolute_path() { + let tr = SingleLetter; + assert_eq!( + tr.transform_one(Path::new("/home/user/src/main.rs")), + PathBuf::from("/h/u/s/main.rs") + ); + } +} diff --git a/crates/deptangle-minpath/src/transform.rs b/crates/deptangle-minpath/src/transform.rs new file mode 100644 index 0000000..3db6580 --- /dev/null +++ b/crates/deptangle-minpath/src/transform.rs @@ -0,0 +1,264 @@ +use std::path::{Path, PathBuf}; + +use indexmap::IndexMap; + +use super::abbreviate::SmartAbbreviate; +use super::common_prefix::StripCommonPrefix; +use super::homedir::HomeDir; +use super::normalize::{RelativeTo, ResolveRelative}; +use super::prefix::StripPrefix; +use super::single_letter::SingleLetter; +use super::unique_suffix::MinimalUniqueSuffix; + +/// Transform that operates on each path independently +pub(crate) trait LocalTransform { + fn transform(&self, input: &Path) -> PathBuf; +} + +/// Transform that requires knowledge of all paths +pub(crate) trait GlobalTransform { + fn transform(&self, inputs: &[PathBuf]) -> Vec; +} + +/// A transform that can be either local or global +enum Transform { + Local(Box), + Global(Box), +} + +/// A mapping from original paths to their shortened forms +/// +/// Created by [`PathTransforms::build`]. Provides O(1) lookup by original path +/// while preserving the original input order when iterating. Duplicate input +/// paths are deduplicated (only the first occurrence is kept). +/// +/// ``` +/// use deptangle_minpath::PathTransforms; +/// +/// let paths = vec![ +/// "/home/alice/project/src/main.rs", +/// "/home/alice/project/src/lib.rs", +/// ]; +/// +/// let shortened = PathTransforms::new() +/// .home_dir(true) +/// .minimal_unique_suffix(true) +/// .build(&paths); +/// +/// // Look up individual paths +/// assert_eq!(shortened.shorten("/home/alice/project/src/main.rs").to_str(), Some("main.rs")); +/// +/// // Iterate in original order +/// for (original, short) in shortened.iter() { +/// println!("{} -> {}", original.display(), short.display()); +/// } +/// ``` +pub struct ShortenedPaths { + mapping: IndexMap, +} + +impl ShortenedPaths { + fn new(originals: Vec, shortened: Vec) -> Self { + debug_assert_eq!(originals.len(), shortened.len()); + let mapping = originals.into_iter().zip(shortened).collect(); + Self { mapping } + } + + /// Returns the shortened form of a path, or the original if not registered + /// + /// This is the primary lookup method. It never fails - if the path wasn't + /// in the original input set, it returns the path unchanged. + pub fn shorten<'a, P: AsRef + ?Sized>(&'a self, path: &'a P) -> &'a Path { + let path = path.as_ref(); + self.mapping.get(path).map(|p| p.as_path()).unwrap_or(path) + } + + /// Returns the shortened form of a path if it was registered + pub fn get>(&self, path: P) -> Option<&Path> { + self.mapping.get(path.as_ref()).map(|p| p.as_path()) + } + + /// Iterate over (original, shortened) pairs in input order (duplicates removed) + pub fn iter(&self) -> impl Iterator { + self.mapping.iter().map(|(k, v)| (k.as_path(), v.as_path())) + } + + /// Iterate over original paths in input order (duplicates removed) + pub fn originals(&self) -> impl Iterator { + self.mapping.keys().map(|p| p.as_path()) + } + + /// Iterate over shortened paths in input order (duplicates removed) + pub fn shortened(&self) -> impl Iterator { + self.mapping.values().map(|p| p.as_path()) + } + + /// Returns the number of unique paths + pub fn len(&self) -> usize { + self.mapping.len() + } + + /// Returns true if there are no paths + pub fn is_empty(&self) -> bool { + self.mapping.is_empty() + } +} + +/// A collection of path transforms for shortening file paths. +/// +/// Transforms execute in the order they are added to the builder. +/// +/// ## Available transforms +/// +/// - [`home_dir`](Self::home_dir) - replace `/home//...` with `~/...` +/// - [`resolve_relative`](Self::resolve_relative) - normalize `.` and `..` components +/// - [`relative_to`](Self::relative_to) - make paths relative to a base +/// - [`strip_prefix`](Self::strip_prefix) - remove specified path prefixes +/// - [`smart_abbreviate`](Self::smart_abbreviate) - abbreviate `Documents` -> `docs`, etc. +/// - [`strip_common_prefix`](Self::strip_common_prefix) - remove prefix shared by all paths +/// - [`minimal_unique_suffix`](Self::minimal_unique_suffix) - shorten to unique suffix +/// - [`single_letter`](Self::single_letter) - abbreviate directories to single letters +/// +/// ## Example +/// +/// ``` +/// use deptangle_minpath::PathTransforms; +/// +/// let paths = vec![ +/// "/home/alice/project/src/main.rs", +/// "/home/alice/project/src/lib.rs", +/// ]; +/// +/// let shortened = PathTransforms::new() +/// .home_dir(true) +/// .strip_common_prefix(true) +/// .minimal_unique_suffix(true) +/// .build(&paths); +/// +/// // Query individual paths +/// println!("{}", shortened.shorten("/home/alice/project/src/main.rs").display()); +/// ``` +#[derive(Default)] +pub struct PathTransforms { + transforms: Vec, +} + +impl PathTransforms { + pub fn new() -> Self { + Self::default() + } + + fn add_local(&mut self, tr: T) { + self.transforms.push(Transform::Local(Box::new(tr))); + } + + fn add_global(&mut self, tr: T) { + self.transforms.push(Transform::Global(Box::new(tr))); + } + + /// Replace `/home//...` paths with `~/...` + pub fn home_dir(mut self, enabled: bool) -> Self { + if enabled { + self.add_local(HomeDir); + } + self + } + + /// Normalize paths by resolving `.` and `..` components without filesystem access + pub fn resolve_relative(mut self, enabled: bool) -> Self { + if enabled { + self.add_local(ResolveRelative); + } + self + } + + /// Make paths relative to the given base path (no-op if `None`) + pub fn relative_to>(mut self, base: Option

) -> Self { + if let Some(base) = base { + self.add_local(RelativeTo::new(base)); + } + self + } + + /// Strip the given prefixes from paths (first matching prefix wins) + pub fn strip_prefix(mut self, prefixes: I) -> Self + where + I: IntoIterator, + P: AsRef, + { + let prefixes: Vec = prefixes + .into_iter() + .map(|p| p.as_ref().to_path_buf()) + .collect(); + if !prefixes.is_empty() { + self.add_local(StripPrefix::new(prefixes)); + } + self + } + + /// Abbreviate common directory names (e.g., `Documents` -> `docs`, `source` -> `src`) + pub fn smart_abbreviate(mut self, enabled: bool) -> Self { + if enabled { + self.add_local(SmartAbbreviate::new()); + } + self + } + + /// Remove the common prefix shared by all paths + pub fn strip_common_prefix(mut self, enabled: bool) -> Self { + if enabled { + self.add_global(StripCommonPrefix); + } + self + } + + /// Shorten paths to the minimal unique suffix (filename, or more if needed to disambiguate) + pub fn minimal_unique_suffix(mut self, enabled: bool) -> Self { + if enabled { + self.add_global(MinimalUniqueSuffix); + } + self + } + + /// Abbreviate directory names to single letters (e.g., `src/utils/parse.rs` -> `s/u/parse.rs`) + pub fn single_letter(mut self, enabled: bool) -> Self { + if enabled { + self.add_global(SingleLetter); + } + self + } + + /// Apply all configured transforms and return a lookup structure + /// + /// This is the primary entry point for library users. It computes the + /// shortened forms for all input paths and returns a [`ShortenedPaths`] + /// that supports O(1) lookup while preserving input order for iteration. + /// + /// Transforms are applied in the order they were added to the builder. + pub fn build(&self, inputs: I) -> ShortenedPaths + where + I: IntoIterator, + P: AsRef, + { + let inputs: Vec = inputs + .into_iter() + .map(|p| p.as_ref().to_path_buf()) + .collect(); + + let shortened = self.apply(&inputs); + ShortenedPaths::new(inputs, shortened) + } + + fn apply(&self, inputs: &[PathBuf]) -> Vec { + let mut current: Vec = inputs.to_vec(); + + for transform in &self.transforms { + current = match transform { + Transform::Local(tr) => current.iter().map(|p| tr.transform(p)).collect(), + Transform::Global(tr) => tr.transform(¤t), + }; + } + + current + } +} diff --git a/crates/deptangle-minpath/src/unique_suffix.rs b/crates/deptangle-minpath/src/unique_suffix.rs new file mode 100644 index 0000000..004cead --- /dev/null +++ b/crates/deptangle-minpath/src/unique_suffix.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::transform::GlobalTransform; + +pub struct MinimalUniqueSuffix; + +impl MinimalUniqueSuffix { + /// Returns the last `n` components of a path as a borrowed slice. + fn suffix(path: &Path, n: usize) -> &Path { + let total = path.components().count(); + if n >= total { + return path; + } + + let prefix: PathBuf = path.components().take(total - n).collect(); + path.strip_prefix(&prefix).unwrap_or(path) + } +} + +impl GlobalTransform for MinimalUniqueSuffix { + // Start with the filename only, and extend the suffix component-by-component until there are + // no collisions. + fn transform(&self, inputs: &[PathBuf]) -> Vec { + if inputs.is_empty() { + return vec![]; + } + + // Track how many components from the end each path needs + let mut suffix_len: Vec = vec![1; inputs.len()]; + let max_components: Vec = inputs.iter().map(|p| p.components().count()).collect(); + + loop { + // Group paths by their current suffix + let mut groups: HashMap<&Path, Vec> = HashMap::new(); + for (i, path) in inputs.iter().enumerate() { + let suffix = Self::suffix(path, suffix_len[i]); + groups.entry(suffix).or_default().push(i); + } + + // Extend suffix for any paths that collide + let mut had_collision = false; + for indices in groups.into_values() { + if indices.len() > 1 { + for i in indices { + if suffix_len[i] < max_components[i] { + suffix_len[i] += 1; + had_collision = true; + } + } + } + } + + if !had_collision { + break; + } + } + + inputs + .iter() + .enumerate() + .map(|(i, path)| Self::suffix(path, suffix_len[i]).to_path_buf()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unique_paths_reduce_to_filename() { + let tr = MinimalUniqueSuffix; + + // Single path + assert_eq!( + tr.transform(&[PathBuf::from("src/main.rs")]), + [PathBuf::from("main.rs")] + ); + + // Multiple unique paths + assert_eq!( + tr.transform(&[ + PathBuf::from("src/main.rs"), + PathBuf::from("src/lib.rs"), + PathBuf::from("tests/test.rs"), + ]), + [ + PathBuf::from("main.rs"), + PathBuf::from("lib.rs"), + PathBuf::from("test.rs"), + ] + ); + } + + #[test] + fn collisions_extend_until_unique() { + let tr = MinimalUniqueSuffix; + + // Simple collision + assert_eq!( + tr.transform(&[PathBuf::from("src/main.rs"), PathBuf::from("tests/main.rs"),]), + [PathBuf::from("src/main.rs"), PathBuf::from("tests/main.rs")] + ); + + // Mixed: collision + unique + assert_eq!( + tr.transform(&[ + PathBuf::from("src/main.rs"), + PathBuf::from("tests/main.rs"), + PathBuf::from("src/lib.rs"), + ]), + [ + PathBuf::from("src/main.rs"), + PathBuf::from("tests/main.rs"), + PathBuf::from("lib.rs"), + ] + ); + + // Deep collision requiring multiple iterations + assert_eq!( + tr.transform(&[ + PathBuf::from("a/utils/parse.rs"), + PathBuf::from("b/utils/parse.rs"), + ]), + [ + PathBuf::from("a/utils/parse.rs"), + PathBuf::from("b/utils/parse.rs"), + ] + ); + + // Three-way collision + assert_eq!( + tr.transform(&[ + PathBuf::from("a/main.rs"), + PathBuf::from("b/main.rs"), + PathBuf::from("c/main.rs"), + ]), + [ + PathBuf::from("a/main.rs"), + PathBuf::from("b/main.rs"), + PathBuf::from("c/main.rs"), + ] + ); + + // Asymmetric depth: one path needs more extension than the other + assert_eq!( + tr.transform(&[PathBuf::from("a/b/c.rs"), PathBuf::from("d/c.rs"),]), + [PathBuf::from("b/c.rs"), PathBuf::from("d/c.rs")] + ); + } + + #[test] + fn identical_paths_stay_full() { + let tr = MinimalUniqueSuffix; + + // Two identical paths + assert_eq!( + tr.transform(&[PathBuf::from("a/b.rs"), PathBuf::from("a/b.rs")]), + [PathBuf::from("a/b.rs"), PathBuf::from("a/b.rs")] + ); + + // Identical + unique + assert_eq!( + tr.transform(&[ + PathBuf::from("a/b.rs"), + PathBuf::from("a/b.rs"), + PathBuf::from("c.rs"), + ]), + [ + PathBuf::from("a/b.rs"), + PathBuf::from("a/b.rs"), + PathBuf::from("c.rs"), + ] + ); + } + + #[test] + fn empty_input() { + let tr = MinimalUniqueSuffix; + assert!(tr.transform(&[]).is_empty()); + } +} diff --git a/crates/deptangle-ops/Cargo.toml b/crates/deptangle-ops/Cargo.toml new file mode 100644 index 0000000..d708656 --- /dev/null +++ b/crates/deptangle-ops/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "deptangle-ops" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Dependency graph operations" + +[dependencies] +clap.workspace = true +deptangle-graph.workspace = true +deptangle-minpath.workspace = true +eyre.workspace = true +globset.workspace = true +graphrs.workspace = true +indexmap.workspace = true +petgraph.workspace = true +rand.workspace = true +regex.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true diff --git a/crates/deptangle-ops/src/cluster/graphrs_bridge.rs b/crates/deptangle-ops/src/cluster/graphrs_bridge.rs new file mode 100644 index 0000000..d65dd9c --- /dev/null +++ b/crates/deptangle-ops/src/cluster/graphrs_bridge.rs @@ -0,0 +1,89 @@ +use std::collections::HashSet; + +use deptangle_graph::DepGraph; +use graphrs::algorithms::community::{leiden, louvain}; +use graphrs::{Edge, Graph, GraphSpecs, Node}; + +use super::clusters_to_depgraph; + +/// Convert a DepGraph into a graphrs Graph. +fn depgraph_to_graphrs(graph: &DepGraph, directed: bool) -> eyre::Result> { + let specs = if directed { + GraphSpecs::directed_create_missing() + } else { + GraphSpecs::undirected_create_missing() + }; + + let all_nodes = graph.all_nodes(); + let all_edges = graph.all_edges(); + + let nodes: Vec<_> = all_nodes + .keys() + .map(|id| Node::from_name(id.clone())) + .collect(); + + let edges: Vec<_> = all_edges + .iter() + .map(|e| Edge::new(e.from.clone(), e.to.clone())) + .collect(); + + let g = Graph::new_from_nodes_and_edges(nodes, edges, specs) + .map_err(|e| eyre::eyre!("graphrs error: {e}"))?; + + Ok(g) +} + +/// Convert graphrs community result (`Vec>`) to our partition format. +fn communities_to_partition(graph: &DepGraph, communities: Vec>) -> Vec> { + let all_nodes = graph.all_nodes(); + communities + .iter() + .map(|community| { + let mut ids: Vec<&str> = all_nodes + .keys() + .filter(|id| community.contains(id.as_str())) + .map(|id| id.as_str()) + .collect(); + ids.sort(); + ids + }) + .collect() +} + +/// Run the Louvain community detection algorithm on the dependency graph. +pub fn louvain_clustering( + graph: &DepGraph, + directed: bool, + resolution: f64, + seed: Option, +) -> eyre::Result { + let g = depgraph_to_graphrs(graph, directed)?; + + let communities = louvain::louvain_communities(&g, false, Some(resolution), None, seed) + .map_err(|e| eyre::eyre!("louvain error: {e}"))?; + + let partition = communities_to_partition(graph, communities); + Ok(clusters_to_depgraph(graph, &partition)) +} + +/// Run the Leiden community detection algorithm on the dependency graph. +pub fn leiden_clustering( + graph: &DepGraph, + directed: bool, + resolution: f64, +) -> eyre::Result { + let g = depgraph_to_graphrs(graph, directed)?; + + let communities = leiden::leiden( + &g, + false, + leiden::QualityFunction::CPM, + Some(resolution), + None, + None, + ) + .map_err(|e| eyre::eyre!("leiden error: {e}"))?; + + let partition = communities_to_partition(graph, communities); + Ok(clusters_to_depgraph(graph, &partition)) +} diff --git a/crates/deptangle-ops/src/cluster/lpa.rs b/crates/deptangle-ops/src/cluster/lpa.rs new file mode 100644 index 0000000..0730c64 --- /dev/null +++ b/crates/deptangle-ops/src/cluster/lpa.rs @@ -0,0 +1,162 @@ +use std::collections::HashMap; + +use deptangle_graph::{DepGraph, FlatGraphView}; +use rand::SeedableRng; +use rand::prelude::SliceRandom; +use rand::rngs::StdRng; + +use super::{Adjacency, clusters_to_depgraph}; + +/// Run Label Propagation Algorithm on the dependency graph. +/// +/// Each node starts in its own cluster. Each iteration, nodes adopt the most common +/// cluster label among their neighbors (ties broken by smallest label). Stops when +/// no labels change or `max_iter` is reached. +/// +/// If `seed` is provided, the node processing order is shuffled each iteration. +/// Otherwise, nodes are processed in graph order (deterministic). +pub fn lpa(graph: &DepGraph, directed: bool, max_iter: usize, seed: Option) -> DepGraph { + let view = FlatGraphView::new(graph); + let n = view.idx_to_id.len(); + + if n == 0 { + return DepGraph::default(); + } + + let adj = Adjacency::new(&view, directed); + + // Each node starts with its own label (index). + let mut labels: Vec = (0..n).collect(); + + let mut rng = seed.map(StdRng::seed_from_u64); + let mut order: Vec = (0..n).collect(); + + for _ in 0..max_iter { + if let Some(rng) = rng.as_mut() { + order.shuffle(rng); + } + + let mut changed = false; + for &i in &order { + let neighbors = &adj.neighbors[i]; + if neighbors.is_empty() { + continue; + } + + // Count neighbor labels. + let mut counts: HashMap = HashMap::new(); + for &neighbor in neighbors { + *counts.entry(labels[neighbor]).or_default() += 1; + } + + // Find most common label; ties broken by smallest label. + let mut best_label = labels[i]; + let mut best_count = 0; + for (&label, &count) in &counts { + if count > best_count || (count == best_count && label < best_label) { + best_label = label; + best_count = count; + } + } + + if best_label != labels[i] { + labels[i] = best_label; + changed = true; + } + } + + if !changed { + break; + } + } + + // Convert label assignments to partition. + let mut cluster_map: HashMap> = HashMap::new(); + for (i, &label) in labels.iter().enumerate() { + cluster_map + .entry(label) + .or_default() + .push(view.idx_to_id[i]); + } + + // Sort clusters by their smallest label for deterministic output. + let mut clusters: Vec<(usize, Vec<&str>)> = cluster_map.into_iter().collect(); + clusters.sort_by_key(|(label, _)| *label); + let partition: Vec> = clusters.into_iter().map(|(_, ids)| ids).collect(); + + clusters_to_depgraph(graph, &partition) +} + +#[cfg(test)] +mod tests { + use deptangle_graph::{Edge, NodeInfo}; + + use super::*; + + fn make_graph(nodes: &[(&str, &str)], edges: &[(&str, &str)]) -> DepGraph { + DepGraph { + nodes: nodes + .iter() + .map(|(id, label)| (id.to_string(), NodeInfo::new(*label))) + .collect(), + edges: edges + .iter() + .map(|(from, to)| Edge { + from: from.to_string(), + to: to.to_string(), + ..Default::default() + }) + .collect(), + ..Default::default() + } + } + + #[test] + fn two_disconnected_components() { + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("c", "d")], + ); + let result = lpa(&g, false, 100, None); + assert_eq!(result.subgraphs.len(), 2); + // No cross-cluster edges + assert!(result.edges.is_empty()); + } + + #[test] + fn single_clique() { + // Fully connected: a-b, b-c, a-c -- should all be in one cluster + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c")], + &[ + ("a", "b"), + ("b", "c"), + ("a", "c"), + ("b", "a"), + ("c", "b"), + ("c", "a"), + ], + ); + let result = lpa(&g, false, 100, None); + assert_eq!(result.subgraphs.len(), 1); + assert_eq!(result.subgraphs[0].nodes.len(), 3); + } + + #[test] + fn empty_graph() { + let g = DepGraph::default(); + let result = lpa(&g, false, 100, None); + assert!(result.subgraphs.is_empty()); + assert!(result.edges.is_empty()); + } + + #[test] + fn with_seed() { + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("c", "d")], + ); + let result = lpa(&g, false, 100, Some(42)); + assert_eq!(result.subgraphs.len(), 2); + } +} diff --git a/crates/deptangle-ops/src/cluster/mod.rs b/crates/deptangle-ops/src/cluster/mod.rs new file mode 100644 index 0000000..8fee05e --- /dev/null +++ b/crates/deptangle-ops/src/cluster/mod.rs @@ -0,0 +1,188 @@ +pub mod graphrs_bridge; +pub mod lpa; + +use std::collections::HashMap; + +use deptangle_graph::{DepGraph, Edge, FlatGraphView, NodeInfo}; +use indexmap::IndexMap; +use petgraph::Direction; + +/// Precomputed neighbor lists from a flattened dependency graph. +/// +/// In undirected mode, neighbors include both incoming and outgoing edges (deduplicated). +/// In directed mode, only outgoing neighbors are included. +pub struct Adjacency { + /// For each node index, the set of neighbor node indices. + pub neighbors: Vec>, +} + +impl Adjacency { + pub fn new(view: &FlatGraphView, directed: bool) -> Self { + let n = view.idx_to_id.len(); + let mut neighbors = vec![Vec::new(); n]; + + for idx in view.pg.node_indices() { + let i = idx.index(); + let mut seen = Vec::new(); + + for neighbor in view.pg.neighbors_directed(idx, Direction::Outgoing) { + seen.push(neighbor.index()); + } + + if !directed { + for neighbor in view.pg.neighbors_directed(idx, Direction::Incoming) { + if !seen.contains(&neighbor.index()) { + seen.push(neighbor.index()); + } + } + } + + neighbors[i] = seen; + } + + Adjacency { neighbors } + } +} + +/// Convert a partition (list of clusters, each a list of node IDs) into a DepGraph with +/// one subgraph per cluster. Intra-cluster edges go in the subgraph; cross-cluster edges +/// go at the top level. +pub fn clusters_to_depgraph(graph: &DepGraph, partition: &[Vec<&str>]) -> DepGraph { + let all_nodes = graph.all_nodes(); + let all_edges = graph.all_edges(); + + // Map each node ID to its cluster index. + let mut node_to_cluster: HashMap<&str, usize> = HashMap::new(); + for (i, cluster) in partition.iter().enumerate() { + for &id in cluster { + node_to_cluster.insert(id, i); + } + } + + let mut subgraphs = Vec::new(); + for (i, cluster) in partition.iter().enumerate() { + let cluster_ids: std::collections::HashSet<&str> = cluster.iter().copied().collect(); + + let nodes: IndexMap = all_nodes + .iter() + .filter(|(id, _)| cluster_ids.contains(id.as_str())) + .map(|(id, info)| (id.clone(), info.clone())) + .collect(); + + let edges: Vec = all_edges + .iter() + .filter(|e| { + cluster_ids.contains(e.from.as_str()) && cluster_ids.contains(e.to.as_str()) + }) + .cloned() + .collect(); + + subgraphs.push(DepGraph { + id: Some(format!("cluster_{i}")), + nodes, + edges, + ..Default::default() + }); + } + + // Cross-cluster edges: both endpoints assigned to clusters but in different ones. + let cross_edges: Vec = all_edges + .iter() + .filter(|e| { + match ( + node_to_cluster.get(e.from.as_str()), + node_to_cluster.get(e.to.as_str()), + ) { + (Some(cf), Some(ct)) => cf != ct, + _ => false, + } + }) + .cloned() + .collect(); + + DepGraph { + edges: cross_edges, + subgraphs, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_graph(nodes: &[(&str, &str)], edges: &[(&str, &str)]) -> DepGraph { + DepGraph { + nodes: nodes + .iter() + .map(|(id, label)| (id.to_string(), NodeInfo::new(*label))) + .collect(), + edges: edges + .iter() + .map(|(from, to)| Edge { + from: from.to_string(), + to: to.to_string(), + ..Default::default() + }) + .collect(), + ..Default::default() + } + } + + #[test] + fn adjacency_undirected() { + let g = make_graph(&[("a", "a"), ("b", "b"), ("c", "c")], &[("a", "b")]); + let view = FlatGraphView::new(&g); + let adj = Adjacency::new(&view, false); + // a's neighbors: b (outgoing) -> [b_idx] + let a_idx = view.id_to_idx["a"].index(); + let b_idx = view.id_to_idx["b"].index(); + assert!(adj.neighbors[a_idx].contains(&b_idx)); + // b's neighbors: a (incoming, undirected) -> [a_idx] + assert!(adj.neighbors[b_idx].contains(&a_idx)); + } + + #[test] + fn adjacency_directed() { + let g = make_graph(&[("a", "a"), ("b", "b")], &[("a", "b")]); + let view = FlatGraphView::new(&g); + let adj = Adjacency::new(&view, true); + let a_idx = view.id_to_idx["a"].index(); + let b_idx = view.id_to_idx["b"].index(); + // a -> b: a has neighbor b + assert!(adj.neighbors[a_idx].contains(&b_idx)); + // b has no outgoing edges in directed mode + assert!(adj.neighbors[b_idx].is_empty()); + } + + #[test] + fn clusters_to_depgraph_basic() { + let g = make_graph( + &[("a", "a"), ("b", "b"), ("c", "c"), ("d", "d")], + &[("a", "b"), ("c", "d"), ("b", "c")], + ); + let partition = vec![vec!["a", "b"], vec!["c", "d"]]; + let result = clusters_to_depgraph(&g, &partition); + + assert_eq!(result.subgraphs.len(), 2); + assert_eq!(result.subgraphs[0].id.as_deref(), Some("cluster_0")); + assert_eq!(result.subgraphs[0].nodes.len(), 2); + assert_eq!(result.subgraphs[0].edges.len(), 1); // a->b + assert_eq!(result.subgraphs[1].id.as_deref(), Some("cluster_1")); + assert_eq!(result.subgraphs[1].nodes.len(), 2); + assert_eq!(result.subgraphs[1].edges.len(), 1); // c->d + // Cross-cluster edge: b->c + assert_eq!(result.edges.len(), 1); + assert_eq!(result.edges[0].from, "b"); + assert_eq!(result.edges[0].to, "c"); + } + + #[test] + fn clusters_to_depgraph_empty() { + let g = DepGraph::default(); + let partition: Vec> = vec![]; + let result = clusters_to_depgraph(&g, &partition); + assert!(result.subgraphs.is_empty()); + assert!(result.edges.is_empty()); + } +} diff --git a/crates/deptangle-ops/src/diff.rs b/crates/deptangle-ops/src/diff.rs new file mode 100644 index 0000000..eaadef9 --- /dev/null +++ b/crates/deptangle-ops/src/diff.rs @@ -0,0 +1,845 @@ +use std::collections::HashSet; +use std::io::Write; + +use deptangle_graph::{DepGraph, Edge, NodeInfo}; +use indexmap::IndexMap; + +/// Status of a node or edge in a graph diff. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffStatus { + Added, + Removed, + Changed, + Moved, + Unchanged, +} + +/// A node with its diff status. +#[derive(Debug)] +pub struct DiffNode { + pub status: DiffStatus, + pub info: NodeInfo, +} + +/// An edge with its diff status. +#[derive(Debug)] +pub struct DiffEdge { + pub status: DiffStatus, + pub edge: Edge, +} + +/// Result of diffing two dependency graphs. +#[derive(Debug)] +pub struct GraphDiff { + pub nodes: IndexMap, + pub edges: Vec, +} + +impl GraphDiff { + /// Returns true if any node or edge has a status other than Unchanged. + pub fn has_changes(&self) -> bool { + self.nodes + .values() + .any(|n| n.status != DiffStatus::Unchanged) + || self.edges.iter().any(|e| e.status != DiffStatus::Unchanged) + } +} + +fn node_eq(a: &NodeInfo, b: &NodeInfo) -> bool { + a.label == b.label && a.node_type == b.node_type && a.attrs == b.attrs +} + +fn edge_eq(a: &Edge, b: &Edge) -> bool { + a.label == b.label && a.attrs == b.attrs +} + +fn build_incoming(edges: &[Edge]) -> IndexMap> { + let mut incoming: IndexMap> = IndexMap::new(); + for edge in edges { + incoming + .entry(edge.to.clone()) + .or_default() + .push(edge.from.clone()); + } + incoming +} + +/// Compute the difference between two dependency graphs. +/// +/// Nodes are matched by ID. Edges are matched by (from, to) tuple. +/// Content equality for nodes compares label, node_type, and attrs. +/// Content equality for edges compares label and attrs. +/// Nodes that are unchanged in content but have a single parent that +/// changed are marked as Moved. +pub fn diff(before: &DepGraph, after: &DepGraph) -> GraphDiff { + let before_nodes = before.all_nodes(); + let after_nodes = after.all_nodes(); + let before_edges = before.all_edges(); + let after_edges = after.all_edges(); + + let mut nodes = IndexMap::new(); + + // After-graph nodes: Added, Changed, or Unchanged + for (id, after_info) in after_nodes { + let status = match before_nodes.get(id) { + Some(before_info) => { + if node_eq(before_info, after_info) { + DiffStatus::Unchanged + } else { + DiffStatus::Changed + } + } + None => DiffStatus::Added, + }; + nodes.insert( + id.clone(), + DiffNode { + status, + info: after_info.clone(), + }, + ); + } + + // Before-only nodes: Removed + for (id, before_info) in before_nodes { + if !after_nodes.contains_key(id) { + nodes.insert( + id.clone(), + DiffNode { + status: DiffStatus::Removed, + info: before_info.clone(), + }, + ); + } + } + + // Build before-edge lookup grouped by (from, to), consuming matched entries as we go + let mut before_edge_map: IndexMap<(String, String), Vec> = IndexMap::new(); + for edge in before_edges { + let key = (edge.from.clone(), edge.to.clone()); + before_edge_map.entry(key).or_default().push(edge.clone()); + } + + let mut edges = Vec::new(); + + for edge in after_edges { + let key = (edge.from.clone(), edge.to.clone()); + let status = match before_edge_map.get_mut(&key) { + Some(before_edges) => { + if let Some(pos) = before_edges.iter().position(|be| edge_eq(be, edge)) { + before_edges.swap_remove(pos); + DiffStatus::Unchanged + } else if !before_edges.is_empty() { + before_edges.swap_remove(0); + DiffStatus::Changed + } else { + DiffStatus::Added + } + } + None => DiffStatus::Added, + }; + edges.push(DiffEdge { + status, + edge: edge.clone(), + }); + } + + // Remaining before edges are Removed + for (_, remaining) in before_edge_map { + for edge in remaining { + edges.push(DiffEdge { + status: DiffStatus::Removed, + edge, + }); + } + } + + // Move detection: upgrade Unchanged nodes whose single parent changed + let before_incoming = build_incoming(before_edges); + let after_incoming = build_incoming(after_edges); + + for (id, diff_node) in &mut nodes { + if diff_node.status != DiffStatus::Unchanged { + continue; + } + let before_parents = before_incoming.get(id.as_str()); + let after_parents = after_incoming.get(id.as_str()); + match (before_parents, after_parents) { + (Some(bp), Some(ap)) if bp.len() == 1 && ap.len() == 1 && bp[0] != ap[0] => { + diff_node.status = DiffStatus::Moved; + } + _ => {} + } + } + + GraphDiff { nodes, edges } +} + +/// Build an annotated graph combining both inputs with visual diff styling. +/// +/// Added nodes/edges are green, removed are red, changed are orange, +/// moved are blue. Each element gets a `diff` attribute for programmatic +/// filtering. The after-graph's subgraph structure is preserved: nodes +/// appear in their original subgraph positions. Removed nodes (only in +/// the before-graph) are placed at root level, or into a `cluster_removed` +/// subgraph when `cluster` is true. +pub fn annotate_graph(diff: &GraphDiff, after: &DepGraph, cluster: bool) -> DepGraph { + fn annotate_node(diff_node: &DiffNode) -> NodeInfo { + let mut info = diff_node.info.clone(); + match diff_node.status { + DiffStatus::Added => { + info.label = format!("+ {}", info.label); + info.attrs.insert("color".into(), "green".into()); + info.attrs.insert("fontcolor".into(), "green".into()); + info.attrs.insert("diff".into(), "added".into()); + } + DiffStatus::Removed => { + info.label = format!("- {}", info.label); + info.attrs.insert("color".into(), "red".into()); + info.attrs.insert("fontcolor".into(), "red".into()); + info.attrs.insert("diff".into(), "removed".into()); + } + DiffStatus::Changed => { + info.label = format!("~ {}", info.label); + info.attrs.insert("color".into(), "orange".into()); + info.attrs.insert("fontcolor".into(), "orange".into()); + info.attrs.insert("diff".into(), "changed".into()); + } + DiffStatus::Moved => { + info.label = format!("> {}", info.label); + info.attrs.insert("color".into(), "blue".into()); + info.attrs.insert("fontcolor".into(), "blue".into()); + info.attrs.insert("diff".into(), "moved".into()); + } + DiffStatus::Unchanged => { + info.attrs.insert("diff".into(), "unchanged".into()); + } + } + info + } + + fn annotate_subgraph(diff: &GraphDiff, subgraph: &DepGraph) -> DepGraph { + let nodes: IndexMap = subgraph + .nodes + .keys() + .filter_map(|id| { + let diff_node = diff.nodes.get(id)?; + Some((id.clone(), annotate_node(diff_node))) + }) + .collect(); + + let subgraphs: Vec = subgraph + .subgraphs + .iter() + .map(|sg| annotate_subgraph(diff, sg)) + .filter(|sg| !sg.nodes.is_empty() || !sg.subgraphs.is_empty()) + .collect(); + + DepGraph { + id: subgraph.id.clone(), + attrs: subgraph.attrs.clone(), + nodes, + subgraphs, + ..Default::default() + } + } + + // Walk the after-graph tree to place nodes in their original positions. + let mut root = annotate_subgraph(diff, after); + + // Removed nodes are not in the after-graph; collect them separately. + let removed_nodes: IndexMap = diff + .nodes + .iter() + .filter(|(_, n)| n.status == DiffStatus::Removed) + .map(|(id, n)| (id.clone(), annotate_node(n))) + .collect(); + + if !removed_nodes.is_empty() { + if cluster { + root.subgraphs.push(DepGraph { + id: Some("cluster_removed".into()), + nodes: removed_nodes, + ..Default::default() + }); + } else { + root.nodes.extend(removed_nodes); + } + } + + // Edges stay at root level. + for diff_edge in &diff.edges { + let mut edge = diff_edge.edge.clone(); + match diff_edge.status { + DiffStatus::Added => { + edge.attrs.insert("color".into(), "green".into()); + edge.attrs.insert("diff".into(), "added".into()); + } + DiffStatus::Removed => { + edge.attrs.insert("color".into(), "red".into()); + edge.attrs.insert("diff".into(), "removed".into()); + } + DiffStatus::Changed => { + edge.attrs.insert("color".into(), "orange".into()); + edge.attrs.insert("diff".into(), "changed".into()); + } + DiffStatus::Moved => { + edge.attrs.insert("color".into(), "blue".into()); + edge.attrs.insert("diff".into(), "moved".into()); + } + DiffStatus::Unchanged => { + edge.attrs.insert("diff".into(), "unchanged".into()); + } + } + root.edges.push(edge); + } + + root +} + +/// Build a graph containing only nodes exclusive to the "before" graph. +/// +/// The before-graph's subgraph structure is preserved. Edges are included +/// only when both endpoints are removed nodes. Empty subgraphs are dropped. +pub fn subtract_graph(diff: &GraphDiff, before: &DepGraph) -> DepGraph { + let removed_ids: HashSet<&str> = diff + .nodes + .iter() + .filter(|(_, n)| n.status == DiffStatus::Removed) + .map(|(id, _)| id.as_str()) + .collect(); + + fn filter_subgraph(graph: &DepGraph, keep: &HashSet<&str>) -> DepGraph { + DepGraph { + id: graph.id.clone(), + attrs: graph.attrs.clone(), + nodes: graph + .nodes + .iter() + .filter(|(id, _)| keep.contains(id.as_str())) + .map(|(id, info)| (id.clone(), info.clone())) + .collect(), + edges: graph + .edges + .iter() + .filter(|e| keep.contains(e.from.as_str()) && keep.contains(e.to.as_str())) + .cloned() + .collect(), + subgraphs: graph + .subgraphs + .iter() + .map(|sg| filter_subgraph(sg, keep)) + .filter(|sg| !sg.nodes.is_empty() || !sg.subgraphs.is_empty()) + .collect(), + ..Default::default() + } + } + + filter_subgraph(before, &removed_ids) +} + +/// Write a tab-delimited listing of changed nodes and edges. +/// +/// Unchanged items are omitted. Node format: `\t\t