diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 00000000..9a637ffb --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,34 @@ +# cargo-mutants configuration for vespertide. +# Mutation testing complements unit/property tests by mutating source AST +# and verifying tests FAIL. Survived mutants = test gap. +# +# Run locally: +# cargo install --locked cargo-mutants +# cargo mutants --in-place --timeout-multiplier 3.0 -vV +# +# Run on changed lines only (used by PR CI): +# cargo mutants --in-diff git.diff --in-place + +# Skip patterns that produce noise (Debug/Display/From impls, generated boilerplate) +exclude_re = [ + "impl Debug", + "impl Display", + "impl From<", + "impl std::error::Error", + "fn fmt\\(", + # RawSql is an opaque escape hatch by design + "MigrationAction::RawSql", +] + +# Focus on logic-heavy crates. Exclude: +# - schema-gen: tool, not library +# - macro: proc-macro coverage handled via trybuild +# - exporter: snapshot tests catch most mutations trivially (output diff) +exclude_globs = [ + "crates/vespertide-schema-gen/**", + "crates/vespertide-macro/**", + "crates/vespertide-exporter/**", +] + +# 3× baseline test time per mutant (handles proptest variance) +timeout_multiplier = 3.0 diff --git a/.changepacks/changepack_log_py8izYJZIfSolhHmyu236.json b/.changepacks/changepack_log_py8izYJZIfSolhHmyu236.json new file mode 100644 index 00000000..99cb89df --- /dev/null +++ b/.changepacks/changepack_log_py8izYJZIfSolhHmyu236.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-exporter/Cargo.toml":"Minor","crates/vespertide-planner/Cargo.toml":"Minor","crates/vespertide-query/Cargo.toml":"Minor","crates/vespertide-core/Cargo.toml":"Minor","crates/vespertide-lsp/Cargo.toml":"Minor","crates/vespertide-config/Cargo.toml":"Minor","crates/vespertide-loader/Cargo.toml":"Minor","crates/vespertide/Cargo.toml":"Minor","crates/vespertide-naming/Cargo.toml":"Minor","crates/vespertide-cli/Cargo.toml":"Minor","crates/vespertide-macro/Cargo.toml":"Minor"},"note":"Refactor and Impl lsp","date":"2026-05-22T12:04:07.053304800Z"} \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a1f0ffe6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,60 @@ +# Dependabot configuration for vespertide. +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# Keeps GitHub Actions and Cargo dependencies up to date. +# `changepacks/action@main` is grouped under `ignore` per project policy +# (org-internal action intentionally tracks main). + +version: 2 +updates: + # GitHub Actions: actions are pinned to major version tags (e.g. `@v6`), + # so minor/patch updates within a major are picked up automatically by the + # runner. Dependabot only proposes MAJOR-version bumps for review. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Asia/Seoul" + open-pull-requests-limit: 5 + groups: + github-actions-major: + applies-to: version-updates + update-types: + - "major" + patterns: + - "*" + ignore: + # Internal action intentionally pinned to main branch. + - dependency-name: "changepacks/action" + # Skip non-major updates: major tags auto-pick patches/minors. + - dependency-name: "*" + update-types: ["version-update:semver-minor", "version-update:semver-patch"] + + # Cargo dependencies: weekly minor/patch updates, group by ecosystem. + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Asia/Seoul" + open-pull-requests-limit: 10 + groups: + cargo-patches: + applies-to: version-updates + update-types: + - "patch" + patterns: + - "*" + cargo-minor: + applies-to: version-updates + update-types: + - "minor" + patterns: + - "*" + ignore: + # sea-orm is pinned to RC; manual updates only. + - dependency-name: "sea-orm" + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1bcdf3b8..4b23fad6 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,3 +1,10 @@ +# AGENTS.md policy: Every `.rs` file must stay ≤ 1000 lines. +# +# GitHub Actions pinning policy: third-party actions use major version tags +# only (e.g. `@v6`, not `@v6.0.2`) so security patches and bug fixes within +# the same major are picked up automatically. Dependabot proposes only +# major bumps (see `.github/dependabot.yml`). The `changepacks/action@main` +# reference is intentionally kept on `main` per project policy. name: CI on: @@ -5,9 +12,9 @@ on: branches: - main paths-ignore: - - '**/*.md' + - "**/*.md" - LICENSE - - '**/*.gitignore' + - "**/*.gitignore" - .editorconfig pull_request: workflow_dispatch: @@ -17,52 +24,114 @@ concurrency: cancel-in-progress: true jobs: - test: - name: Test + fmt: + name: fmt runs-on: ubuntu-latest - container: - image: xd009642/tarpaulin:develop-nightly - options: --security-opt seccomp=unconfined steps: - - uses: actions/checkout@v5 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - bun-version: latest + components: rustfmt + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 - uses: actions-rust-lang/setup-rust-toolchain@v1 - - name: Install - uses: dtolnay/rust-toolchain@stable with: - components: clippy, rustfmt - - name: Build - run: cargo check + components: clippy - name: Lint - run: cargo clippy --all-targets --all-features -- -D warnings && cargo fmt --check + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Test - run: | - # rust coverage issue - echo 'max_width = 100000' > .rustfmt.toml - echo 'tab_spaces = 4' >> .rustfmt.toml - echo 'newline_style = "Unix"' >> .rustfmt.toml - echo 'fn_call_width = 100000' >> .rustfmt.toml - echo 'fn_params_layout = "Compressed"' >> .rustfmt.toml - echo 'chain_width = 100000' >> .rustfmt.toml - echo 'merge_derives = true' >> .rustfmt.toml - echo 'use_small_heuristics = "Default"' >> .rustfmt.toml - cargo fmt - cargo tarpaulin --engine llvm --out Lcov Stdout --workspace --exclude app - - name: Upload to codecov.io - uses: codecov/codecov-action@v5 + run: cargo test --workspace --all-features + + test-parallelism: + name: Parallelism (RAYON_NUM_THREADS=${{ matrix.threads }}) + runs-on: ubuntu-latest + strategy: + matrix: + threads: ["1", "4"] + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Test with Rayon thread count + run: cargo test --workspace --all-features --exclude vespertide-fuzz + env: + RAYON_NUM_THREADS: ${{ matrix.threads }} + + # SQL validity gates: daemon-free real-engine and parser-level validation. + # - SQLite: in-memory execution via rusqlite (bundled = static link) + # - PG/MySQL/SQLite syntax: sqlparser-rs pure Rust 3-dialect parser + # - PG strict: pg_query = PG's real C parser (FFI, Linux/macOS only) + # No Docker, no daemon, no service container — runs on plain ubuntu-latest. + sql-validity: + name: SQL validity (daemon-free) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Install build-essential for pg_query (PG C parser) + run: sudo apt-get update && sudo apt-get install -y build-essential libreadline-dev zlib1g-dev flex bison + - name: Run SQLite in-memory exec property test + run: cargo test -p vespertide-query --test sql_sqlite_exec --release + - name: Run sqlparser 3-dialect parse property test + run: cargo test -p vespertide-query --test sql_dialect_parse --release + - name: Run pg_query (real PG parser) property test + run: cargo test -p vespertide-query --test sql_pg_query --release + + # cargo-deny enforces license/advisory/multiple-version policy; cargo-semver-checks blocks accidental semver-major API changes. + deny: + name: cargo-deny + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: EmbarkStudios/cargo-deny-action@v2 with: - token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true - files: lcov.info - if: github.ref == 'refs/heads/main' + command: check all + + semver-checks: + name: cargo-semver-checks + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: obi1kenobi/cargo-semver-checks-action@v2 + with: + # Only check published crates; skip cli (binary) and schema-gen (publish=false) + package: vespertide,vespertide-core,vespertide-config,vespertide-loader,vespertide-naming,vespertide-planner,vespertide-query,vespertide-exporter,vespertide-macro + feature-group: default-features # publish changepacks: name: changepacks runs-on: ubuntu-latest - needs: test + needs: + - fmt + - clippy + - test + - test-parallelism + - sql-validity + - doc + - schema-drift + - insta-pending + - line-budget + - coverage + - deny + - semver-checks permissions: # create pull request comments pull-requests: write @@ -71,10 +140,87 @@ jobs: # Create brench to create pull request contents: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 + # changepacks/action@main: project-internal action intentionally tracks main. - uses: changepacks/action@main id: changepacks with: publish: true env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + doc: + name: doc + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Check docs + run: RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace + + schema-drift: + name: schema-drift + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Regenerate schemas + run: cargo run -p vespertide-schema-gen -- --out _tmp_schemas + - name: Check schema drift + run: git diff --no-index --exit-code -- schemas _tmp_schemas + + insta-pending: + name: insta-pending + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Run exporter snapshots + run: cargo test -p vespertide-exporter + - name: Fail on pending snapshots + run: | + pending=$(find . -name '*.snap.new' -type f -print) + if [ -n "$pending" ]; then + printf '%s\n' "Pending insta snapshots:" "$pending" + exit 1 + fi + + line-budget: + name: line-budget + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check Rust line budget + run: sh scripts/check-line-budget.sh + + coverage: + name: coverage + runs-on: ubuntu-latest + container: + image: xd009642/tarpaulin:develop-nightly + options: --security-opt seccomp=unconfined + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Coverage + run: | + # rust coverage issue + echo 'max_width = 100000' > .rustfmt.toml + echo 'tab_spaces = 4' >> .rustfmt.toml + echo 'newline_style = "Unix"' >> .rustfmt.toml + echo 'fn_call_width = 100000' >> .rustfmt.toml + echo 'fn_params_layout = "Compressed"' >> .rustfmt.toml + echo 'chain_width = 100000' >> .rustfmt.toml + echo 'merge_derives = true' >> .rustfmt.toml + echo 'use_small_heuristics = "Default"' >> .rustfmt.toml + cargo fmt + cargo tarpaulin --engine llvm --out Lcov Stdout --workspace --exclude app + - name: Upload to codecov.io + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true + files: lcov.info + if: github.ref == 'refs/heads/main' diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 00000000..6f40a0b4 --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,31 @@ +name: benchmarks + +on: + pull_request: + paths: + - "crates/**/*.rs" + - "crates/**/benches/**" + - "crates/**/Cargo.toml" + workflow_dispatch: + +jobs: + bench: + name: criterion (informational) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Compile benchmarks + run: cargo bench --workspace --no-run + + - name: Run benchmarks (informational) + run: cargo bench --workspace -- --output-format bencher | tee bench-results.txt + + - name: Upload results + uses: actions/upload-artifact@v7 + with: + name: bench-results + path: bench-results.txt diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..acfbc721 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,65 @@ +name: fuzz + +on: + schedule: + - cron: '0 19 * * *' # nightly 04:00 KST (= 19:00 UTC prior day) + workflow_dispatch: + inputs: + target: + description: 'Single fuzz target name (default: all)' + default: 'all' + duration_seconds: + description: 'Per-target fuzz duration in seconds' + default: '300' + +jobs: + fuzz: + name: cargo-fuzz + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + target: + - fuzz_model_deser + - fuzz_sql_identifier + - fuzz_migration_apply + - fuzz_lsp_request + steps: + - uses: actions/checkout@v6 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: nightly + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-fuzz + + - name: Cache corpus + uses: actions/cache@v4 + with: + path: fuzz/corpus/${{ matrix.target }} + key: fuzz-corpus-${{ matrix.target }}-${{ github.sha }} + restore-keys: | + fuzz-corpus-${{ matrix.target }}- + + - name: Run cargo-fuzz (${{ matrix.target }}, 300s) + run: | + cd fuzz + cargo +nightly fuzz run ${{ matrix.target }} \ + -- -max_total_time=300 -max_len=4096 + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: fuzz-artifacts-${{ matrix.target }} + path: fuzz/artifacts/${{ matrix.target }}/ + + - name: Upload corpus + if: always() + uses: actions/upload-artifact@v7 + with: + name: fuzz-corpus-${{ matrix.target }} + path: fuzz/corpus/${{ matrix.target }}/ diff --git a/.github/workflows/lsp-release.yml b/.github/workflows/lsp-release.yml new file mode 100644 index 00000000..8ab94f28 --- /dev/null +++ b/.github/workflows/lsp-release.yml @@ -0,0 +1,110 @@ +# GitHub Actions pinning policy: third-party actions use major version tags +# only (e.g. `@v6`, not `@v6.0.2`). `dev-five-git/*` and `changepacks/action` +# intentionally track `@main` per project policy (not used here). +name: LSP Release + +on: + push: + tags: + - "lsp-v*" + workflow_dispatch: + inputs: + tag: + description: "Tag to release (e.g. lsp-v0.1.0)" + required: true + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runs-on: ubuntu-latest + asset_name: vespertide-lsp-linux-x86_64 + archive: tar.gz + use_cross: false + - target: aarch64-unknown-linux-gnu + runs-on: ubuntu-latest + asset_name: vespertide-lsp-linux-aarch64 + archive: tar.gz + use_cross: true + - target: x86_64-apple-darwin + runs-on: macos-13 + asset_name: vespertide-lsp-darwin-x86_64 + archive: tar.gz + use_cross: false + - target: aarch64-apple-darwin + runs-on: macos-latest + asset_name: vespertide-lsp-darwin-aarch64 + archive: tar.gz + use_cross: false + - target: x86_64-pc-windows-msvc + runs-on: windows-latest + asset_name: vespertide-lsp-windows-x86_64 + archive: zip + use_cross: false + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.target }} + + - name: Install cross (for aarch64-unknown-linux-gnu) + if: matrix.use_cross + uses: taiki-e/install-action@v2 + with: + tool: cross + + - name: Build (native) + if: ${{ !matrix.use_cross }} + run: cargo build -p vespertide-lsp --release --target ${{ matrix.target }} + + - name: Build (cross) + if: matrix.use_cross + run: cross build -p vespertide-lsp --release --target ${{ matrix.target }} + + - name: Strip (Unix, native only) + if: runner.os != 'Windows' && !matrix.use_cross + run: strip "target/${{ matrix.target }}/release/vespertide-lsp" || true + shell: bash + + - name: Package (tar.gz) + if: matrix.archive == 'tar.gz' + run: | + mkdir -p dist + cp "target/${{ matrix.target }}/release/vespertide-lsp" dist/ + cd dist + tar -czf "${{ matrix.asset_name }}.tar.gz" vespertide-lsp + shasum -a 256 "${{ matrix.asset_name }}.tar.gz" > "${{ matrix.asset_name }}.tar.gz.sha256" + ls -la + shell: bash + + - name: Package (zip) + if: matrix.archive == 'zip' + run: | + New-Item -ItemType Directory -Force -Path dist | Out-Null + Copy-Item "target\${{ matrix.target }}\release\vespertide-lsp.exe" "dist\" + Compress-Archive -Path "dist\vespertide-lsp.exe" -DestinationPath "dist\${{ matrix.asset_name }}.zip" + $hash = (Get-FileHash "dist\${{ matrix.asset_name }}.zip" -Algorithm SHA256).Hash.ToLower() + "$hash ${{ matrix.asset_name }}.zip" | Out-File -FilePath "dist\${{ matrix.asset_name }}.zip.sha256" -Encoding ascii + shell: pwsh + + - name: Upload to Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + files: | + dist/${{ matrix.asset_name }}.${{ matrix.archive }} + dist/${{ matrix.asset_name }}.${{ matrix.archive }}.sha256 + fail_on_unmatched_files: true + generate_release_notes: false diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml new file mode 100644 index 00000000..5f97692d --- /dev/null +++ b/.github/workflows/mutants.yml @@ -0,0 +1,49 @@ +# Action versions pinned per `.github/dependabot.yml` policy. +name: mutation-tests + +on: + pull_request: + paths: + - "crates/**/*.rs" + - ".cargo/mutants.toml" + +jobs: + incremental-mutants: + name: cargo-mutants (changed lines) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-mutants + + - name: Generate diff against PR base + run: git diff origin/${{ github.base_ref }}.. > git.diff + + - name: Skip if no Rust changes + run: | + if ! grep -q '\.rs$' git.diff; then + echo "No Rust file changes; skipping mutation tests." + exit 0 + fi + + - name: Run cargo-mutants on changed lines + run: | + cargo mutants \ + --no-shuffle \ + --in-diff git.diff \ + --in-place \ + --timeout-multiplier 3.0 \ + -vV + + - uses: actions/upload-artifact@v7 + if: always() + with: + name: mutants-output + path: mutants.out/ diff --git a/.github/workflows/vscode-release.yml b/.github/workflows/vscode-release.yml new file mode 100644 index 00000000..fe613b4c --- /dev/null +++ b/.github/workflows/vscode-release.yml @@ -0,0 +1,147 @@ +name: VSCode Release + +on: + push: + tags: + - "vscode-v*" + workflow_dispatch: + inputs: + lsp_tag: + description: "vespertide-lsp release tag to bundle (e.g. lsp-v0.1.0)" + required: true + vscode_tag: + description: "VSCode extension tag to release (e.g. vscode-v0.1.0)" + required: true + +permissions: + contents: write + +jobs: + publish: + name: Publish ${{ matrix.vsce_target }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - vsce_target: linux-x64 + asset_name: vespertide-lsp-linux-x86_64.tar.gz + binary_dir_name: linux-x64 + binary_ext: "" + archive_format: tar.gz + - vsce_target: linux-arm64 + asset_name: vespertide-lsp-linux-aarch64.tar.gz + binary_dir_name: linux-arm64 + binary_ext: "" + archive_format: tar.gz + - vsce_target: darwin-x64 + asset_name: vespertide-lsp-darwin-x86_64.tar.gz + binary_dir_name: darwin-x64 + binary_ext: "" + archive_format: tar.gz + - vsce_target: darwin-arm64 + asset_name: vespertide-lsp-darwin-aarch64.tar.gz + binary_dir_name: darwin-arm64 + binary_ext: "" + archive_format: tar.gz + - vsce_target: win32-x64 + asset_name: vespertide-lsp-windows-x86_64.zip + binary_dir_name: win32-x64 + binary_ext: ".exe" + archive_format: zip + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Determine LSP tag + id: lsp_tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ -n "${{ github.event.inputs.lsp_tag }}" ]; then + echo "tag=${{ github.event.inputs.lsp_tag }}" >> "$GITHUB_OUTPUT" + else + # Default: latest lsp-v* tag + LATEST=$(gh release list --limit 50 --repo dev-five-git/vespertide \ + | awk '$1 ~ /^lsp-v/ { print $1; exit }') + if [ -z "$LATEST" ]; then + echo "::error::No lsp-v* release found. Run lsp-release workflow first or pass --lsp-tag." + exit 1 + fi + echo "tag=$LATEST" >> "$GITHUB_OUTPUT" + fi + + - name: Download LSP binary asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p apps/vscode-extension/bin/${{ matrix.binary_dir_name }} + gh release download "${{ steps.lsp_tag.outputs.tag }}" \ + --repo dev-five-git/vespertide \ + --pattern "${{ matrix.asset_name }}" \ + --dir /tmp/lsp-asset + ls -la /tmp/lsp-asset + + - name: Extract LSP binary (tar.gz) + if: matrix.archive_format == 'tar.gz' + run: | + tar -xzf "/tmp/lsp-asset/${{ matrix.asset_name }}" -C apps/vscode-extension/bin/${{ matrix.binary_dir_name }} + chmod +x "apps/vscode-extension/bin/${{ matrix.binary_dir_name }}/vespertide-lsp" + ls -la apps/vscode-extension/bin/${{ matrix.binary_dir_name }} + + - name: Extract LSP binary (zip) + if: matrix.archive_format == 'zip' + run: | + unzip "/tmp/lsp-asset/${{ matrix.asset_name }}" -d apps/vscode-extension/bin/${{ matrix.binary_dir_name }} + ls -la apps/vscode-extension/bin/${{ matrix.binary_dir_name }} + + - name: Install dependencies + build + working-directory: apps/vscode-extension + run: | + bun install --frozen-lockfile + bun run build + + - name: Package VSIX + working-directory: apps/vscode-extension + run: | + bunx vsce package --target ${{ matrix.vsce_target }} --no-dependencies -o vespertide-${{ matrix.vsce_target }}.vsix + ls -la *.vsix + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.vscode_tag || github.ref_name }} + files: apps/vscode-extension/vespertide-*.vsix + fail_on_unmatched_files: true + generate_release_notes: false + + - name: Publish to VS Code Marketplace + if: github.event_name == 'push' + working-directory: apps/vscode-extension + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: | + if [ -z "$VSCE_PAT" ]; then + echo "::warning::VSCE_PAT secret not set — skipping Marketplace publish" + else + bunx vsce publish --packagePath vespertide-${{ matrix.vsce_target }}.vsix -p "$VSCE_PAT" + fi + + - name: Publish to Open VSX (VSCodium/Cursor) + if: github.event_name == 'push' + continue-on-error: true + working-directory: apps/vscode-extension + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: | + if [ -z "$OVSX_PAT" ]; then + echo "::warning::OVSX_PAT secret not set — skipping Open VSX publish" + else + bunx ovsx publish vespertide-${{ matrix.vsce_target }}.vsix -p "$OVSX_PAT" + fi diff --git a/.gitignore b/.gitignore index 95c509c2..9036a2d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,128 @@ -/target -local.db -settings.local.json -coverage -lcov.info -.sisyphus -.omc -node_modules +# ─── Rust build artifacts ───────────────────────────────────────────── +/target +# criterion benchmark HTML reports +target/criterion/ +# cargo profile-guided / llvm coverage raw data +*.profraw +# processed profile data +*.profdata +# Windows PDB debug symbols (occasionally leak outside target/) +*.pdb +# Windows cargo / rustc link tempfiles (e.g. tmp3vwkcx.exe, tmpXYZ.dll) +tmp*.exe +tmp*.dll +tmp*.lib +tmp*.exp +# profiling visualization output +flamegraph.svg +# linux perf record output +perf.data +perf.data.old + +# ─── Insta snapshot review queue ────────────────────────────────────── +# Pending snapshots awaiting `cargo insta accept` — CI gate fails on these. +*.snap.new +*.pending-snap + +# ─── cargo-mutants output (regenerated per run) ─────────────────────── +mutants-out*/ +mutants.out/ + +# ─── cargo-fuzz (corpus has gitkeep'd subdirs; artifacts are crash dumps) ── +fuzz/target/ +fuzz/artifacts/ +fuzz/corpus/* +!fuzz/corpus/*/ +!fuzz/corpus/*/.gitkeep + +# ─── CI temp / drift detection ──────────────────────────────────────── +# schema-drift job regenerated schemas for diff +_tmp_schemas/ + +# ─── Coverage output ────────────────────────────────────────────────── +coverage/ +lcov.info +# cargo-tarpaulin HTML report +tarpaulin-report.html +# cargo-tarpaulin XML report +cobertura.xml + +# ─── Vespertide local / dev-only files ──────────────────────────────── +# ad-hoc local SQLite for manual testing +local.db +# local overrides not committed +settings.local.json + +# ─── Vespertide ERD CLI outputs (smoke tests / ad-hoc renders) ──────── +examples/**/erd.svg +examples/**/erd.dot +examples/**/erd.mermaid +examples/**/erd.md + +# ─── OhMyOpenCode / agent infrastructure ────────────────────────────── +.sisyphus +.omc +.omo +.audit + +# ─── Local secrets / environment ────────────────────────────────────── +.env +.env.local +.env.*.local +# direnv auto-load (may contain secrets) +.envrc + +# ─── Patch / conflict artifacts ─────────────────────────────────────── +*.bak +*.orig +*.rej + +# ─── Logs ───────────────────────────────────────────────────────────── +*.log + +# ─── Editor / IDE ───────────────────────────────────────────────────── +.vscode/ +.idea/ +*.iml +.fleet/ + +# vim / emacs swap files +*.swp +*.swo +*.swn +*.swm +*~ + +# ─── OS junk ────────────────────────────────────────────────────────── +# macOS Finder metadata +.DS_Store +# macOS resource forks +._* +# macOS metadata +.AppleDouble +.LSOverride +# Windows Explorer thumbnails (current + encrypted + legacy) +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +# Windows folder settings +desktop.ini +# Windows recycle bin +$RECYCLE.BIN/ +# Windows shortcuts +*.lnk + +# ─── Nix build artifacts ────────────────────────────────────────────── +# nix-build symlink +result +# nix-build flake outputs +result-* + +# ─── Generic temp / scratch files ───────────────────────────────────── +*.tmp +*.temp +# LibreOffice / OpenOffice lock files +.~lock.* + +# ─── Node.js (apps/landing Next.js project from origin/main) ────────── +node_modules diff --git a/AGENTS.md b/AGENTS.md index 71b57cbe..d48bd023 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,8 +35,8 @@ vespertide/ |------|----------|-------| | Core types (TableDef, ColumnDef) | `vespertide-core/src/schema/` | Start with `table.rs`, `column.rs` | | Column type system | `vespertide-core/src/schema/column.rs` | `ColumnType::Simple/Complex` variants | -| Migration actions | `vespertide-core/src/action.rs` | 12 action variants, `MigrationPlan` struct | -| Schema diffing | `vespertide-planner/src/diff.rs` | **3215 lines** - topological sort for FK deps | +| Migration actions | `vespertide-core/src/action.rs` | **14 action variants** (incl. `RawSql` escape hatch), `MigrationPlan` struct | +| Schema diffing | `vespertide-planner/src/diff.rs` | topological sort for FK deps | | SQL generation | `vespertide-query/src/sql/` | One file per action type | | CLI commands | `vespertide-cli/src/commands/` | `cmd_*` functions | | ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel}/` | Backend-specific generators | @@ -93,9 +93,9 @@ ColumnDef { |---------|---------| | `ColumnType::Integer` | Use `ColumnType::Simple(SimpleColumnType::Integer)` | | Forgetting inline fields in ColumnDef | Will cause compile errors - 4 Option fields required | -| Raw SQL in migrations | Use typed `MigrationAction` enums | +| Raw SQL in migrations | Prefer typed `MigrationAction` enums. `MigrationAction::RawSql` exists as a documented **emergency escape hatch** only — non-portable across backends, skipped by baseline replay, and not recommended for normal use | | Skipping `normalize()` on TableDef | Inline constraints won't convert to table-level | -| Assuming YAML works | YAML loading NOT implemented (templates only) | +| `.rs` file exceeding 1000 lines | Maintainability hard limit - split into focused submodules | ## COMMANDS @@ -122,15 +122,53 @@ cargo insta test -p vespertide-exporter cargo insta accept ``` -## COMPLEXITY HOTSPOTS +## COMPLEXITY HOTSPOTS (≤ 1000-line policy enforced) + +**Policy**: Every `.rs` file must stay ≤ 1000 lines. CI enforces; current state: ✅ zero violations. + +Largest production files (margin 임박 = next split candidates): + +| File | Lines | What | +|------|-------|------| +| `exporter/src/seaorm/relations.rs` | 996 | SeaORM FK relation resolution + sequential aggregation | +| `cli/src/commands/export.rs` | 991 | CLI export command for 4 ORMs | +| `query/src/sql/create_table.rs` | 750 | CREATE TABLE statement generation | +| `query/src/sql/add_column.rs` | 732 | ADD COLUMN with SQLite temp-table for non-nullable/enum | +| `query/src/sql/helpers.rs` | 706 | Column type mapping, FK actions, enum/naming helpers | +| `cli/src/commands/diff.rs` | 659 | Diff CLI command | +| `loader/src/models.rs` | 641 | Model file loading with rayon parallelization | +| `naming/src/lib.rs` | 630 | Naming convention utilities | +| `query/src/sql/modify_column_default.rs` | 604 | ALTER COLUMN SET/DROP DEFAULT | + +Largest test files (snapshot-locked; split costs snapshot rename): | File | Lines | What | |------|-------|------| -| `planner/src/diff.rs` | 3215 | Schema diffing with topological FK sort | -| `exporter/src/seaorm/mod.rs` | 2961 | SeaORM codegen with relation inference | -| `planner/src/validate.rs` | 1821 | Schema/migration validation | -| `core/src/schema/table.rs` | 1582 | Table normalization logic | -| `query/src/sql/remove_constraint.rs` | 1581 | SQLite temp table workarounds | +| `exporter/src/seaorm/tests.rs` | 990 | SeaORM codegen snapshots | +| `core/src/schema/table/tests.rs` | 986 | Table normalization tests | +| `exporter/src/sqlalchemy/tests.rs` | 988 | SQLAlchemy snapshots | +| `query/src/sql/delete_column/tests.rs` | 954 | DROP COLUMN tests | +| `planner/src/validate/tests/plan_validation.rs` | 954 | Plan validation tests | + +**Historical splits** (Waves 1-9 of optimization work): +- `planner/src/diff.rs` (4739) → `diff/{mod,columns,constraints,ordering,tables}.rs` +- `exporter/src/seaorm/mod.rs` (4122) → split into `mod.rs` + `relations.rs` + `helper_tests.rs` +- `cli/src/commands/revision.rs` (3064) → `revision/{mod,prompts,recreate,tests}.rs` +- `planner/src/validate.rs` (2299) → `validate/{plan,schema,foreign_keys,tests}.rs` +- `planner/src/apply.rs` (1534) → `apply/{mod,tests}.rs` +- `core/src/schema/table.rs` (1526) → `table/{mod,tests}.rs` +- `query/src/sql/mod.rs` (1507) → `sql/{mod,tests}.rs` +- `query/src/sql/remove_constraint.rs` (1465) → `remove_constraint/{mod,sqlite,...}.rs` +- `exporter/src/sqlalchemy/mod.rs` (1383) → `sqlalchemy/{mod,render,types,tests}.rs` +- `query/src/sql/add_constraint.rs` (1356) → `add_constraint/{mod,tests}.rs` +- `exporter/src/sqlmodel/mod.rs` (1274) → `sqlmodel/{mod,render,types,tests}.rs` +- `core/src/action.rs` (1236) → `action/{mod,tests}.rs` +- `exporter/src/jpa/mod.rs` (1122) → `jpa/{mod,render,types}.rs` +- `query/src/sql/delete_column.rs` (1084) → `delete_column/{mod,tests}.rs` +- `query/src/sql/modify_column_type.rs` (1056, Wave 9) → `modify_column_type/{mod,direct,sqlite_rebuild,tests}.rs` +- `query/src/builder.rs` (995, Wave 9 preventive) → `builder/{mod,sequential,transaction,parallel,tests}.rs` + +Verify line policy: `python -c "import os, glob; files = []; [files.extend(glob.glob(os.path.join(r,'*.rs'))) for r,_,_ in os.walk('crates')]; over = [(sum(1 for _ in open(f, encoding='utf-8', errors='ignore')), f) for f in files]; result = sorted([x for x in over if x[0] > 1000], reverse=True); print('\n'.join(f'{l:5} {p}' for l, p in result) if result else 'OK: zero files >1000 lines')"` ## TESTING @@ -146,11 +184,80 @@ cargo insta accept |---------|-------------------|-------| | PostgreSQL | `"identifier"` | Full feature support | | MySQL | `` `identifier` `` | Full feature support | -| SQLite | `"identifier"` | Temp table workarounds for ALTER | +| SQLite | `"identifier"` | Full feature support (ALTER limitations implemented via canonical temp-table-rebuild pattern in `query/src/sql/remove_constraint.rs` etc.) | + +## MODEL FORMATS + +Both JSON and YAML are supported for model and migration files. Loaders accept `.json`, `.yaml`, and `.yml` extensions. JSON is preferred (canonical schema URLs reference JSON) but YAML loading is a first-class, tested feature — see `vespertide-loader/src/models.rs` and `vespertide-config/src/file_format.rs`. ## NOTES - Edition 2024 (bleeding edge) - No LSP available - use grep/AST tools -- YAML loading not implemented -- Migration replay pattern: baseline always reconstructed from history +- Every `.rs` file must stay ≤ 1000 lines; CI enforces this +- Migration replay pattern: baseline always reconstructed from history (raw SQL actions are opaque to replay) + +## MUTATION TESTING + +`cargo-mutants` runs in CI on every PR for changed lines only. Locally: + +```bash +# Full pass on the planner crate (slow, ~30 min) +cargo install --locked cargo-mutants +cargo mutants -p vespertide-planner --in-place --timeout-multiplier 3.0 + +# Only mutations introduced by current changes +cargo mutants --in-diff <(git diff main..) --in-place +``` + +Survived mutants indicate test gaps. Fix by adding assertions, not by suppressing the mutant. + +## FUZZING + +`cargo-fuzz` runs in a nightly CI job (`.github/workflows/fuzz.yml`). +Three targets in `fuzz/fuzz_targets/`: + +- `fuzz_model_deser` — JSON deserialization of `TableDef` / `MigrationPlan` +- `fuzz_sql_identifier` — `quote_ident` safety invariants +- `fuzz_migration_apply` — `apply_action` never-panic property + +Local run (requires nightly): + +```bash +rustup install nightly +cargo install cargo-fuzz +cd fuzz +cargo +nightly fuzz run fuzz_model_deser -- -max_total_time=60 +``` + +Corpus and artifacts are gitignored except the `.gitkeep` markers. +Discovered crashes appear under `fuzz/artifacts//` and should be +committed to a regression test before fixing. + +## BENCHMARKS + +`criterion` benchmarks in `crates/*/benches/`. Run locally: + +```bash +# All benchmarks +cargo bench --workspace + +# Single crate +cargo bench -p vespertide-planner + +# Single benchmark with statistical comparison +cargo bench -p vespertide-planner --bench diff_benchmarks -- diff_identity/100 +``` + +HTML reports at `target/criterion//report/index.html`. + +Save baseline for comparison: + +```bash +cargo bench -- --save-baseline main +git checkout feature/foo +cargo bench -- --baseline main +``` + +CI workflow in `.github/workflows/bench.yml` runs on PR for informational +trend tracking (not currently blocking). diff --git a/Cargo.lock b/Cargo.lock index 63a30e1f..93186ca4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,11 +57,17 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -74,15 +80,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -125,6 +131,15 @@ dependencies = [ "vespertide", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -133,9 +148,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4754a624e5ae42081f464514be454b39711daae0458906dacde5f4c632f33a8" +checksum = "3bd47f2a6ddc39244bd722a27ee5da66c03369d087b9e024eafdb03e98b98ea7" dependencies = [ "arrow-arith", "arrow-array", @@ -151,9 +166,9 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7b3141e0ec5145a22d8694ea8b6d6f69305971c4fa1c1a13ef0195aef2d678b" +checksum = "7c7bbd679c5418b8639b92be01f361d60013c4906574b578b77b63c78356594c" dependencies = [ "arrow-array", "arrow-buffer", @@ -165,9 +180,9 @@ dependencies = [ [[package]] name = "arrow-array" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8955af33b25f3b175ee10af580577280b4bd01f7e823d94c7cdef7cf8c9aef" +checksum = "c8a4ab47b3f3eac60f7fd31b81e9028fda018607bcc63451aca4f2b755269862" dependencies = [ "ahash 0.8.12", "arrow-buffer", @@ -183,9 +198,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c697ddca96183182f35b3a18e50b9110b11e916d7b7799cbfd4d34662f2c56c2" +checksum = "0d18b89b4c4f4811d0858175e79541fe98e33e18db3b011708bc287b1240593f" dependencies = [ "bytes", "half", @@ -195,9 +210,9 @@ dependencies = [ [[package]] name = "arrow-cast" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "646bbb821e86fd57189c10b4fcdaa941deaf4181924917b0daa92735baa6ada5" +checksum = "722b5c41dd1d14d0a879a1bce92c6fe33f546101bb2acce57a209825edd075b3" dependencies = [ "arrow-array", "arrow-buffer", @@ -216,9 +231,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fdd994a9d28e6365aa78e15da3f3950c0fdcea6b963a12fa1c391afb637b304" +checksum = "c1683705c63dcf0d18972759eda48489028cbbff67af7d6bef2c6b7b74ab778a" dependencies = [ "arrow-buffer", "arrow-schema", @@ -229,9 +244,9 @@ dependencies = [ [[package]] name = "arrow-ord" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d8f1870e03d4cbed632959498bcc84083b5a24bded52905ae1695bd29da45b" +checksum = "082342947d4e5a2bcccf029a0a0397e21cb3bb8421edd9571d34fb5dd2670256" dependencies = [ "arrow-array", "arrow-buffer", @@ -242,9 +257,9 @@ dependencies = [ [[package]] name = "arrow-row" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18228633bad92bff92a95746bbeb16e5fc318e8382b75619dec26db79e4de4c0" +checksum = "e3a931b520a2a5e22033e01a6f2486b4cdc26f9106b759abeebc320f125e94d7" dependencies = [ "arrow-array", "arrow-buffer", @@ -255,15 +270,15 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c872d36b7bf2a6a6a2b40de9156265f0242910791db366a2c17476ba8330d68" +checksum = "e4cf0d4a6609679e03002167a61074a21d7b1ad9ea65e462b2c0a97f8a3b2bc6" [[package]] name = "arrow-select" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68bf3e3efbd1278f770d67e5dc410257300b161b93baedb3aae836144edcaf4b" +checksum = "0b320d86a9806923663bb0fd9baa65ecaba81cb0cd77ff8c1768b9716b4ef891" dependencies = [ "ahash 0.8.12", "arrow-array", @@ -275,9 +290,9 @@ dependencies = [ [[package]] name = "arrow-string" -version = "57.3.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85e968097061b3c0e9fe3079cf2e703e487890700546b5b0647f60fca1b5a8d8" +checksum = "b493e99162e5764077e7823e50ba284858d365922631c7aaefe9487b1abd02c2" dependencies = [ "arrow-array", "arrow-buffer", @@ -292,9 +307,9 @@ dependencies = [ [[package]] name = "assert_cmd" -version = "2.1.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" dependencies = [ "anstyle", "bstr", @@ -372,9 +387,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -425,9 +440,9 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.12.5" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" dependencies = [ "axum", "axum-core", @@ -478,11 +493,55 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.66.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b84e06fc203107bfbad243f4aba2af864eb7db3b1cf46ea0a023b0b433d2a7" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.117", + "which", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" -version = "2.11.0" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -508,21 +567,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "borsh" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", "proc-macro-crate", @@ -582,16 +648,33 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" -version = "1.2.57" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -618,11 +701,49 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" -version = "4.5.53" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -630,9 +751,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -642,9 +763,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -654,23 +775,23 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" -version = "3.0.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -767,9 +888,64 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] [[package]] name = "crossbeam-queue" @@ -794,9 +970,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -836,6 +1012,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "der" version = "0.7.10" @@ -857,6 +1047,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -920,6 +1121,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dissimilar" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" + +[[package]] +name = "dot-writer" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2f7a508d3f95b7cb559acf2231c7efad02fe04061d3165b12513c2dbcc77af0" + [[package]] name = "dotenvy" version = "0.15.7" @@ -994,11 +1207,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -1006,6 +1231,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "float-cmp" version = "0.10.0" @@ -1015,6 +1246,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.11.1" @@ -1062,6 +1313,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -1070,9 +1327,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1175,9 +1432,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -1246,6 +1503,15 @@ dependencies = [ "ahash 0.7.8", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1263,6 +1529,21 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -1308,6 +1589,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1388,9 +1675,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1402,7 +1689,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", ] @@ -1448,12 +1734,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1461,9 +1748,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1474,9 +1761,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1488,15 +1775,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1508,15 +1795,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1552,9 +1839,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1562,12 +1849,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1596,12 +1883,32 @@ dependencies = [ "tempfile", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[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.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1617,12 +1924,24 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1636,6 +1955,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1701,9 +2026,29 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] [[package]] name = "libm" @@ -1713,14 +2058,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.5", ] [[package]] @@ -1734,6 +2079,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1742,9 +2093,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1761,6 +2112,42 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "ls-types" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896e16b8e17d8732b9efe4d5b66cb0cc162b3023a2d8122f2aea6f7f185e0a67" +dependencies = [ + "bitflags 2.11.1", + "fluent-uri 0.4.1", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "lsp-textdocument" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da72ba568f141737cbaf0ce7c7f757a00f94f204352bb1297ae0f28ae43398a" +dependencies = [ + "lsp-types", + "serde_json", +] + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri 0.1.4", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "mac_address" version = "1.1.8" @@ -1772,6 +2159,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -1809,11 +2205,17 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -1837,6 +2239,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "native-tls" version = "0.2.18" @@ -1860,19 +2268,38 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", "memoffset", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1894,7 +2321,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1910,9 +2337,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -1956,17 +2383,22 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -1990,9 +2422,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -2062,6 +2494,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2077,6 +2515,34 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pg_query" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ca6fdb8f9d32182abf17328789f87f305dd8c8ce5bf48c5aa2b5cffc94e1c04" +dependencies = [ + "bindgen", + "cc", + "fs_extra", + "glob", + "itertools 0.10.5", + "prost", + "prost-build", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "pgvector" version = "0.4.1" @@ -2092,12 +2558,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkcs1" version = "0.7.5" @@ -2121,9 +2581,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -2131,6 +2591,34 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "pluralizer" version = "0.5.0" @@ -2143,9 +2631,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2167,9 +2655,9 @@ dependencies = [ [[package]] name = "predicates" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "difflib", @@ -2181,15 +2669,15 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] name = "predicates-tree" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ "predicates-core", "termtree", @@ -2258,6 +2746,77 @@ dependencies = [ "yansi", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.11.1", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -2278,6 +2837,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -2307,13 +2872,23 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -2323,7 +2898,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "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]] @@ -2335,22 +2920,60 @@ 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_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[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 = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2459,7 +3082,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2506,22 +3129,43 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" -version = "1.40.0" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" dependencies = [ "arrayvec", "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.6", "rkyv", "serde", "serde_json", + "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2531,16 +3175,29 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2550,12 +3207,33 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scc" version = "2.4.0" @@ -2567,18 +3245,18 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -2589,9 +3267,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", @@ -2626,9 +3304,9 @@ dependencies = [ [[package]] name = "sea-orm" -version = "2.0.0-rc.37" +version = "2.0.0-rc.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b846dc1c7fefbea372c03765ff08307d68894bbad8c73b66176dcd53a3ee131" +checksum = "9b5428ce6a0c8f6b9858df21ad1aa00c2fb94e1c9f344a0436bc855391e5a225" dependencies = [ "async-stream", "async-trait", @@ -2636,7 +3314,7 @@ dependencies = [ "chrono", "derive_more", "futures-util", - "itertools", + "itertools 0.14.0", "log", "mac_address", "ouroboros", @@ -2644,14 +3322,14 @@ dependencies = [ "rust_decimal", "sea-orm-arrow", "sea-orm-macros", - "sea-query 1.0.0-rc.31", + "sea-query 1.0.0-rc.33", "sea-query-sqlx", "sea-schema", "serde", "serde_json", "sqlx", "strum", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -2665,18 +3343,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c2eee8405f16c1f337fe3a83389361caea83c928d14dbd666a480407072c365" dependencies = [ "arrow", - "sea-query 1.0.0-rc.31", - "thiserror", + "sea-query 1.0.0-rc.33", + "thiserror 2.0.18", ] [[package]] name = "sea-orm-macros" -version = "2.0.0-rc.37" +version = "2.0.0-rc.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b449fe660e4d365f335222025df97ae01e670ef7ad788b3c67db9183b6cb0474" +checksum = "ae1374d83dd5b43f14dcc90fc726486c556f4db774b680b12b8c680af76e8233" dependencies = [ "heck 0.5.0", - "itertools", + "itertools 0.14.0", "pluralizer", "proc-macro2", "quote", @@ -2697,9 +3375,9 @@ dependencies = [ [[package]] name = "sea-query" -version = "1.0.0-rc.31" +version = "1.0.0-rc.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58decdaaaf2a698170af2fa1b2e8f7b43a970e7768bf18aebaab113bada46354" +checksum = "b04cdb0135c16e829504e93fbe7880513578d56f07aaea152283526590111828" dependencies = [ "chrono", "inherent", @@ -2722,7 +3400,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2736,16 +3414,16 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "thiserror", + "thiserror 2.0.18", ] [[package]] name = "sea-query-sqlx" -version = "0.8.0-rc.14" +version = "0.8.0-rc.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4377164b09a11bb692dec6966eb0e6908d63d768defef0be689b39e02cf8544" +checksum = "a04aeecfe00614fece56336fd35dc385bb9ffed0c75660695ba925e42a3991ef" dependencies = [ - "sea-query 1.0.0-rc.31", + "sea-query 1.0.0-rc.33", "sqlx", ] @@ -2756,7 +3434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b363dd21c20fe4d1488819cb2bc7f8d4696c62dd9f39554f97639f54d57dd0ab" dependencies = [ "async-trait", - "sea-query 1.0.0-rc.31", + "sea-query 1.0.0-rc.33", "sea-query-sqlx", "sea-schema-derive", "sqlx", @@ -2786,7 +3464,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -2805,9 +3483,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -2869,6 +3547,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -2887,6 +3566,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2960,6 +3659,15 @@ dependencies = [ "digest", ] +[[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 = "shell-words" version = "1.1.1" @@ -2989,7 +3697,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -3048,6 +3756,15 @@ dependencies = [ "der", ] +[[package]] +name = "sqlparser" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a875d8cd437cc8a97e9aeaeea352ec9a19aea99c23e9effb17757291de80b08" +dependencies = [ + "log", +] + [[package]] name = "sqlx" version = "0.8.6" @@ -3079,7 +3796,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink", + "hashlink 0.10.0", "indexmap", "log", "memchr", @@ -3091,7 +3808,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -3146,7 +3863,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.11.1", "byteorder", "bytes", "chrono", @@ -3168,7 +3885,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.6", "rsa", "rust_decimal", "serde", @@ -3177,7 +3894,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -3192,7 +3909,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.11.1", "byteorder", "chrono", "crc", @@ -3210,7 +3927,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.6", "rust_decimal", "serde", "serde_json", @@ -3218,7 +3935,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -3245,7 +3962,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -3264,6 +3981,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "stringprep" version = "0.1.5" @@ -3283,9 +4006,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" [[package]] name = "subtle" @@ -3338,6 +4061,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" version = "3.27.0" @@ -3347,23 +4076,52 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3377,6 +4135,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -3419,19 +4186,29 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3444,9 +4221,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -3461,9 +4238,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3481,20 +4258,48 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", @@ -3504,13 +4309,19 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -3533,6 +4344,26 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" +[[package]] +name = "tower-lsp-server" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0e711655c89181a6bc6a2cc348131fcd9680085f5b06b6af13427a393a6e72" +dependencies = [ + "bytes", + "dashmap", + "futures", + "httparse", + "ls-types", + "memchr", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower", + "tracing", +] + [[package]] name = "tower-service" version = "0.3.3" @@ -3569,13 +4400,105 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree-sitter" +version = "0.26.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "dissimilar", + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", ] [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unarray" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-bidi" @@ -3648,9 +4571,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -3658,6 +4581,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -3672,9 +4601,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.50" +version = "0.1.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e53fae5b7dfbc9c6358a7ed2ab03309bafda94edff6f0ed137aacbbf4673290" +checksum = "f4f217d51b0403454c5ee6b798d853acf93cbe2bc5b318beba6d40035850262b" dependencies = [ "axum", "axum-extra", @@ -3689,9 +4618,9 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.50" +version = "0.1.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7907610cd10b5404764392d01a9ed1cbc9f77e65fbdab3d29d7e725a69fef2a" +checksum = "73fc3c38f267572f59d54aa92c2ed96bacf746abf8e0a67a8d50819cea3ad4fc" dependencies = [ "serde", "serde_json", @@ -3699,9 +4628,9 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.50" +version = "0.1.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8516ec0107927abe8dfa91ea4c7db0c03a63c8b1e5273a6013e3de665b5ff029" +checksum = "7452a3ffa22362fecbb61da1d85dad8c18e09cb8187a37c3272f2693a829a5a6" dependencies = [ "proc-macro2", "quote", @@ -3732,10 +4661,12 @@ dependencies = [ "clap", "colored", "dialoguer", + "dot-writer", "futures", + "insta", "predicates", + "rayon", "rstest", - "schemars", "serde_json", "serde_yaml", "serial_test", @@ -3764,11 +4695,14 @@ dependencies = [ name = "vespertide-core" version = "0.1.61" dependencies = [ + "criterion", + "proptest", "rstest", "schemars", + "sea-orm", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "vespertide-naming", ] @@ -3776,19 +4710,36 @@ dependencies = [ name = "vespertide-exporter" version = "0.1.61" dependencies = [ + "criterion", "insta", + "proptest", + "rayon", "rstest", - "thiserror", + "thiserror 2.0.18", "vespertide-config", "vespertide-core", "vespertide-naming", ] +[[package]] +name = "vespertide-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "libfuzzer-sys", + "serde_json", + "vespertide-core", + "vespertide-lsp", + "vespertide-planner", + "vespertide-query", +] + [[package]] name = "vespertide-loader" version = "0.1.61" dependencies = [ "anyhow", + "rayon", "rstest", "serde_json", "serde_yaml", @@ -3799,14 +4750,44 @@ dependencies = [ "vespertide-planner", ] +[[package]] +name = "vespertide-lsp" +version = "0.1.61" +dependencies = [ + "dashmap", + "insta", + "lsp-textdocument", + "lsp-types", + "proptest", + "rstest", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "tokio", + "tower-lsp-server", + "tracing", + "tracing-subscriber", + "tree-sitter", + "tree-sitter-json", + "tree-sitter-yaml", + "vespertide-config", + "vespertide-core", + "vespertide-loader", + "vespertide-planner", +] + [[package]] name = "vespertide-macro" version = "0.1.61" dependencies = [ + "proc-macro-crate", "proc-macro2", + "quote", "runtime-macros", "syn 2.0.117", "tempfile", + "trybuild", "vespertide-config", "vespertide-core", "vespertide-loader", @@ -3817,14 +4798,22 @@ dependencies = [ [[package]] name = "vespertide-naming" version = "0.1.61" +dependencies = [ + "criterion", + "proptest", +] [[package]] name = "vespertide-planner" version = "0.1.61" dependencies = [ + "criterion", "insta", + "proptest", + "rayon", "rstest", - "thiserror", + "serial_test", + "thiserror 2.0.18", "vespertide-core", "vespertide-naming", ] @@ -3833,10 +4822,17 @@ dependencies = [ name = "vespertide-query" version = "0.1.61" dependencies = [ + "criterion", "insta", + "pg_query", + "proptest", + "rayon", "rstest", + "rusqlite", "sea-query 0.32.7", - "thiserror", + "serial_test", + "sqlparser", + "thiserror 2.0.18", "vespertide-core", "vespertide-naming", "vespertide-planner", @@ -3866,6 +4862,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3874,11 +4880,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -3887,7 +4893,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -3898,22 +4904,23 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3921,9 +4928,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -3934,9 +4941,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -3969,12 +4976,34 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "whoami" version = "1.6.1" @@ -4001,6 +5030,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -4216,9 +5254,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -4232,6 +5270,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -4281,7 +5325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -4313,9 +5357,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -4334,9 +5378,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4345,9 +5389,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -4357,18 +5401,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.41" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96e13bc581734df6250836c59a5f44f3c57db9f9acb9dc8e3eaabdaf6170254d" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.41" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -4377,18 +5421,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -4404,9 +5448,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -4415,9 +5459,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -4426,9 +5470,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 7eb342bf..c8ddc658 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,20 @@ [workspace] -members = ["crates/*", "examples/app"] +members = ["crates/*", "examples/app", "fuzz"] +exclude = ["apps/zed-extension"] +default-members = [ + "crates/vespertide", + "crates/vespertide-cli", + "crates/vespertide-config", + "crates/vespertide-core", + "crates/vespertide-exporter", + "crates/vespertide-loader", + "crates/vespertide-macro", + "crates/vespertide-naming", + "crates/vespertide-planner", + "crates/vespertide-query", + "crates/vespertide-schema-gen", + "examples/app", +] resolver = "2" [workspace.package] @@ -10,9 +25,24 @@ homepage = "https://github.com/dev-five-git/vespertide" documentation = "https://docs.rs/vespertide" [workspace.lints.rust] +unsafe_code = "warn" unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } +[workspace.lints.clippy] +# AGENTS policy: clippy::all + pedantic enforced workspace-wide. +# Pre-1.0 quality bar: every warning must be fixed or have a justified allow. +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +# Pedantic lints that are too noisy for this codebase and accepted as project style: +module_name_repetitions = "allow" # crate-prefixed type names are intentional (vespertide-* convention) +similar_names = "allow" # `from`/`to`, `table`/`tables` etc. are domain-natural +must_use_candidate = "allow" # blanket-applying #[must_use] obscures real intent; case-by-case via audit +missing_errors_doc = "allow" # adding `# Errors` to every Result fn is performative; deferred +missing_panics_doc = "allow" # same as above; we don't panic in production code paths +return_self_not_must_use = "allow" # builder patterns are obvious from context + [workspace.dependencies] +rayon = "1.12" vespertide-core = { path = "crates/vespertide-core", version = "=0.1.61", default-features = false } vespertide-config = { path = "crates/vespertide-config", version = "=0.1.61", default-features = false } vespertide-loader = { path = "crates/vespertide-loader", version = "=0.1.61", default-features = false } @@ -21,6 +51,7 @@ vespertide-naming = { path = "crates/vespertide-naming", version = "=0.1.61" } vespertide-planner = { path = "crates/vespertide-planner", version = "=0.1.61" } vespertide-query = { path = "crates/vespertide-query", version = "=0.1.61" } vespertide-exporter = { path = "crates/vespertide-exporter", version = "=0.1.61" } +vespertide-lsp = { path = "crates/vespertide-lsp", version = "=0.1.61" } [profile.dev] debug = 1 # Line tables only — faster DWARF generation for large codegen output diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..c3c021a0 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,222 @@ +# Release Process + +This document covers the release workflow for the Vespertide ecosystem: the LSP +binary, the VSCode extension, and the Zed extension. + +## Release Channels + +| Component | Tag pattern | CI workflow | Output | +|---|---|---|---| +| `vespertide-lsp` binary | `lsp-v*` | `.github/workflows/lsp-release.yml` | 5 platform tarballs/zips on GH Release | +| VSCode extension | `vscode-v*` | `.github/workflows/vscode-release.yml` | 5 platform-specific VSIX → Marketplace + Open VSX | +| Zed extension | `zed-v*` | Manual PR to `zed-industries/extensions` | Submodule pin in Zed registry | + +## Prerequisites + +Repository secrets required (set in GitHub Settings → Secrets): + +- `VSCE_PAT` — Visual Studio Marketplace personal access token (Azure DevOps) +- `OVSX_PAT` — Open VSX Registry personal access token (optional; gracefully skipped if absent) +- `GITHUB_TOKEN` — auto-provided by Actions, no setup needed + +## Release Flow + +### Step 1 — Cut the LSP binary release + +```bash +# Bump version in crates/vespertide-lsp/Cargo.toml +# git commit + push +git tag lsp-v0.1.0 +git push origin lsp-v0.1.0 +``` + +Triggers `lsp-release.yml`: +- Builds `vespertide-lsp` for 5 platforms (Linux x86_64/aarch64, macOS x86_64/aarch64, Windows x86_64) +- Uploads as `vespertide-lsp-{os}-{arch}.{tar.gz,zip}` to GH Release +- Includes SHA256 alongside each archive + +Verify on the [Releases page](https://github.com/dev-five-git/vespertide/releases): +- 5 archive assets present (each with a `.sha256` companion) +- Release notes auto-generated from commit log + +### Step 2 — Cut the VSCode extension release + +After `lsp-v*` Release is live: + +```bash +# Bump version in apps/vscode-extension/package.json +# git commit + push +git tag vscode-v0.1.0 +git push origin vscode-v0.1.0 +``` + +Triggers `vscode-release.yml`: +- Downloads the corresponding `vespertide-lsp-*` asset from the latest `lsp-v*` Release +- Extracts into `apps/vscode-extension/bin//` +- Builds via `bun + esbuild` +- Packages 5 platform-specific `.vsix` files via `vsce` +- Publishes to VS Code Marketplace (via `VSCE_PAT`) +- Publishes to Open VSX (via `OVSX_PAT`, non-fatal) +- Uploads `.vsix` files to the `vscode-v*` GH Release for manual install fallback + +Pinning a specific LSP tag (rather than latest): + +```bash +gh workflow run vscode-release.yml \ + --field lsp_tag=lsp-v0.1.0 \ + --field vscode_tag=vscode-v0.1.0 +``` + +### Step 3 — Cut the Zed extension release + +Zed publishes via PR to a community-maintained registry, not via automated workflow. + +#### 3a. Bump and tag + +```bash +# Bump version in apps/zed-extension/extension.toml and Cargo.toml +# git commit + push +git tag zed-v0.1.0 +git push origin zed-v0.1.0 +``` + +The `zed-v*` tag itself does not trigger any workflow. It serves as a reference +point for the Zed registry submission. + +#### 3b. Submit PR to zed-industries/extensions + +1. Fork `https://github.com/zed-industries/extensions` (one-time) +2. Clone your fork +3. Add or update the Vespertide submodule: + +```bash +# First time: +git submodule add https://github.com/dev-five-git/vespertide.git \ + extensions/vespertide + +# Subsequent releases (advance the submodule SHA): +cd extensions/vespertide +git fetch origin zed-v0.1.0 +git checkout zed-v0.1.0 +cd ../.. +``` + +Note: the Zed registry expects the submodule root to contain +`apps/zed-extension/`. Some maintainers use a dedicated `zed-vespertide` repo +solely containing the extension. If `zed-industries/extensions` rejects the +nested layout, see [Layout fallback](#layout-fallback) below. + +4. Edit `extensions.toml` in the registry root: + +```toml +[vespertide] +submodule = "extensions/vespertide" +version = "0.1.0" +path = "apps/zed-extension" # path inside the submodule, if supported +``` + +5. Sort + commit: + +```bash +pnpm sort-extensions +git add extensions/vespertide extensions.toml +git commit -m "vespertide: add v0.1.0" +git push +``` + +6. Open PR. The Zed CI will: + - Validate the WIT manifest + - Build the extension to WASM + - Verify the LICENSE file + - Confirm `id` uniqueness + +7. Once merged, the extension is auto-published. Users install via + `zed: extensions` → search "Vespertide" → Install. + +#### Layout fallback + +If `zed-industries/extensions` rejects the nested `apps/zed-extension/` path, +mirror the extension to a dedicated repository: + +```bash +# Create a new public repo, e.g. dev-five-git/zed-vespertide +git subtree split --prefix=apps/zed-extension -b zed-extension-mirror +git push https://github.com/dev-five-git/zed-vespertide.git \ + zed-extension-mirror:main --force +``` + +Then submit `dev-five-git/zed-vespertide` as the submodule instead. + +## Rollback + +### Rollback an LSP release + +```bash +# Delete the bad release + tag +gh release delete lsp-v0.1.0 --yes +git push --delete origin lsp-v0.1.0 +git tag -d lsp-v0.1.0 + +# Cut a corrected release +git tag lsp-v0.1.1 +git push origin lsp-v0.1.1 +``` + +VSCode/Zed users on the bad version will not auto-revert. Tell them to update. + +### Rollback a VSCode release + +```bash +# Mark as deprecated on the Marketplace +bunx vsce unpublish dev-five-git.vespertide@0.1.0 + +# Or, if Marketplace already has a newer version, push a hotfix: +git tag vscode-v0.1.1 +git push origin vscode-v0.1.1 +``` + +Open VSX is harder to unpublish — contact the maintainers. + +### Rollback a Zed release + +Zed extensions cannot be unpublished. Open a follow-up PR to the registry +that bumps the version with a fix or yanks the entry by setting it to a +known-good prior version. + +## Pre-release vs Stable Versioning + +VSCode convention: **odd minor versions are pre-release**, **even minor versions +are stable**. + +- `0.1.x` — pre-release (use `vsce publish --pre-release`) +- `0.2.x` — first stable +- `0.3.x` — pre-release again +- `0.4.x` — stable + +To cut a pre-release: + +```bash +# Tag with -pre suffix to differentiate +git tag vscode-v0.1.0-pre1 +git push origin vscode-v0.1.0-pre1 +``` + +Adjust `vscode-release.yml` to forward `--pre-release` to `vsce publish` if the +tag contains `-pre`. + +## Verification Checklist (per release) + +- [ ] All 5 LSP binaries present on `lsp-v*` GH Release +- [ ] Each binary has its `.sha256` companion file +- [ ] VSCode Marketplace shows the new version (search "vespertide") +- [ ] Open VSX shows the new version (https://open-vsx.org/extension/dev-five-git/vespertide) +- [ ] Zed registry PR merged + extension installable from `zed: extensions` +- [ ] README + CHANGELOG updated to reference the new version +- [ ] No regressions in `cargo test --workspace` on `refactor`/`main` + +## Related Documentation + +- [`apps/vscode-extension/README.md`](apps/vscode-extension/README.md) — extension-specific docs +- [`apps/zed-extension/README.md`](apps/zed-extension/README.md) — Zed-specific docs +- [`docs/PERFORMANCE-AUDIT.md`](docs/PERFORMANCE-AUDIT.md) — perf history +- [`docs/PARALLELIZATION.md`](docs/PARALLELIZATION.md) — concurrency design diff --git a/apps/vscode-extension/.gitignore b/apps/vscode-extension/.gitignore new file mode 100644 index 00000000..e50c5f0d --- /dev/null +++ b/apps/vscode-extension/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +out/ +*.vsix +bin/ diff --git a/apps/vscode-extension/.vscodeignore b/apps/vscode-extension/.vscodeignore new file mode 100644 index 00000000..78725b61 --- /dev/null +++ b/apps/vscode-extension/.vscodeignore @@ -0,0 +1,16 @@ +.vscode/ +.github/ +src/ +out/ +node_modules/ +tsconfig.json +esbuild.config.mjs +.eslintrc* +*.vsix +.gitkeep +.vscode-test.mjs + +!dist/extension.js +!bin/** +!media/** +!language-configuration.json diff --git a/apps/vscode-extension/README.md b/apps/vscode-extension/README.md new file mode 100644 index 00000000..1f450c5d --- /dev/null +++ b/apps/vscode-extension/README.md @@ -0,0 +1,69 @@ +# Vespertide for Visual Studio Code + +> Declarative database schema management — directly in your editor. + +Vespertide brings first-class language support for Vespertide schema files to VS Code: rich diagnostics, hover hints, go-to-definition, smart completion, and — uniquely — **live drift detection** between your declared models and applied migration history. + +

+ Built by DevFive +

+ +--- + +## Features + +- **Diagnostics** — Real-time validation of `models/*.json` and `models/*.yaml` files. Catch invalid column types, broken foreign keys, and ENUM mismatches before you ever run `vespertide diff`. +- **Hover** — Inspect column types, constraints, and ENUM members without leaving your schema file. +- **Go-to-Definition** — Jump from foreign-key references straight to the target table. +- **Completion** — Context-aware suggestions for column types, table names, and constraint kinds. +- **🟣 Drift Detection** — **The killer feature no other schema tool offers.** Vespertide continuously compares your declared models against your migration history and surfaces drift inline — the exact lines that diverge, highlighted as you type. + +--- + +## Installation + +Install **Vespertide** from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=dev-five-git.vespertide) or the [Open VSX Registry](https://open-vsx.org/extension/dev-five-git/vespertide). + +```bash +code --install-extension dev-five-git.vespertide +``` + +The extension ships with the `vespertide-lsp` binary for your platform — no separate install required. + +--- + +## Usage + +Open any project that contains a `models/` directory with `.json`, `.yaml`, or `.yml` schema files, or a `vespertide.json` config at the workspace root. + +The language server activates automatically and starts publishing diagnostics. The Vespertide status bar item (bottom-left) shows the connection state. + +--- + +## Configuration + +| Setting | Default | Description | +| --- | --- | --- | +| `vespertide.serverPath` | `""` | Override path to the `vespertide-lsp` binary. Leave empty to use the bundled binary. | +| `vespertide.logLevel` | `"info"` | Server log level (`off`, `error`, `warn`, `info`, `debug`, `trace`). | +| `vespertide.trace.server` | `"off"` | Trace LSP protocol messages between editor and server. Useful for debugging. | + +--- + +## Commands + +| Command | ID | +| --- | --- | +| Vespertide: Restart Language Server | `vespertide.restartServer` | + +--- + +## Requirements + +- **Visual Studio Code** `1.105.0` or newer + +--- + +## License + +[Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) © DevFive diff --git a/apps/vscode-extension/esbuild.config.mjs b/apps/vscode-extension/esbuild.config.mjs new file mode 100644 index 00000000..78fe9d94 --- /dev/null +++ b/apps/vscode-extension/esbuild.config.mjs @@ -0,0 +1,25 @@ +import * as esbuild from "esbuild"; + +const production = process.argv.includes("--production"); +const watch = process.argv.includes("--watch"); + +const config = { + entryPoints: ["src/extension.ts"], + bundle: true, + format: "cjs", + platform: "node", + outfile: "dist/extension.js", + external: ["vscode"], + minify: production, + sourcemap: !production, + sourcesContent: false, + logLevel: "info", +}; + +if (watch) { + const ctx = await esbuild.context(config); + await ctx.watch(); + console.log("[esbuild] watching..."); +} else { + await esbuild.build(config); +} diff --git a/apps/vscode-extension/language-configuration.json b/apps/vscode-extension/language-configuration.json new file mode 100644 index 00000000..fa3a220a --- /dev/null +++ b/apps/vscode-extension/language-configuration.json @@ -0,0 +1,24 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": ["/*", "*/"] + }, + "brackets": [ + ["{", "}"], + ["[", "]"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "\"", "close": "\"" } + ], + "surroundingPairs": [ + ["{", "}"], + ["[", "]"], + ["\"", "\""] + ], + "indentationRules": { + "increaseIndentPattern": "^.*(\\{[^}]*|\\[[^\\]]*)$", + "decreaseIndentPattern": "^\\s*[}\\]]" + } +} diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json new file mode 100644 index 00000000..7a57b48a --- /dev/null +++ b/apps/vscode-extension/package.json @@ -0,0 +1,119 @@ +{ + "name": "vespertide", + "displayName": "Vespertide", + "description": "Language support for Vespertide schema files — diagnostics, hover, go-to-definition, completion, and drift detection.", + "version": "0.1.0", + "publisher": "dev-five-git", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/dev-five-git/vespertide" + }, + "engines": { "vscode": "^1.105.0" }, + "categories": ["Programming Languages", "Linters"], + "keywords": ["vespertide", "database", "schema", "migration", "lsp"], + + "activationEvents": [ + "workspaceContains:**/models/*.json", + "workspaceContains:**/models/*.yaml", + "workspaceContains:**/models/*.yml", + "workspaceContains:**/*.vespertide.json", + "workspaceContains:**/*.vespertide.yaml", + "workspaceContains:**/*.vespertide.yml", + "workspaceContains:vespertide.json" + ], + + "main": "./dist/extension.js", + + "contributes": { + "languages": [ + { + "id": "vespertide-json", + "aliases": ["Vespertide Model", "Vespertide JSON"], + "filenamePatterns": [ + "**/models/*.json", + "**/*.vespertide.json", + "vespertide.json" + ], + "configuration": "./language-configuration.json" + }, + { + "id": "vespertide-yaml", + "aliases": ["Vespertide Model", "Vespertide YAML"], + "filenamePatterns": [ + "**/models/*.yaml", + "**/models/*.yml", + "**/*.vespertide.yaml", + "**/*.vespertide.yml" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "vespertide-json", + "scopeName": "source.vespertide.json", + "path": "./syntaxes/vespertide-json.tmLanguage.json" + }, + { + "language": "vespertide-yaml", + "scopeName": "source.vespertide.yaml", + "path": "./syntaxes/vespertide-yaml.tmLanguage.json" + } + ], + "configuration": { + "title": "Vespertide", + "properties": { + "vespertide.serverPath": { + "type": "string", + "default": "", + "markdownDescription": "Override path to the `vespertide-lsp` binary. Leave empty to use the bundled binary." + }, + "vespertide.logLevel": { + "type": "string", + "enum": ["off", "error", "warn", "info", "debug", "trace"], + "default": "info" + }, + "vespertide.trace.server": { + "type": "string", + "enum": ["off", "messages", "verbose"], + "default": "off", + "description": "Trace LSP messages between editor and server (debug)." + } + } + }, + "commands": [ + { + "command": "vespertide.restartServer", + "title": "Vespertide: Restart Language Server", + "category": "Vespertide" + } + ] + }, + + "scripts": { + "vscode:prepublish": "node esbuild.config.mjs --production", + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "typecheck": "tsc --noEmit", + "package": "vsce package --no-dependencies", + "publish:vsce": "vsce publish --no-dependencies", + "publish:ovsx": "ovsx publish", + "test": "vscode-test" + }, + + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/vscode": "^1.105.0", + "@vscode/test-cli": "^0.0.12", + "@vscode/vsce": "^3.9.1", + "esbuild": "^0.28.0", + "ovsx": "^0.10.12", + "typescript": "^6.0.3" + }, + + "vsce": { "dependencies": false } +} diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts new file mode 100644 index 00000000..4447711c --- /dev/null +++ b/apps/vscode-extension/src/extension.ts @@ -0,0 +1,169 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, + TransportKind, + RevealOutputChannelOn, +} from "vscode-languageclient/node"; + +let client: LanguageClient | undefined; +let statusBarItem: vscode.StatusBarItem; + +function getPlatformDir(): string { + const arch = os.arch(); + const plat = os.platform(); + const key = `${plat}-${arch}`; + const map: Record = { + "linux-x64": "linux-x64", + "linux-arm64": "linux-arm64", + "darwin-x64": "darwin-x64", + "darwin-arm64": "darwin-arm64", + "win32-x64": "win32-x64", + }; + const dir = map[key]; + if (!dir) throw new Error(`Unsupported platform: ${key}`); + return dir; +} + +function findOnPath(exe: string): string | undefined { + const env = process.env.PATH ?? ""; + const sep = os.platform() === "win32" ? ";" : ":"; + for (const dir of env.split(sep)) { + if (!dir) continue; + const candidate = path.join(dir, exe); + if (fs.existsSync(candidate)) return candidate; + } + return undefined; +} + +function resolveServerBinary(context: vscode.ExtensionContext): string { + const config = vscode.workspace.getConfiguration("vespertide"); + const override = config.get("serverPath"); + if (override && override.trim() !== "") { + if (!fs.existsSync(override)) { + throw new Error(`vespertide.serverPath points to a non-existent file: ${override}`); + } + return override; + } + + const exe = os.platform() === "win32" ? "vespertide-lsp.exe" : "vespertide-lsp"; + const bundled = context.asAbsolutePath(path.join("bin", getPlatformDir(), exe)); + if (fs.existsSync(bundled)) { + return bundled; + } + + // Dev convenience: when the bundled binary is missing (`cargo install` / + // local debug builds), fall back to whatever `vespertide-lsp` exists on + // PATH. This is the same UX Zed offers and removes the need to set + // `vespertide.serverPath` while iterating on the LSP. + const onPath = findOnPath(exe); + if (onPath) { + return onPath; + } + + throw new Error( + `Vespertide LSP binary not found.\n` + + `Looked for bundled: ${bundled}\n` + + `Looked on PATH for: ${exe}\n` + + `Set "vespertide.serverPath", install via \`cargo install vespertide-cli\`, or reinstall the extension.` + ); +} + +function createStatusBarItem(): vscode.StatusBarItem { + const item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + item.text = "$(loading~spin) Vespertide"; + item.tooltip = "Vespertide Language Server"; + item.command = "vespertide.restartServer"; + item.show(); + return item; +} + +async function startClient(context: vscode.ExtensionContext): Promise { + let serverPath: string; + try { + serverPath = resolveServerBinary(context); + } catch (err) { + statusBarItem.text = "$(error) Vespertide: Not Found"; + void vscode.window.showErrorMessage(`Vespertide: ${(err as Error).message}`); + return; + } + + // Surface the binary path so a stale F5 / cached dev-host can be + // diagnosed at a glance. The full path lives in the status bar tooltip + // and is logged so it ends up in both VS Code's output channel and the + // LSP's own file log. + console.log(`[vespertide] launching LSP server from: ${serverPath}`); + + const config = vscode.workspace.getConfiguration("vespertide"); + const logLevel = config.get("logLevel", "info"); + + const serverOptions: ServerOptions = { + run: { + command: serverPath, + args: [], + transport: TransportKind.stdio, + options: { env: { ...process.env, RUST_LOG: `vespertide_lsp=${logLevel}` } }, + }, + debug: { + command: serverPath, + args: [], + transport: TransportKind.stdio, + options: { env: { ...process.env, RUST_LOG: "vespertide_lsp=trace" } }, + }, + }; + + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { scheme: "file", language: "vespertide-json" }, + { scheme: "file", language: "vespertide-yaml" }, + ], + synchronize: { + fileEvents: vscode.workspace.createFileSystemWatcher( + "**/{models,migrations}/*.{json,yaml,yml}" + ), + }, + revealOutputChannelOn: RevealOutputChannelOn.Error, + traceOutputChannel: vscode.window.createOutputChannel("Vespertide LSP Trace"), + }; + + client = new LanguageClient("vespertide", "Vespertide", serverOptions, clientOptions); + + try { + await client.start(); + statusBarItem.text = "$(check) Vespertide"; + statusBarItem.tooltip = `Vespertide LSP (connected)\nBinary: ${serverPath}`; + } catch (err) { + statusBarItem.text = "$(error) Vespertide"; + void vscode.window.showErrorMessage(`Vespertide LSP failed to start: ${err}`); + } +} + +async function stopClient(): Promise { + if (client) { + await client.stop(); + client = undefined; + } +} + +export async function activate(context: vscode.ExtensionContext): Promise { + statusBarItem = createStatusBarItem(); + context.subscriptions.push(statusBarItem); + + context.subscriptions.push( + vscode.commands.registerCommand("vespertide.restartServer", async () => { + statusBarItem.text = "$(loading~spin) Vespertide: Restarting"; + await stopClient(); + await startClient(context); + }) + ); + + await startClient(context); +} + +export async function deactivate(): Promise { + await stopClient(); +} diff --git a/apps/vscode-extension/syntaxes/vespertide-json.tmLanguage.json b/apps/vscode-extension/syntaxes/vespertide-json.tmLanguage.json new file mode 100644 index 00000000..8436fb08 --- /dev/null +++ b/apps/vscode-extension/syntaxes/vespertide-json.tmLanguage.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Vespertide Model", + "scopeName": "source.vespertide.json", + "patterns": [ + { "include": "source.json" } + ] +} diff --git a/apps/vscode-extension/syntaxes/vespertide-yaml.tmLanguage.json b/apps/vscode-extension/syntaxes/vespertide-yaml.tmLanguage.json new file mode 100644 index 00000000..ea26f98b --- /dev/null +++ b/apps/vscode-extension/syntaxes/vespertide-yaml.tmLanguage.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Vespertide Model YAML", + "scopeName": "source.vespertide.yaml", + "patterns": [ + { "include": "source.yaml" } + ] +} diff --git a/apps/vscode-extension/tsconfig.json b/apps/vscode-extension/tsconfig.json new file mode 100644 index 00000000..442ed4e3 --- /dev/null +++ b/apps/vscode-extension/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "lib": ["ES2022"], + "outDir": "out", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "out"] +} diff --git a/apps/zed-extension/.gitignore b/apps/zed-extension/.gitignore new file mode 100644 index 00000000..d506bd92 --- /dev/null +++ b/apps/zed-extension/.gitignore @@ -0,0 +1,3 @@ +/target +*.wasm +.zed-extension/ diff --git a/apps/zed-extension/Cargo.lock b/apps/zed-extension/Cargo.lock new file mode 100644 index 00000000..d9cc0b41 --- /dev/null +++ b/apps/zed-extension/Cargo.lock @@ -0,0 +1,817 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "auditable-serde" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5" +dependencies = [ + "semver", + "serde", + "serde_json", + "topological-sort", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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 = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "wasm-encoder" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d" +dependencies = [ + "anyhow", + "auditable-serde", + "flate2", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "url", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" +dependencies = [ + "bitflags", + "futures", + "once_cell", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zed-vespertide" +version = "0.1.0" +dependencies = [ + "zed_extension_api", +] + +[[package]] +name = "zed_extension_api" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/apps/zed-extension/Cargo.toml b/apps/zed-extension/Cargo.toml new file mode 100644 index 00000000..c868cd0d --- /dev/null +++ b/apps/zed-extension/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "zed-vespertide" +version = "0.1.0" +edition = "2021" +authors = ["DevFive "] +description = "Zed extension for Vespertide schema language support" +license = "Apache-2.0" +repository = "https://github.com/dev-five-git/vespertide" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +zed_extension_api = "0.7" diff --git a/apps/zed-extension/LICENSE b/apps/zed-extension/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/apps/zed-extension/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/apps/zed-extension/README.md b/apps/zed-extension/README.md new file mode 100644 index 00000000..282d82df --- /dev/null +++ b/apps/zed-extension/README.md @@ -0,0 +1,63 @@ +# Vespertide for Zed + +> Language support for Vespertide schema files — by **[DevFive](https://devfive.kr)**. + +Brings first-class editing for Vespertide JSON and YAML schemas to the [Zed editor](https://zed.dev) by wiring up the `vespertide-lsp` language server. + +## Features + +- **Diagnostics** — surfaces schema validation errors and unresolved references inline. +- **Hover** — column-type and constraint documentation on hover. +- **Go to Definition** — jump to `ref_table` / enum definitions across model files. +- **Completion** — context-aware suggestions for column types, references, and enum values. +- **Drift Detection** — flags models that have diverged from the applied migration history. _Unique to Vespertide._ + +## Installation + +### From the Zed extensions registry + +Once published, open the command palette and run: + +``` +zed: extensions +``` + +Search for **Vespertide** and click _Install_. + +### Local development (dev extension) + +Clone this repository and from the Zed command palette run: + +``` +zed: install dev extension +``` + +Point the picker at `apps/zed-extension/`. The extension builds to WebAssembly and downloads the `vespertide-lsp` binary from the latest [GitHub Release](https://github.com/dev-five-git/vespertide/releases) on first use. + +If a `vespertide-lsp` binary is already on your `PATH` (for example via `cargo install vespertide-cli` with the LSP feature, or a local debug build), the extension uses it directly — no download. + +## Configuration + +By default the extension matches files ending in `.vespertide`, `.vespertide.json`, `.vespertide.yaml`, and `.vespertide.yml`. To opt-in additional globs (for example the conventional `models/**/*.json` layout) add this to your Zed `settings.json`: + +```json +{ + "file_types": { + "Vespertide JSON": ["models/**/*.json"], + "Vespertide YAML": ["models/**/*.yaml", "models/**/*.yml"] + } +} +``` + +Per-project overrides go in `.zed/settings.json` at the repository root. + +## Requirements + +- Zed `0.155.0` or later (extension API `0.7`). +- One of: + - A `vespertide-lsp` binary on `PATH`, **or** + - Network access on first launch so the extension can pull the latest release asset from GitHub. + +## License + +Apache-2.0. See [LICENSE](./LICENSE). diff --git a/apps/zed-extension/extension.toml b/apps/zed-extension/extension.toml new file mode 100644 index 00000000..1ae8f67d --- /dev/null +++ b/apps/zed-extension/extension.toml @@ -0,0 +1,11 @@ +id = "vespertide" +name = "Vespertide" +version = "0.1.0" +schema_version = 1 +authors = ["DevFive "] +description = "Language support for Vespertide schema files — diagnostics, hover, go-to-definition, completion, and drift detection" +repository = "https://github.com/dev-five-git/vespertide" + +[language_servers.vespertide-lsp] +name = "Vespertide LSP" +languages = ["Vespertide Model", "Vespertide Model YAML"] diff --git a/apps/zed-extension/languages/vespertide-json/brackets.scm b/apps/zed-extension/languages/vespertide-json/brackets.scm new file mode 100644 index 00000000..9e8c9cd9 --- /dev/null +++ b/apps/zed-extension/languages/vespertide-json/brackets.scm @@ -0,0 +1,3 @@ +("[" @open "]" @close) +("{" @open "}" @close) +("\"" @open "\"" @close) diff --git a/apps/zed-extension/languages/vespertide-json/config.toml b/apps/zed-extension/languages/vespertide-json/config.toml new file mode 100644 index 00000000..6a7fd9ec --- /dev/null +++ b/apps/zed-extension/languages/vespertide-json/config.toml @@ -0,0 +1,7 @@ +name = "Vespertide Model" +grammar = "json" +path_suffixes = ["vespertide", "vespertide.json"] +line_comments = ["// "] +tab_size = 2 +hard_tabs = false +auto_indent_using_last_non_empty_line = true diff --git a/apps/zed-extension/languages/vespertide-json/highlights.scm b/apps/zed-extension/languages/vespertide-json/highlights.scm new file mode 100644 index 00000000..53580c9d --- /dev/null +++ b/apps/zed-extension/languages/vespertide-json/highlights.scm @@ -0,0 +1,51 @@ +; Vespertide Model — JSON highlights. +; +; Strategy: distinguish keys from value strings, and give Vespertide-specific +; structural keys (name/columns/constraints/...) extra emphasis so the schema +; layout reads at a glance instead of looking like flat text. + +; ---------------------------------------------------------------------------- +; Structural keys — strongest emphasis. +((pair + key: (string) @keyword) + (#match? @keyword "^\"(\\$schema|name|columns|constraints|indexes|foreign_key|primary_key)\"$")) + +; Column-modifier keys — softer than structural. +((pair + key: (string) @attribute) + (#match? @attribute "^\"(type|kind|nullable|unique|index|default|comment|length|precision|scale|values|custom_type|ref_table|ref_columns|on_delete|on_update)\"$")) + +; Generic pair keys. +(pair + key: (string) @property) + +; ---------------------------------------------------------------------------- +; Values. +(pair + value: (string) @string) + +(array + (string) @string) + +(number) @number + +(true) @boolean +(false) @boolean +(null) @constant.builtin + +(escape_sequence) @string.escape + +; Punctuation. +[ + "," + ":" +] @punctuation.delimiter + +[ + "[" + "]" + "{" + "}" +] @punctuation.bracket + +(ERROR) @comment.error diff --git a/apps/zed-extension/languages/vespertide-json/indents.scm b/apps/zed-extension/languages/vespertide-json/indents.scm new file mode 100644 index 00000000..87feef7d --- /dev/null +++ b/apps/zed-extension/languages/vespertide-json/indents.scm @@ -0,0 +1,9 @@ +[ + (object) + (array) +] @indent + +[ + "}" + "]" +] @end diff --git a/apps/zed-extension/languages/vespertide-json/outline.scm b/apps/zed-extension/languages/vespertide-json/outline.scm new file mode 100644 index 00000000..a8a23419 --- /dev/null +++ b/apps/zed-extension/languages/vespertide-json/outline.scm @@ -0,0 +1,2 @@ +(pair + key: (string) @name) @item diff --git a/apps/zed-extension/languages/vespertide-yaml/brackets.scm b/apps/zed-extension/languages/vespertide-yaml/brackets.scm new file mode 100644 index 00000000..59cf4520 --- /dev/null +++ b/apps/zed-extension/languages/vespertide-yaml/brackets.scm @@ -0,0 +1,4 @@ +("[" @open "]" @close) +("{" @open "}" @close) +("\"" @open "\"" @close) +("'" @open "'" @close) diff --git a/apps/zed-extension/languages/vespertide-yaml/config.toml b/apps/zed-extension/languages/vespertide-yaml/config.toml new file mode 100644 index 00000000..c42c1f82 --- /dev/null +++ b/apps/zed-extension/languages/vespertide-yaml/config.toml @@ -0,0 +1,5 @@ +name = "Vespertide Model YAML" +grammar = "yaml" +path_suffixes = ["vespertide.yaml", "vespertide.yml"] +tab_size = 2 +hard_tabs = false diff --git a/apps/zed-extension/languages/vespertide-yaml/highlights.scm b/apps/zed-extension/languages/vespertide-yaml/highlights.scm new file mode 100644 index 00000000..6210272a --- /dev/null +++ b/apps/zed-extension/languages/vespertide-yaml/highlights.scm @@ -0,0 +1,66 @@ +; Vespertide Model — YAML highlights. + +; Structural keys — strongest emphasis. +((block_mapping_pair + key: (flow_node) @keyword) + (#match? @keyword "^(\\$schema|name|columns|constraints|indexes|foreign_key|primary_key)$")) +((flow_pair + key: (flow_node) @keyword) + (#match? @keyword "^(\\$schema|name|columns|constraints|indexes|foreign_key|primary_key)$")) + +; Column-modifier keys. +((block_mapping_pair + key: (flow_node) @attribute) + (#match? @attribute "^(type|kind|nullable|unique|index|default|comment|length|precision|scale|values|custom_type|ref_table|ref_columns|on_delete|on_update)$")) +((flow_pair + key: (flow_node) @attribute) + (#match? @attribute "^(type|kind|nullable|unique|index|default|comment|length|precision|scale|values|custom_type|ref_table|ref_columns|on_delete|on_update)$")) + +; Generic keys. +(block_mapping_pair + key: (flow_node) @property) +(flow_pair + key: (flow_node) @property) + +; String values. +[ + (double_quote_scalar) + (single_quote_scalar) + (block_scalar) + (string_scalar) +] @string + +(escape_sequence) @string.escape + +(boolean_scalar) @boolean +(null_scalar) @constant.builtin + +(integer_scalar) @number +(float_scalar) @number + +[ + "," + ":" + "-" + "?" + "|" + ">" +] @punctuation.delimiter + +[ + "[" + "]" + "{" + "}" +] @punctuation.bracket + +(comment) @comment + +(anchor_name) @label +(alias_name) @label + +(tag) @type + +(yaml_directive) @keyword +(tag_directive) @keyword +(reserved_directive) @keyword diff --git a/apps/zed-extension/languages/vespertide-yaml/indents.scm b/apps/zed-extension/languages/vespertide-yaml/indents.scm new file mode 100644 index 00000000..db312ff6 --- /dev/null +++ b/apps/zed-extension/languages/vespertide-yaml/indents.scm @@ -0,0 +1,6 @@ +[ + (block_mapping) + (block_sequence) + (flow_mapping) + (flow_sequence) +] @indent diff --git a/apps/zed-extension/languages/vespertide-yaml/outline.scm b/apps/zed-extension/languages/vespertide-yaml/outline.scm new file mode 100644 index 00000000..2a3270a7 --- /dev/null +++ b/apps/zed-extension/languages/vespertide-yaml/outline.scm @@ -0,0 +1,2 @@ +(block_mapping_pair + key: (flow_node) @name) @item diff --git a/apps/zed-extension/src/lib.rs b/apps/zed-extension/src/lib.rs new file mode 100644 index 00000000..14dd7ca8 --- /dev/null +++ b/apps/zed-extension/src/lib.rs @@ -0,0 +1,158 @@ +use std::fs; +use zed_extension_api::{self as zed, LanguageServerId, Result}; + +struct VespertideExtension { + cached_binary_path: Option, +} + +impl zed::Extension for VespertideExtension { + fn new() -> Self { + Self { + cached_binary_path: None, + } + } + + fn language_server_command( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + // 1. PATH lookup (for `cargo install` users or local dev). + // On Windows, `worktree.which()` does NOT auto-append `.exe`, so try + // platform-appropriate names in order. + let (os, _arch) = zed::current_platform(); + let candidates: &[&str] = match os { + zed::Os::Windows => &["vespertide-lsp.exe", "vespertide-lsp"], + _ => &["vespertide-lsp"], + }; + for name in candidates { + if let Some(path) = worktree.which(name) { + return Ok(zed::Command { + command: path, + args: vec![], + env: Default::default(), + }); + } + } + + // 2. Cached binary from a previous download. + if let Some(path) = &self.cached_binary_path { + if fs::metadata(path).is_ok_and(|m| m.is_file()) { + return Ok(zed::Command { + command: path.clone(), + args: vec![], + env: Default::default(), + }); + } + } + + // 3. Download from GitHub Releases. + let binary_path = self.install_language_server(language_server_id)?; + self.cached_binary_path = Some(binary_path.clone()); + + Ok(zed::Command { + command: binary_path, + args: vec![], + env: Default::default(), + }) + } +} + +impl VespertideExtension { + fn install_language_server(&self, id: &LanguageServerId) -> Result { + zed::set_language_server_installation_status( + id, + &zed::LanguageServerInstallationStatus::CheckingForUpdate, + ); + + let release = zed::latest_github_release( + "dev-five-git/vespertide", + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (os, arch) = zed::current_platform(); + let asset_name = format!( + "vespertide-lsp-{os}-{arch}.tar.gz", + os = match os { + zed::Os::Mac => "darwin", + zed::Os::Linux => "linux", + zed::Os::Windows => "windows", + }, + arch = match arch { + zed::Architecture::Aarch64 => "aarch64", + zed::Architecture::X8664 => "x86_64", + zed::Architecture::X86 => "x86", + } + ); + + let asset = release + .assets + .iter() + .find(|a| a.name == asset_name) + .ok_or_else(|| format!("No asset found for platform: {asset_name}"))?; + + let version_dir = format!("vespertide-lsp-{}", release.version); + let binary_path = format!( + "{version_dir}/vespertide-lsp{ext}", + ext = if matches!(os, zed::Os::Windows) { + ".exe" + } else { + "" + } + ); + + if !fs::metadata(&binary_path).is_ok_and(|m| m.is_file()) { + zed::set_language_server_installation_status( + id, + &zed::LanguageServerInstallationStatus::Downloading, + ); + + zed::download_file( + &asset.download_url, + &version_dir, + zed::DownloadedFileType::GzipTar, + )?; + + zed::make_file_executable(&binary_path)?; + + // Clean up old version directories. + if let Ok(entries) = fs::read_dir(".") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("vespertide-lsp-") && name_str != version_dir { + let _ = fs::remove_dir_all(entry.path()); + } + } + } + } + + zed::set_language_server_installation_status( + id, + &zed::LanguageServerInstallationStatus::None, + ); + + Ok(binary_path) + } +} + +zed::register_extension!(VespertideExtension); + +#[cfg(test)] +mod tests { + #[test] + fn asset_name_format_matches_release_convention() { + // Smoke test the format string; actual matching happens at runtime. + let asset_name = format!("vespertide-lsp-{}-{}.tar.gz", "linux", "x86_64"); + assert_eq!(asset_name, "vespertide-lsp-linux-x86_64.tar.gz"); + } + + #[test] + fn asset_name_format_windows_arm() { + let asset_name = format!("vespertide-lsp-{}-{}.tar.gz", "windows", "aarch64"); + assert_eq!(asset_name, "vespertide-lsp-windows-aarch64.tar.gz"); + } +} diff --git a/bun.lock b/bun.lock index 5f1e32bf..3a9459d6 100644 --- a/bun.lock +++ b/bun.lock @@ -49,10 +49,52 @@ "typescript": "^6.0.3", }, }, + "apps/vscode-extension": { + "name": "vespertide", + "version": "0.1.0", + "dependencies": { + "vscode-languageclient": "^9.0.1", + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/vscode": "^1.105.0", + "@vscode/test-cli": "^0.0.12", + "@vscode/vsce": "^3.9.1", + "esbuild": "^0.28.0", + "ovsx": "^0.10.12", + "typescript": "^6.0.3", + }, + }, }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + "@azu/format-text": ["@azu/format-text@1.0.2", "", {}, "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg=="], + + "@azu/style-format": ["@azu/style-format@1.0.1", "", { "dependencies": { "@azu/format-text": "^1.0.1" } }, "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g=="], + + "@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="], + + "@azure/core-auth": ["@azure/core-auth@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="], + + "@azure/core-client": ["@azure/core-client@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w=="], + + "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.23.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ=="], + + "@azure/core-tracing": ["@azure/core-tracing@1.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ=="], + + "@azure/core-util": ["@azure/core-util@1.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A=="], + + "@azure/identity": ["@azure/identity@4.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^5.1.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw=="], + + "@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="], + + "@azure/msal-browser": ["@azure/msal-browser@5.11.0", "", { "dependencies": { "@azure/msal-common": "16.6.2" } }, "sha512-zkGNYS3TwY8lUpPIafAmsFCYZbgFixY9y/LZB9GUg0IILoHTqpN26j5OrkL1AQThh/YdZsawe4iWXfp85lFVxg=="], + + "@azure/msal-common": ["@azure/msal-common@16.6.2", "", {}, "sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA=="], + + "@azure/msal-node": ["@azure/msal-node@5.2.2", "", { "dependencies": { "@azure/msal-common": "16.6.2", "jsonwebtoken": "^9.0.0" } }, "sha512-toS+2AePxqyzb0YOKttDOOiSl3jrkK9aiqIvpurpis0O34QcIS5gToqrgT39p04Dpxw3YoUU0lxJKTpSFFfA6Q=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -87,6 +129,8 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@devup-api/core": ["@devup-api/core@0.1.17", "", {}, "sha512-6jf6GA6ejJHt8WSL3+zTSeYJoQAIo7H70ldvcHypqPDdenwr5JH18nqLeAh/v2UFHhlWzqU5ZT4nwuPspKl1Gw=="], "@devup-api/fetch": ["@devup-api/fetch@0.1.20", "", { "dependencies": { "@devup-api/core": "^0.1.16" } }, "sha512-Jt6f1NsjyZC2pkoI4xd/3+AhS7bZQ+Z56W0ASyK61bCZ2PXlB+KG1NjZZZQOCsvLL892GiRdgNgab/rbnHws6A=="], @@ -119,8 +163,64 @@ "@devup-ui/webpack-plugin": ["@devup-ui/webpack-plugin@1.0.58", "", { "dependencies": { "@devup-ui/plugin-utils": "^1.0.4", "@devup-ui/wasm": "^1.0.68" } }, "sha512-JrQQd3p0kQ1kASL5E783OsfXLdzh4Aaiugy1901wN7enF7fcKvZthIxDMd3ionLDXym4ZAb+l0QnRFKlMW2Gcg=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -203,6 +303,8 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -219,6 +321,8 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="], "@next/mdx": ["@next/mdx@16.2.4", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-e/3bgla+/oF3vDlndI0eFPa0bnP47HPVA0InsAJi7Jr3DwV8WpEGuOcm/3PdI5/93FfNiBhMVeVHZpm1sFlmJw=="], @@ -239,6 +343,42 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw=="], + "@node-rs/crc32": ["@node-rs/crc32@1.10.6", "", { "optionalDependencies": { "@node-rs/crc32-android-arm-eabi": "1.10.6", "@node-rs/crc32-android-arm64": "1.10.6", "@node-rs/crc32-darwin-arm64": "1.10.6", "@node-rs/crc32-darwin-x64": "1.10.6", "@node-rs/crc32-freebsd-x64": "1.10.6", "@node-rs/crc32-linux-arm-gnueabihf": "1.10.6", "@node-rs/crc32-linux-arm64-gnu": "1.10.6", "@node-rs/crc32-linux-arm64-musl": "1.10.6", "@node-rs/crc32-linux-x64-gnu": "1.10.6", "@node-rs/crc32-linux-x64-musl": "1.10.6", "@node-rs/crc32-wasm32-wasi": "1.10.6", "@node-rs/crc32-win32-arm64-msvc": "1.10.6", "@node-rs/crc32-win32-ia32-msvc": "1.10.6", "@node-rs/crc32-win32-x64-msvc": "1.10.6" } }, "sha512-+llXfqt+UzgoDzT9of5vPQPGqTAVCohU74I9zIBkNo5TH6s2P31DFJOGsJQKN207f0GHnYv5pV3wh3BCY/un/A=="], + + "@node-rs/crc32-android-arm-eabi": ["@node-rs/crc32-android-arm-eabi@1.10.6", "", { "os": "android", "cpu": "arm" }, "sha512-vZAMuJXm3TpWPOkkhxdrofWDv+Q+I2oO7ucLRbXyAPmXFNDhHtBxbO1rk9Qzz+M3eep8ieS4/+jCL1Q0zacNMQ=="], + + "@node-rs/crc32-android-arm64": ["@node-rs/crc32-android-arm64@1.10.6", "", { "os": "android", "cpu": "arm64" }, "sha512-Vl/JbjCinCw/H9gEpZveWCMjxjcEChDcDBM8S4hKay5yyoRCUHJPuKr4sjVDBeOm+1nwU3oOm6Ca8dyblwp4/w=="], + + "@node-rs/crc32-darwin-arm64": ["@node-rs/crc32-darwin-arm64@1.10.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kARYANp5GnmsQiViA5Qu74weYQ3phOHSYQf0G+U5wB3NB5JmBHnZcOc46Ig21tTypWtdv7u63TaltJQE41noyg=="], + + "@node-rs/crc32-darwin-x64": ["@node-rs/crc32-darwin-x64@1.10.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-Q99bevJVMfLTISpkpKBlXgtPUItrvTWKFyiqoKH5IvscZmLV++NH4V13Pa17GTBmv9n18OwzgQY4/SRq6PQNVA=="], + + "@node-rs/crc32-freebsd-x64": ["@node-rs/crc32-freebsd-x64@1.10.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-66hpawbNjrgnS9EDMErta/lpaqOMrL6a6ee+nlI2viduVOmRZWm9Rg9XdGTK/+c4bQLdtC6jOd+Kp4EyGRYkAg=="], + + "@node-rs/crc32-linux-arm-gnueabihf": ["@node-rs/crc32-linux-arm-gnueabihf@1.10.6", "", { "os": "linux", "cpu": "arm" }, "sha512-E8Z0WChH7X6ankbVm8J/Yym19Cq3otx6l4NFPS6JW/cWdjv7iw+Sps2huSug+TBprjbcEA+s4TvEwfDI1KScjg=="], + + "@node-rs/crc32-linux-arm64-gnu": ["@node-rs/crc32-linux-arm64-gnu@1.10.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-LmWcfDbqAvypX0bQjQVPmQGazh4dLiVklkgHxpV4P0TcQ1DT86H/SWpMBMs/ncF8DGuCQ05cNyMv1iddUDugoQ=="], + + "@node-rs/crc32-linux-arm64-musl": ["@node-rs/crc32-linux-arm64-musl@1.10.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-k8ra/bmg0hwRrIEE8JL1p32WfaN9gDlUUpQRWsbxd1WhjqvXea7kKO6K4DwVxyxlPhBS9Gkb5Urq7Y4mXANzaw=="], + + "@node-rs/crc32-linux-x64-gnu": ["@node-rs/crc32-linux-x64-gnu@1.10.6", "", { "os": "linux", "cpu": "x64" }, "sha512-IfjtqcuFK7JrSZ9mlAFhb83xgium30PguvRjIMI45C3FJwu18bnLk1oR619IYb/zetQT82MObgmqfKOtgemEKw=="], + + "@node-rs/crc32-linux-x64-musl": ["@node-rs/crc32-linux-x64-musl@1.10.6", "", { "os": "linux", "cpu": "x64" }, "sha512-LbFYsA5M9pNunOweSt6uhxenYQF94v3bHDAQRPTQ3rnjn+mK6IC7YTAYoBjvoJP8lVzcvk9hRj8wp4Jyh6Y80g=="], + + "@node-rs/crc32-wasm32-wasi": ["@node-rs/crc32-wasm32-wasi@1.10.6", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.5" }, "cpu": "none" }, "sha512-KaejdLgHMPsRaxnM+OG9L9XdWL2TabNx80HLdsCOoX9BVhEkfh39OeahBo8lBmidylKbLGMQoGfIKDjq0YMStw=="], + + "@node-rs/crc32-win32-arm64-msvc": ["@node-rs/crc32-win32-arm64-msvc@1.10.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-x50AXiSxn5Ccn+dCjLf1T7ZpdBiV1Sp5aC+H2ijhJO4alwznvXgWbopPRVhbp2nj0i+Gb6kkDUEyU+508KAdGQ=="], + + "@node-rs/crc32-win32-ia32-msvc": ["@node-rs/crc32-win32-ia32-msvc@1.10.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-DpDxQLaErJF9l36aghe1Mx+cOnYLKYo6qVPqPL9ukJ5rAGLtCdU0C+Zoi3gs9ySm8zmbFgazq/LvmsZYU42aBw=="], + + "@node-rs/crc32-win32-x64-msvc": ["@node-rs/crc32-win32-x64-msvc@1.10.6", "", { "os": "win32", "cpu": "x64" }, "sha512-5B1vXosIIBw1m2Rcnw62IIfH7W9s9f7H7Ma0rRuhT8HR4Xh8QCgw6NJSI2S2MCngsGktYnAhyUvs81b7efTyQw=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@npmcli/config": ["@npmcli/config@8.3.4", "", { "dependencies": { "@npmcli/map-workspaces": "^3.0.2", "@npmcli/package-json": "^5.1.1", "ci-info": "^4.0.0", "ini": "^4.1.2", "nopt": "^7.2.1", "proc-log": "^4.2.0", "semver": "^7.3.5", "walk-up-path": "^3.0.1" } }, "sha512-01rtHedemDNhUXdicU7s+QYz/3JyV5Naj84cvdXGH4mgCdL+agmSYaLF4LUG4vMCLzhBO8YtS0gPpH1FGvbgAw=="], "@npmcli/git": ["@npmcli/git@5.0.8", "", { "dependencies": { "@npmcli/promise-spawn": "^7.0.0", "ini": "^4.1.3", "lru-cache": "^10.0.1", "npm-pick-manifest": "^9.0.0", "proc-log": "^4.0.0", "promise-inflight": "^1.0.1", "promise-retry": "^2.0.1", "semver": "^7.3.5", "which": "^4.0.0" } }, "sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ=="], @@ -293,6 +433,30 @@ "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@secretlint/config-creator": ["@secretlint/config-creator@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2" } }, "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ=="], + + "@secretlint/config-loader": ["@secretlint/config-loader@10.2.2", "", { "dependencies": { "@secretlint/profiler": "^10.2.2", "@secretlint/resolver": "^10.2.2", "@secretlint/types": "^10.2.2", "ajv": "^8.17.1", "debug": "^4.4.1", "rc-config-loader": "^4.1.3" } }, "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ=="], + + "@secretlint/core": ["@secretlint/core@10.2.2", "", { "dependencies": { "@secretlint/profiler": "^10.2.2", "@secretlint/types": "^10.2.2", "debug": "^4.4.1", "structured-source": "^4.0.0" } }, "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw=="], + + "@secretlint/formatter": ["@secretlint/formatter@10.2.2", "", { "dependencies": { "@secretlint/resolver": "^10.2.2", "@secretlint/types": "^10.2.2", "@textlint/linter-formatter": "^15.2.0", "@textlint/module-interop": "^15.2.0", "@textlint/types": "^15.2.0", "chalk": "^5.4.1", "debug": "^4.4.1", "pluralize": "^8.0.0", "strip-ansi": "^7.1.0", "table": "^6.9.0", "terminal-link": "^4.0.0" } }, "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA=="], + + "@secretlint/node": ["@secretlint/node@10.2.2", "", { "dependencies": { "@secretlint/config-loader": "^10.2.2", "@secretlint/core": "^10.2.2", "@secretlint/formatter": "^10.2.2", "@secretlint/profiler": "^10.2.2", "@secretlint/source-creator": "^10.2.2", "@secretlint/types": "^10.2.2", "debug": "^4.4.1", "p-map": "^7.0.3" } }, "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ=="], + + "@secretlint/profiler": ["@secretlint/profiler@10.2.2", "", {}, "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig=="], + + "@secretlint/resolver": ["@secretlint/resolver@10.2.2", "", {}, "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w=="], + + "@secretlint/secretlint-formatter-sarif": ["@secretlint/secretlint-formatter-sarif@10.2.2", "", { "dependencies": { "node-sarif-builder": "^3.2.0" } }, "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ=="], + + "@secretlint/secretlint-rule-no-dotenv": ["@secretlint/secretlint-rule-no-dotenv@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2" } }, "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg=="], + + "@secretlint/secretlint-rule-preset-recommend": ["@secretlint/secretlint-rule-preset-recommend@10.2.2", "", {}, "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA=="], + + "@secretlint/source-creator": ["@secretlint/source-creator@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2", "istextorbinary": "^9.5.0" } }, "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw=="], + + "@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], + "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], @@ -309,6 +473,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.100.5", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": "^5.4.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-WKt+xyxvMQkUL4sqMQ8l3gzCplNi9HedVQN32WmBJYKITJ9a5r3H5cpICp8y96V8ZL5rZH0EZRgpO6sy8fAgrQ=="], @@ -325,6 +491,18 @@ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@textlint/ast-node-types": ["@textlint/ast-node-types@15.7.1", "", {}, "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA=="], + + "@textlint/linter-formatter": ["@textlint/linter-formatter@15.7.1", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.7.1", "@textlint/resolver": "15.7.1", "@textlint/types": "15.7.1", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.18.1", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA=="], + + "@textlint/module-interop": ["@textlint/module-interop@15.7.1", "", {}, "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g=="], + + "@textlint/resolver": ["@textlint/resolver@15.7.1", "", {}, "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g=="], + + "@textlint/types": ["@textlint/types@15.7.1", "", { "dependencies": { "@textlint/ast-node-types": "15.7.1" } }, "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], @@ -343,24 +521,34 @@ "@types/is-empty": ["@types/is-empty@1.2.3", "", {}, "sha512-4J1l5d79hoIvsrKh5VUKVRA1aIdsOb10Hu5j3J2VfP/msDnfTdGPmNp2E1Wg+vs97Bktzo+MZePFFXSGoykYJw=="], + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + "@types/mocha": ["@types/mocha@10.0.10", "", {}, "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], + "@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="], + "@types/supports-color": ["@types/supports-color@8.1.3", "", {}, "sha512-Hy6UMpxhE3j1tLpl27exp1XqHD7n8chAiNPzWfz16LPZoMMoSc4dzLl6w9qijkEb/r5O1ozdu1CWGA2L83ZeZg=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/vscode": ["@types/vscode@1.120.0", "", {}, "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -385,20 +573,52 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="], + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.5", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vscode/test-cli": ["@vscode/test-cli@0.0.12", "", { "dependencies": { "@types/mocha": "^10.0.10", "c8": "^10.1.3", "chokidar": "^3.6.0", "enhanced-resolve": "^5.18.3", "glob": "^10.3.10", "minimatch": "^9.0.3", "mocha": "^11.7.4", "supports-color": "^10.2.2", "yargs": "^17.7.2" }, "bin": { "vscode-test": "out/bin.mjs" } }, "sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ=="], + + "@vscode/vsce": ["@vscode/vsce@3.9.1", "", { "dependencies": { "@azure/identity": "^4.1.0", "@secretlint/node": "^10.1.2", "@secretlint/secretlint-formatter-sarif": "^10.1.2", "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", "chalk": "^4.1.2", "cheerio": "^1.0.0-rc.9", "cockatiel": "^3.1.2", "commander": "^12.1.0", "form-data": "^4.0.0", "glob": "^11.0.0", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", "markdown-it": "^14.1.0", "mime": "^1.3.4", "minimatch": "^3.0.3", "parse-semver": "^1.1.1", "read": "^1.0.7", "secretlint": "^10.1.2", "semver": "^7.5.2", "tmp": "^0.2.3", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^3.2.1", "yazl": "^2.2.2" }, "optionalDependencies": { "keytar": "^7.7.0" }, "bin": { "vsce": "vsce" } }, "sha512-MPn5p+DoudI+3GfJSpAZZraE1lgLv0LcwbH3+xy7RgEhty3UIkmUMUA+5jPTDaxXae00AnX5u77FxGM8FhfKKA=="], + + "@vscode/vsce-sign": ["@vscode/vsce-sign@2.0.9", "", { "optionalDependencies": { "@vscode/vsce-sign-alpine-arm64": "2.0.6", "@vscode/vsce-sign-alpine-x64": "2.0.6", "@vscode/vsce-sign-darwin-arm64": "2.0.6", "@vscode/vsce-sign-darwin-x64": "2.0.6", "@vscode/vsce-sign-linux-arm": "2.0.6", "@vscode/vsce-sign-linux-arm64": "2.0.6", "@vscode/vsce-sign-linux-x64": "2.0.6", "@vscode/vsce-sign-win32-arm64": "2.0.6", "@vscode/vsce-sign-win32-x64": "2.0.6" } }, "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g=="], + + "@vscode/vsce-sign-alpine-arm64": ["@vscode/vsce-sign-alpine-arm64@2.0.6", "", { "os": "none", "cpu": "arm64" }, "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q=="], + + "@vscode/vsce-sign-alpine-x64": ["@vscode/vsce-sign-alpine-x64@2.0.6", "", { "os": "none", "cpu": "x64" }, "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w=="], + + "@vscode/vsce-sign-darwin-arm64": ["@vscode/vsce-sign-darwin-arm64@2.0.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ=="], + + "@vscode/vsce-sign-darwin-x64": ["@vscode/vsce-sign-darwin-x64@2.0.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw=="], + + "@vscode/vsce-sign-linux-arm": ["@vscode/vsce-sign-linux-arm@2.0.6", "", { "os": "linux", "cpu": "arm" }, "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA=="], + + "@vscode/vsce-sign-linux-arm64": ["@vscode/vsce-sign-linux-arm64@2.0.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA=="], + + "@vscode/vsce-sign-linux-x64": ["@vscode/vsce-sign-linux-x64@2.0.6", "", { "os": "linux", "cpu": "x64" }, "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA=="], + + "@vscode/vsce-sign-win32-arm64": ["@vscode/vsce-sign-win32-arm64@2.0.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg=="], + + "@vscode/vsce-sign-win32-x64": ["@vscode/vsce-sign-win32-x64@2.0.6", "", { "os": "win32", "cpu": "x64" }, "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ=="], + "abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], @@ -417,30 +637,62 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "azure-devops-node-api": ["azure-devops-node-api@12.5.0", "", { "dependencies": { "tunnel": "0.0.6", "typed-rest-client": "^1.8.4" } }, "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og=="], + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "boundary": ["boundary@2.0.0", "", {}, "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA=="], + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browser-stdout": ["browser-stdout@1.3.1", "", {}, "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-test-env-dom": ["bun-test-env-dom@1.0.3", "", { "dependencies": { "@happy-dom/global-registrator": ">=20.0", "@testing-library/dom": ">=10.4", "@testing-library/jest-dom": ">=6.9", "@testing-library/react": ">=16.3", "@testing-library/user-event": ">=14.6" } }, "sha512-Ozepvzk1s/bJSxABEjbI+Ztnm3CN1b0vRSvf0Qa0rTnuO7S0wKN2cUTsXdyIJuqE6OnlAhyoe2NGqkdeemz5/Q=="], "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "c8": ["c8@10.1.3", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", "find-up": "^5.0.0", "foreground-child": "^3.1.1", "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", "test-exclude": "^7.0.1", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "monocart-coverage-reports": "^2" }, "optionalPeers": ["monocart-coverage-reports"], "bin": { "c8": "bin/c8.js" } }, "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -449,6 +701,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -463,20 +717,36 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cockatiel": ["cockatiel@3.2.1", "", {}, "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q=="], + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], @@ -485,6 +755,10 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -499,33 +773,69 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@4.0.0", "", {}, "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "editions": ["editions@6.22.0", "", { "dependencies": { "version-range": "^4.15.0" } }, "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], @@ -551,6 +861,8 @@ "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], + "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -603,30 +915,52 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="], + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], @@ -637,12 +971,16 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -653,8 +991,12 @@ "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "globby": ["globby@14.1.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^2.1.0", "fast-glob": "^3.3.3", "ignore": "^7.0.3", "path-type": "^6.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.3.0" } }, "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], @@ -693,16 +1035,30 @@ "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -713,9 +1069,11 @@ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -733,10 +1091,14 @@ "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + "is-ci": ["is-ci@2.0.0", "", { "dependencies": { "ci-info": "^2.0.0" }, "bin": { "is-ci": "bin.js" } }, "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w=="], + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], @@ -745,6 +1107,8 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-empty": ["is-empty@1.2.0", "", {}, "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -759,12 +1123,20 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-it-type": ["is-it-type@5.1.3", "", { "dependencies": { "globalthis": "^1.0.2" } }, "sha512-AX2uU0HW+TxagTgQXOJY7+2fbFHemC7YFBwN1XqD8qQMKdtfbOC8OC3fUb4s5NU59a3662Dzwto8tWDdZYRXxg=="], + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -779,16 +1151,28 @@ "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + + "istextorbinary": ["istextorbinary@9.5.0", "", { "dependencies": { "binaryextensions": "^6.11.0", "editions": "^6.21.0", "textextensions": "^6.11.0" } }, "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw=="], + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], @@ -809,32 +1193,72 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "keytar": ["keytar@7.9.0", "", { "dependencies": { "node-addon-api": "^4.3.0", "prebuild-install": "^7.0.1" } }, "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "landing": ["landing@workspace:apps/landing"], + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lines-and-columns": ["lines-and-columns@2.0.4", "", {}, "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A=="], + "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "load-plugin": ["load-plugin@6.0.3", "", { "dependencies": { "@npmcli/config": "^8.0.0", "import-meta-resolve": "^4.0.0" } }, "sha512-kc0X2FEUZr145odl68frm+lMJuQ23+rTXYmR6TImqPtbpmXC4vVXbWKDQ9IzndA0HfyQamWfKLhzsqGSTxE63w=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + + "lodash.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="], + + "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], @@ -855,6 +1279,10 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -911,28 +1339,56 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "mocha": ["mocha@11.7.5", "", { "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", "debug": "^4.3.5", "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", "minimatch": "^9.0.5", "ms": "^2.1.3", "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", "workerpool": "^9.2.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "bin": { "mocha": "bin/mocha.js", "_mocha": "bin/_mocha" } }, "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "next": ["next@16.2.4", "", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="], + "node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="], + + "node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], + "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], + "node-sarif-builder": ["node-sarif-builder@3.4.0", "", { "dependencies": { "@types/sarif": "^2.1.7", "fs-extra": "^11.1.1" } }, "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg=="], + "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "npm-install-checks": ["npm-install-checks@6.3.0", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw=="], "npm-normalize-package-bin": ["npm-normalize-package-bin@3.0.1", "", {}, "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ=="], @@ -941,6 +1397,8 @@ "npm-pick-manifest": ["npm-pick-manifest@9.1.0", "", { "dependencies": { "npm-install-checks": "^6.0.0", "npm-normalize-package-bin": "^3.0.0", "npm-package-arg": "^11.0.0", "semver": "^7.3.5" } }, "sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA=="], + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -955,12 +1413,18 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "ovsx": ["ovsx@0.10.12", "", { "dependencies": { "@vscode/vsce": "^3.7.1", "commander": "^6.2.1", "follow-redirects": "^1.16.0", "is-ci": "^2.0.0", "leven": "^3.1.0", "semver": "^7.6.0", "tmp": "^0.2.3", "yauzl-promise": "^4.0.0" }, "bin": { "ovsx": "bin/ovsx" } }, "sha512-WwMj1iQDvCk02029oxPnkFXsPrHZ+WzmoNW5pJ8JGepHtL30i2JE4s3C3wqzQqj6a35vx2hp0gV3TdfefGmvMg=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "oxlint": ["oxlint@1.61.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.61.0", "@oxlint/binding-android-arm64": "1.61.0", "@oxlint/binding-darwin-arm64": "1.61.0", "@oxlint/binding-darwin-x64": "1.61.0", "@oxlint/binding-freebsd-x64": "1.61.0", "@oxlint/binding-linux-arm-gnueabihf": "1.61.0", "@oxlint/binding-linux-arm-musleabihf": "1.61.0", "@oxlint/binding-linux-arm64-gnu": "1.61.0", "@oxlint/binding-linux-arm64-musl": "1.61.0", "@oxlint/binding-linux-ppc64-gnu": "1.61.0", "@oxlint/binding-linux-riscv64-gnu": "1.61.0", "@oxlint/binding-linux-riscv64-musl": "1.61.0", "@oxlint/binding-linux-s390x-gnu": "1.61.0", "@oxlint/binding-linux-x64-gnu": "1.61.0", "@oxlint/binding-linux-x64-musl": "1.61.0", "@oxlint/binding-openharmony-arm64": "1.61.0", "@oxlint/binding-win32-arm64-msvc": "1.61.0", "@oxlint/binding-win32-ia32-msvc": "1.61.0", "@oxlint/binding-win32-x64-msvc": "1.61.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-ZC0ALuhDZ6ivOFG+sy0D0pEDN49EvsId98zVlmYdkcXHsEM14m/qTNUEsUpiFiCVbpIxYtVBmmLE87nsbUHohQ=="], @@ -969,18 +1433,26 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - "parse-json": ["parse-json@7.1.1", "", { "dependencies": { "@babel/code-frame": "^7.21.4", "error-ex": "^1.3.2", "json-parse-even-better-errors": "^3.0.0", "lines-and-columns": "^2.0.3", "type-fest": "^3.8.0" } }, "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw=="], + "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], "parse-numeric-range": ["parse-numeric-range@1.3.0", "", {}, "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ=="], + "parse-semver": ["parse-semver@1.1.1", "", { "dependencies": { "semver": "^5.1.0" } }, "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -989,14 +1461,22 @@ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "path-type": ["path-type@6.0.0", "", {}, "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ=="], + + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], @@ -1015,18 +1495,38 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "rc-config-loader": ["rc-config-loader@4.1.4", "", { "dependencies": { "debug": "^4.4.3", "js-yaml": "^4.1.1", "json5": "^2.2.3", "require-from-string": "^2.0.2" } }, "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "read": ["read@1.0.7", "", { "dependencies": { "mute-stream": "~0.0.4" } }, "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ=="], + "read-package-json-fast": ["read-package-json-fast@3.0.2", "", { "dependencies": { "json-parse-even-better-errors": "^3.0.0", "npm-normalize-package-bin": "^3.0.0" } }, "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw=="], + "read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], @@ -1067,12 +1567,22 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -1081,9 +1591,17 @@ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "secretlint": ["secretlint@10.2.2", "", { "dependencies": { "@secretlint/config-creator": "^10.2.2", "@secretlint/formatter": "^10.2.2", "@secretlint/node": "^10.2.2", "@secretlint/profiler": "^10.2.2", "debug": "^4.4.1", "globby": "^14.1.0", "read-pkg": "^9.0.1" }, "bin": "./bin/secretlint.js" }, "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -1109,6 +1627,16 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "simple-invariant": ["simple-invariant@2.0.1", "", {}, "sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg=="], + + "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + + "slice-ansi": ["slice-ansi@4.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1125,7 +1653,7 @@ "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - "string-width": ["string-width@6.1.0", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^10.2.1", "strip-ansi": "^7.0.1" } }, "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1143,7 +1671,7 @@ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1151,20 +1679,44 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "structured-source": ["structured-source@4.0.0", "", { "dependencies": { "boundary": "^2.0.0" } }, "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], + "table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "terminal-link": ["terminal-link@4.0.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "supports-hyperlinks": "^3.2.0" } }, "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA=="], + + "test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="], + + "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + + "textextensions": ["textextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -1173,9 +1725,13 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -1185,16 +1741,26 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + "typed-rest-client": ["typed-rest-client@1.8.11", "", { "dependencies": { "qs": "^6.9.1", "tunnel": "0.0.6", "underscore": "^1.12.1" } }, "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA=="], + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "typescript-eslint": ["typescript-eslint@8.59.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], + + "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], "unified-engine": ["unified-engine@11.2.2", "", { "dependencies": { "@types/concat-stream": "^2.0.0", "@types/debug": "^4.0.0", "@types/is-empty": "^1.0.0", "@types/node": "^22.0.0", "@types/unist": "^3.0.0", "concat-stream": "^2.0.0", "debug": "^4.0.0", "extend": "^3.0.0", "glob": "^10.0.0", "ignore": "^6.0.0", "is-empty": "^1.0.0", "is-plain-obj": "^4.0.0", "load-plugin": "^6.0.0", "parse-json": "^7.0.0", "trough": "^2.0.0", "unist-util-inspect": "^8.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0", "vfile-reporter": "^8.0.0", "vfile-statistics": "^3.0.0", "yaml": "^2.0.0" } }, "sha512-15g/gWE7qQl9tQ3nAEbMd5h9HV1EACtFs6N9xaRBZICoCwnNGbal1kOs++ICf4aiTdItZxU2s/kYWhW7htlqJg=="], @@ -1213,16 +1779,26 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], + "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], + "version-range": ["version-range@4.15.0", "", {}, "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg=="], + + "vespertide": ["vespertide@workspace:apps/vscode-extension"], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], @@ -1235,11 +1811,21 @@ "vfile-statistics": ["vfile-statistics@3.0.0", "", { "dependencies": { "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-/qlwqwWBWFOmpXujL/20P+Iuydil0rZZNglR+VNm6J0gpLHwuVM5s7g2TfVoswbXjZ4HuIhLMySEyIw5i7/D8w=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageclient": ["vscode-languageclient@9.0.1", "", { "dependencies": { "minimatch": "^5.1.0", "semver": "^7.3.7", "vscode-languageserver-protocol": "3.17.5" } }, "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + "walk-up-path": ["walk-up-path@3.0.1", "", {}, "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], - "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1253,16 +1839,40 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "workerpool": ["workerpool@9.3.4", "", {}, "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yargs-unparser": ["yargs-unparser@2.0.0", "", { "dependencies": { "camelcase": "^6.0.0", "decamelize": "^4.0.0", "flat": "^5.0.2", "is-plain-obj": "^2.1.0" } }, "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA=="], + + "yauzl": ["yauzl@3.3.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "pend": "~1.2.0" } }, "sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ=="], + + "yauzl-promise": ["yauzl-promise@4.0.0", "", { "dependencies": { "@node-rs/crc32": "^1.7.0", "is-it-type": "^5.1.2", "simple-invariant": "^2.0.1" } }, "sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA=="], + + "yazl": ["yazl@2.5.1", "", { "dependencies": { "buffer-crc32": "~0.2.3" } }, "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -1271,52 +1881,100 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - "@npmcli/config/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "@npmcli/git/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@npmcli/config/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], - "@npmcli/git/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/config/ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], + + "@npmcli/git/ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], + + "@npmcli/git/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@npmcli/git/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "@npmcli/map-workspaces/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "@npmcli/package-json/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/package-json/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "@secretlint/config-loader/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "@secretlint/formatter/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "@secretlint/formatter/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "@textlint/linter-formatter/pluralize": ["pluralize@2.0.0", "", {}, "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@vscode/test-cli/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "@vscode/vsce/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "eslint-plugin-devup/@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], "eslint-plugin-devup/eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="], + "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "fdir/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "globby/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "happy-dom/whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "mocha/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "normalize-package-data/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "mocha/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "npm-install-checks/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "mocha/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "npm-package-arg/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "npm-pick-manifest/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + + "npm-package-arg/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + + "ovsx/commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse-semver/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1325,38 +1983,64 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], - "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "table/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "test-exclude/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "unified-engine/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], "unified-engine/ignore": ["ignore@6.0.2", "", {}, "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A=="], - "vfile-reporter/supports-color": ["supports-color@9.4.0", "", {}, "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw=="], + "unified-engine/parse-json": ["parse-json@7.1.1", "", { "dependencies": { "@babel/code-frame": "^7.21.4", "error-ex": "^1.3.2", "json-parse-even-better-errors": "^3.0.0", "lines-and-columns": "^2.0.3", "type-fest": "^3.8.0" } }, "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw=="], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "vespertide/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], - "wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "vfile-reporter/string-width": ["string-width@6.1.0", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^10.2.1", "strip-ansi": "^7.0.1" } }, "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ=="], - "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "vfile-reporter/supports-color": ["supports-color@9.4.0", "", {}, "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw=="], + + "vscode-languageclient/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + + "yargs-unparser/is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="], - "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@npmcli/git/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "@npmcli/package-json/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "@secretlint/config-loader/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@secretlint/formatter/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@vscode/test-cli/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "@vscode/vsce/glob/jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + + "@vscode/vsce/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@vscode/vsce/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "eslint-plugin-devup/eslint/@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], "eslint-plugin-devup/eslint/@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], @@ -1375,18 +2059,48 @@ "glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "mocha/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "mocha/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "npm-package-arg/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "table/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "test-exclude/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "unified-engine/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "unified-engine/parse-json/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], + + "vespertide/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "vfile-reporter/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "vfile-reporter/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "vscode-languageclient/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@vscode/vsce/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], + + "@vscode/vsce/glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "@vscode/vsce/glob/path-scurry/lru-cache": ["lru-cache@11.5.0", "", {}, "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA=="], + "eslint-plugin-devup/eslint/@eslint/config-array/@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], "eslint-plugin-devup/eslint/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "vfile-reporter/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@vscode/vsce/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "eslint-plugin-devup/eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/crates/vespertide-cli/AGENTS.md b/crates/vespertide-cli/AGENTS.md index b46b6b4d..778a065b 100644 --- a/crates/vespertide-cli/AGENTS.md +++ b/crates/vespertide-cli/AGENTS.md @@ -45,8 +45,12 @@ src/ ## NOTES -- **revision.rs** (1100 lines): Most complex - handles interactive `--fill-with` prompts for NOT NULL columns without defaults +- **revision.rs** (3064 lines, scheduled for split per 1000-line rule): Most complex - handles interactive `--fill-with` prompts for NOT NULL columns without defaults - **export.rs**: Generates `mod.rs` chain for SeaORM exports; Python ORMs skip this - All commands use `load_config()`, `load_models()`, `load_migrations()` from `vespertide_loader` +- YAML and JSON are both fully supported for models and migrations; `new -f yaml` creates YAML templates. +- Prefer typed `MigrationAction` enums; `RawSql` exists as a documented emergency escape hatch, but is not recommended for normal use. - Tests use `serial_test::serial` with `CwdGuard` for directory isolation - Schema URLs default to GitHub raw; override via `VESP_SCHEMA_BASE_URL` env var +- Every `.rs` file must stay ≤ 1000 lines (CI enforced). +- Workspace lints warn on unsafe code and Clippy all: `unsafe_code = "warn"`, `clippy::all = { level = "warn", priority = -1 }`. diff --git a/crates/vespertide-cli/Cargo.toml b/crates/vespertide-cli/Cargo.toml index d7383fdc..4191fde4 100644 --- a/crates/vespertide-cli/Cargo.toml +++ b/crates/vespertide-cli/Cargo.toml @@ -7,9 +7,13 @@ repository.workspace = true homepage.workspace = true documentation.workspace = true description = "CLI command for vespertide (model template, diff, SQL, revision, status, log)" +keywords = ["database", "migration", "schema", "orm", "sql"] +categories = ["database"] +readme = "../../README.md" publish = true [dependencies] +rayon = { workspace = true } anyhow = "1" clap = { version = "4", features = ["derive"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } @@ -18,10 +22,10 @@ dialoguer = "0.12" uuid = { version = "1", features = ["v4"] } serde_json = "1" serde_yaml = "0.9" -schemars = "1.2" tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs"] } futures = "0.3" async-recursion = "1" +dot-writer = "0.1.4" vespertide-config = { workspace = true, features = ["cli", "schema"] } vespertide-core = { workspace = true, features = ["schema"] } vespertide-loader = { workspace = true } @@ -35,10 +39,11 @@ serial_test = "3" rstest = "0.26" assert_cmd = "2" predicates = "3" +insta = "1.47" [[bin]] name = "vespertide" path = "src/main.rs" -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } +[lints] +workspace = true diff --git a/crates/vespertide-cli/src/commands/diff.rs b/crates/vespertide-cli/src/commands/diff.rs index dc2ca19e..6ae741fe 100644 --- a/crates/vespertide-cli/src/commands/diff.rs +++ b/crates/vespertide-cli/src/commands/diff.rs @@ -11,7 +11,7 @@ pub async fn cmd_diff() -> Result<()> { let applied_plans = load_migrations(&config)?; let plan = plan_next_migration(¤t_models, &applied_plans) - .map_err(|e| anyhow::anyhow!("planning error: {}", e))?; + .map_err(|e| anyhow::anyhow!("planning error: {e}"))?; if plan.actions.is_empty() { println!( @@ -39,98 +39,97 @@ pub async fn cmd_diff() -> Result<()> { Ok(()) } +#[expect( + clippy::too_many_lines, + reason = "one display arm per migration action keeps output obvious" +)] fn format_action(action: &MigrationAction) -> String { + let table = action.table_name().map(Colorize::bright_cyan); match action { - MigrationAction::CreateTable { table, .. } => { + MigrationAction::CreateTable { .. } => { format!( "{} {}", "Create table:".bright_green(), - table.bright_cyan().bold() + table.expect("CreateTable has a table").bold() ) } - MigrationAction::DeleteTable { table } => { + MigrationAction::DeleteTable { .. } => { format!( "{} {}", "Delete table:".bright_red(), - table.bright_cyan().bold() + table.expect("DeleteTable has a table").bold() ) } - MigrationAction::AddColumn { table, column, .. } => { + MigrationAction::AddColumn { column, .. } => { format!( "{} {}.{}", "Add column:".bright_green(), - table.bright_cyan(), + table.expect("AddColumn has a table"), column.name.bright_cyan().bold() ) } - MigrationAction::RenameColumn { table, from, to } => { + MigrationAction::RenameColumn { from, to, .. } => { format!( "{} {}.{} {} {}", "Rename column:".bright_yellow(), - table.bright_cyan(), + table.expect("RenameColumn has a table"), from.bright_white(), "->".bright_white(), to.bright_cyan().bold() ) } - MigrationAction::DeleteColumn { table, column } => { + MigrationAction::DeleteColumn { column, .. } => { format!( "{} {}.{}", "Delete column:".bright_red(), - table.bright_cyan(), + table.expect("DeleteColumn has a table"), column.bright_cyan().bold() ) } MigrationAction::ModifyColumnType { - table, - column, - new_type, - .. + column, new_type, .. } => { format!( "{} {}.{} {} {}", "Modify column type:".bright_yellow(), - table.bright_cyan(), + table.expect("ModifyColumnType has a table"), column.bright_cyan().bold(), "->".bright_white(), new_type.to_display_string().bright_cyan().bold() ) } MigrationAction::ModifyColumnNullable { - table, - column, - nullable, - .. + column, nullable, .. } => { let nullability = if *nullable { "NULL" } else { "NOT NULL" }; format!( "{} {}.{} {} {}", "Modify column nullability:".bright_yellow(), - table.bright_cyan(), + table.expect("ModifyColumnNullable has a table"), column.bright_cyan().bold(), "->".bright_white(), nullability.bright_cyan().bold() ) } MigrationAction::ModifyColumnDefault { - table, column, new_default, + .. } => { let default_display = new_default.as_deref().unwrap_or("(none)"); format!( "{} {}.{} {} {}", "Modify column default:".bright_yellow(), - table.bright_cyan(), + table.expect("ModifyColumnDefault has a table"), column.bright_cyan().bold(), "->".bright_white(), default_display.bright_cyan().bold() ) } MigrationAction::ModifyColumnComment { - table, column, new_comment, + .. } => { let comment_display = new_comment.as_deref().unwrap_or("(none)"); let truncated = if comment_display.chars().count() > 30 { @@ -144,7 +143,7 @@ fn format_action(action: &MigrationAction) -> String { format!( "{} {}.{} {} '{}'", "Modify column comment:".bright_yellow(), - table.bright_cyan(), + table.expect("ModifyColumnComment has a table"), column.bright_cyan().bold(), "->".bright_white(), truncated.bright_cyan().bold() @@ -166,27 +165,25 @@ fn format_action(action: &MigrationAction) -> String { sql.bright_cyan() ) } - MigrationAction::AddConstraint { table, constraint } => { + MigrationAction::AddConstraint { constraint, .. } => { format!( "{} {} {} {}", "Add constraint:".bright_green(), format_constraint_type(constraint).bright_cyan().bold(), "on".bright_white(), - table.bright_cyan() + table.expect("AddConstraint has a table") ) } - MigrationAction::RemoveConstraint { table, constraint } => { + MigrationAction::RemoveConstraint { constraint, .. } => { format!( "{} {} {} {}", "Remove constraint:".bright_red(), format_constraint_type(constraint).bright_cyan().bold(), "from".bright_white(), - table.bright_cyan() + table.expect("RemoveConstraint has a table") ) } - MigrationAction::ReplaceConstraint { - table, from, to, .. - } => { + MigrationAction::ReplaceConstraint { from, to, .. } => { format!( "{} {} {} {} {} {}", "Replace constraint:".bright_yellow(), @@ -194,9 +191,10 @@ fn format_action(action: &MigrationAction) -> String { "->".bright_white(), format_constraint_type(to).bright_cyan().bold(), "on".bright_white(), - table.bright_cyan() + table.expect("ReplaceConstraint has a table") ) } + _ => unreachable!("MigrationAction is #[non_exhaustive]; all variants are matched above"), } } @@ -225,7 +223,7 @@ fn format_constraint_type(constraint: &vespertide_core::TableConstraint) -> Stri } } vespertide_core::TableConstraint::Check { name, expr } => { - format!("{} CHECK ({})", name, expr) + format!("{name} CHECK ({expr})") } vespertide_core::TableConstraint::Index { name, columns } => { if let Some(n) = name { @@ -234,6 +232,7 @@ fn format_constraint_type(constraint: &vespertide_core::TableConstraint) -> Stri format!("INDEX ({})", columns.join(", ")) } } + _ => unreachable!("TableConstraint is #[non_exhaustive]; all variants are matched above"), } } diff --git a/crates/vespertide-cli/src/commands/erd/dot.rs b/crates/vespertide-cli/src/commands/erd/dot.rs new file mode 100644 index 00000000..ddbad213 --- /dev/null +++ b/crates/vespertide-cli/src/commands/erd/dot.rs @@ -0,0 +1,88 @@ +use dot_writer::{Attributes, DotWriter, RankDirection, Shape}; +use vespertide_core::{ColumnDef, TableDef}; + +use super::{ + ForeignKeyRelation, collect_foreign_key_relations, column_markers, sanitize_identifier, +}; + +pub fn render_dot(tables: &[TableDef]) -> String { + DotWriter::write_string(|writer| { + writer.set_pretty_print(true); + + let mut digraph = writer.digraph(); + digraph.set_rank_direction(RankDirection::LeftRight); + digraph.set("bgcolor", "transparent", true); + + { + let mut node_attributes = digraph.node_attributes(); + node_attributes.set_shape(Shape::Record); + node_attributes.set("fontname", "Helvetica", true); + } + + { + let mut edge_attributes = digraph.edge_attributes(); + edge_attributes.set("fontname", "Helvetica", true); + } + + for table in tables { + let mut node = digraph.node_named(sanitize_identifier(&table.name)); + node.set_shape(Shape::Record); + node.set_label(&record_label(table)); + } + + for relation in collect_foreign_key_relations(tables) { + let mut edge_attributes = digraph + .edge( + sanitize_identifier(&relation.child_table), + sanitize_identifier(&relation.parent_table), + ) + .attributes(); + edge_attributes.set_label(&relationship_label(&relation)); + } + }) +} + +fn record_label(table: &TableDef) -> String { + let mut fields = Vec::with_capacity(table.columns.len() + 1); + fields.push(escape_record_field(&table.name)); + + for column in &table.columns { + fields.push(column_record_field(table, column)); + } + + format!("{{{}}}", fields.join("|")) +} + +fn column_record_field(table: &TableDef, column: &ColumnDef) -> String { + format!( + "{}: {}{}", + escape_record_field(&column.name), + escape_record_field(&column.r#type.to_display_string()), + escape_record_field(&column_markers(table, column)) + ) +} + +fn relationship_label(relation: &ForeignKeyRelation) -> String { + format!( + "{}: {} -> {}", + relation.cardinality.label(), + relation.child_columns.join(", "), + relation.parent_columns.join(", ") + ) +} + +fn escape_record_field(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + + for ch in value.chars() { + match ch { + '\\' | '{' | '}' | '|' | '<' | '>' | '"' => { + escaped.push('\\'); + escaped.push(ch); + } + _ => escaped.push(ch), + } + } + + escaped +} diff --git a/crates/vespertide-cli/src/commands/erd/mermaid.rs b/crates/vespertide-cli/src/commands/erd/mermaid.rs new file mode 100644 index 00000000..2e7fda42 --- /dev/null +++ b/crates/vespertide-cli/src/commands/erd/mermaid.rs @@ -0,0 +1,103 @@ +use std::fmt::Write as _; + +use vespertide_core::{ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, TableDef}; + +use super::{ + Cardinality, ForeignKeyRelation, collect_foreign_key_relations, is_foreign_key_column, + is_primary_key_column, sanitize_identifier, +}; + +pub fn render_mermaid(tables: &[TableDef]) -> String { + let mut output = String::from("erDiagram\n"); + + for table in tables { + writeln!(output, " {} {{", sanitize_identifier(&table.name)) + .expect("write Mermaid table header"); + + for column in &table.columns { + let primary_key = if is_primary_key_column(table, &column.name) { + " PK" + } else { + "" + }; + let foreign_key = if is_foreign_key_column(table, &column.name) { + " FK" + } else { + "" + }; + + writeln!( + output, + " {} {}{}{}", + column_type_to_mermaid(&column.r#type), + sanitize_identifier(&column.name), + primary_key, + foreign_key + ) + .expect("write Mermaid column"); + } + + writeln!(output, " }}").expect("write Mermaid table footer"); + } + + for relation in collect_foreign_key_relations(tables) { + let (left_table, connector, right_table) = mermaid_relationship(&relation); + writeln!( + output, + " {} {} {} : \"{}\"", + sanitize_identifier(left_table), + connector, + sanitize_identifier(right_table), + escape_mermaid_label(&relation.child_columns.join(", ")) + ) + .expect("write Mermaid relationship"); + } + + output +} + +fn mermaid_relationship(relation: &ForeignKeyRelation) -> (&str, &'static str, &str) { + match relation.cardinality { + Cardinality::OneToOne => (&relation.parent_table, "||--||", &relation.child_table), + Cardinality::OneToMany => (&relation.parent_table, "||--o{", &relation.child_table), + Cardinality::ZeroOrOneToMany => (&relation.parent_table, "|o--o{", &relation.child_table), + Cardinality::ManyToMany => (&relation.child_table, "}o--||", &relation.parent_table), + } +} + +fn column_type_to_mermaid(column_type: &ColumnType) -> &'static str { + match column_type { + ColumnType::Simple(simple) => simple_column_type_to_mermaid(simple), + ColumnType::Complex(complex) => complex_column_type_to_mermaid(complex), + } +} + +fn simple_column_type_to_mermaid(column_type: &SimpleColumnType) -> &'static str { + match column_type { + SimpleColumnType::SmallInt | SimpleColumnType::Integer | SimpleColumnType::BigInt => "int", + SimpleColumnType::Real | SimpleColumnType::DoublePrecision => "float", + SimpleColumnType::Boolean => "boolean", + SimpleColumnType::Date => "date", + SimpleColumnType::Time => "time", + SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz => "datetime", + SimpleColumnType::Bytea => "binary", + SimpleColumnType::Uuid => "uuid", + SimpleColumnType::Json => "json", + _ => "string", + } +} + +fn complex_column_type_to_mermaid(column_type: &ComplexColumnType) -> &'static str { + match column_type { + ComplexColumnType::Numeric { .. } => "decimal", + ComplexColumnType::Enum { values, .. } => match values { + EnumValues::String(_) => "string", + EnumValues::Integer(_) => "int", + }, + _ => "string", + } +} + +fn escape_mermaid_label(label: &str) -> String { + label.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/crates/vespertide-cli/src/commands/erd/mod.rs b/crates/vespertide-cli/src/commands/erd/mod.rs new file mode 100644 index 00000000..0d472350 --- /dev/null +++ b/crates/vespertide-cli/src/commands/erd/mod.rs @@ -0,0 +1,589 @@ +pub mod dot; +pub mod mermaid; +pub mod svg; + +#[cfg(test)] +mod tests; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::ValueEnum; +use vespertide_core::schema::foreign_key::ForeignKeySyntax; +use vespertide_core::schema::primary_key::PrimaryKeySyntax; +use vespertide_core::{ColumnDef, ReferenceAction, StrOrBoolOrArray, TableConstraint, TableDef}; + +use crate::utils::{load_config, load_models}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +pub enum ErdFormat { + Svg, + Mermaid, + Dot, +} + +#[allow(dead_code)] +pub async fn cmd_erd(format: ErdFormat, output: Option) -> Result<()> { + cmd_erd_with_filters(format, output, Vec::new(), Vec::new(), 0).await +} + +pub async fn cmd_erd_with_filters( + format: ErdFormat, + output: Option, + include: Vec, + exclude: Vec, + depth: usize, +) -> Result<()> { + let config = load_config()?; + let tables = filter_tables( + normalize_tables(load_models(&config)?)?, + &include, + &exclude, + depth, + ); + + let rendered = match format { + ErdFormat::Svg => svg::render_svg(&tables).map_err(anyhow::Error::msg)?, + ErdFormat::Mermaid => mermaid::render_mermaid(&tables), + ErdFormat::Dot => dot::render_dot(&tables), + }; + + if let Some(path) = output { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("create ERD output directory {}", parent.display()))?; + } + + tokio::fs::write(&path, rendered) + .await + .with_context(|| format!("write ERD output {}", path.display()))?; + println!("ERD exported to {}", path.display()); + } else { + print!("{rendered}"); + } + + Ok(()) +} + +pub(super) fn filter_tables( + tables: Vec, + include: &[String], + exclude: &[String], + depth: usize, +) -> Vec { + let (tables, warnings) = filter_tables_with_warnings(tables, include, exclude, depth); + for warning in warnings { + eprintln!("{warning}"); + } + tables +} + +pub(super) fn filter_tables_with_warnings( + tables: Vec, + include: &[String], + exclude: &[String], + depth: usize, +) -> (Vec, Vec) { + if include.is_empty() && exclude.is_empty() { + return (tables, Vec::new()); + } + + let include = normalized_filter_names(include); + let exclude = normalized_filter_names(exclude); + let all_names: BTreeSet = tables.iter().map(|table| table.name.clone()).collect(); + + let mut warnings = filter_warnings(&all_names, "--include", &include); + warnings.extend(filter_warnings(&all_names, "--exclude", &exclude)); + + let mut kept: BTreeSet = if include.is_empty() { + all_names.clone() + } else { + include + .iter() + .filter(|name| all_names.contains(*name)) + .cloned() + .collect() + }; + + let adjacency = build_fk_adjacency(&tables); + for _ in 0..depth { + let frontier: Vec = kept.iter().cloned().collect(); + for name in frontier { + if let Some(neighbors) = adjacency.get(&name) { + kept.extend( + neighbors + .iter() + .filter(|neighbor| all_names.contains(*neighbor)) + .cloned(), + ); + } + } + } + + for name in exclude { + kept.remove(&name); + } + + let filtered = tables + .into_iter() + .filter(|table| kept.contains(&table.name)) + .collect(); + (filtered, warnings) +} + +fn normalize_tables(tables: Vec) -> Result> { + tables + .into_iter() + .map(|table| { + table + .normalize() + .with_context(|| format!("normalize table '{}'", table.name)) + }) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct ForeignKeyRelation { + pub child_table: String, + pub child_columns: Vec, + pub parent_table: String, + pub parent_columns: Vec, + pub on_delete: Option, + pub on_update: Option, + pub cardinality: Cardinality, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Cardinality { + OneToOne, + OneToMany, + ZeroOrOneToMany, + ManyToMany, +} + +impl Cardinality { + pub(super) fn label(self) -> &'static str { + match self { + Self::OneToOne => "1:1", + Self::OneToMany => "1:N", + Self::ZeroOrOneToMany => "0..1:N", + Self::ManyToMany => "M:N", + } + } +} + +pub(super) fn collect_foreign_key_relations(tables: &[TableDef]) -> BTreeSet { + let mut relations = BTreeSet::new(); + let table_lookup: BTreeMap<&str, &TableDef> = tables + .iter() + .map(|table| (table.name.as_str(), table)) + .collect(); + + for table in tables { + for constraint in &table.constraints { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = constraint + { + let Some(parent_table) = table_lookup.get(ref_table.as_str()).copied() else { + continue; + }; + relations.insert(build_foreign_key_relation( + table, + columns.clone(), + ref_table.clone(), + ref_columns.clone(), + on_delete.clone(), + on_update.clone(), + parent_table, + )); + } + } + + for column in &table.columns { + if let Some(foreign_key) = &column.foreign_key + && let Some(relation) = + inline_foreign_key_relation(table, column, foreign_key, &table_lookup) + { + relations.insert(relation); + } + } + } + + relations +} + +pub(super) fn is_primary_key_column(table: &TableDef, column_name: &str) -> bool { + table + .columns + .iter() + .any(|column| column.name == column_name && is_inline_primary_key(column)) + || table.constraints.iter().any(|constraint| { + matches!( + constraint, + TableConstraint::PrimaryKey { columns, .. } + if columns.iter().any(|column| column == column_name) + ) + }) +} + +pub(super) fn is_foreign_key_column(table: &TableDef, column_name: &str) -> bool { + table + .columns + .iter() + .any(|column| column.name == column_name && column.foreign_key.is_some()) + || table.constraints.iter().any(|constraint| { + matches!( + constraint, + TableConstraint::ForeignKey { columns, .. } + if columns.iter().any(|column| column == column_name) + ) + }) +} + +pub(super) fn column_markers(table: &TableDef, column: &ColumnDef) -> String { + let mut markers = Vec::new(); + if is_primary_key_column(table, &column.name) { + markers.push("PK"); + } + if is_foreign_key_column(table, &column.name) { + markers.push("FK"); + } + + if markers.is_empty() { + String::new() + } else { + format!(" ({})", markers.join(", ")) + } +} + +pub(super) fn sanitize_identifier(input: &str) -> String { + let mut identifier = String::new(); + + for (index, ch) in input.chars().enumerate() { + if ch == '_' || ch.is_ascii_alphanumeric() { + if index == 0 && ch.is_ascii_digit() { + identifier.push('_'); + } + identifier.push(ch); + } else { + identifier.push('_'); + } + } + + if identifier.is_empty() { + "_".to_string() + } else { + identifier + } +} + +fn inline_foreign_key_relation( + table: &TableDef, + column: &ColumnDef, + foreign_key: &ForeignKeySyntax, + table_lookup: &BTreeMap<&str, &TableDef>, +) -> Option { + let (parent_table, parent_columns, on_delete, on_update) = match foreign_key { + ForeignKeySyntax::String(reference) => { + let (table, columns) = parse_reference(reference)?; + (table, columns, None, None) + } + ForeignKeySyntax::Reference(reference) => { + let (table, columns) = parse_reference(&reference.references)?; + ( + table, + columns, + reference.on_delete.clone(), + reference.on_update.clone(), + ) + } + ForeignKeySyntax::Object(definition) => ( + definition.ref_table.clone(), + definition.ref_columns.clone(), + definition.on_delete.clone(), + definition.on_update.clone(), + ), + }; + + let parent_table_def = table_lookup.get(parent_table.as_str()).copied()?; + Some(build_foreign_key_relation( + table, + vec![column.name.clone()], + parent_table, + parent_columns, + on_delete, + on_update, + parent_table_def, + )) +} + +fn parse_reference(reference: &str) -> Option<(String, Vec)> { + let mut parts = reference.split('.'); + let table = parts.next()?; + let column = parts.next()?; + + if parts.next().is_some() || table.is_empty() || column.is_empty() { + return None; + } + + Some((table.to_string(), vec![column.to_string()])) +} + +fn build_foreign_key_relation( + child_table: &TableDef, + child_columns: Vec, + parent_table: String, + parent_columns: Vec, + on_delete: Option, + on_update: Option, + parent_table_def: &TableDef, +) -> ForeignKeyRelation { + let cardinality = detect_cardinality(child_table, &child_columns, parent_table_def); + ForeignKeyRelation { + child_table: child_table.name.clone(), + child_columns, + parent_table, + parent_columns, + on_delete, + on_update, + cardinality, + } +} + +fn detect_cardinality( + child_table: &TableDef, + child_columns: &[String], + _parent_table: &TableDef, +) -> Cardinality { + if is_junction_table(child_table) { + return Cardinality::ManyToMany; + } + + if are_columns_unique(child_table, child_columns) { + return Cardinality::OneToOne; + } + + if child_columns + .iter() + .any(|column| is_nullable_column(child_table, column)) + { + return Cardinality::ZeroOrOneToMany; + } + + Cardinality::OneToMany +} + +fn is_junction_table(table: &TableDef) -> bool { + let primary_key_columns = primary_key_columns(table); + if primary_key_columns.len() < 2 { + return false; + } + + let foreign_key_groups = foreign_key_column_groups(table); + if foreign_key_groups.len() < 2 { + return false; + } + + let foreign_key_columns: BTreeSet<&str> = foreign_key_groups + .iter() + .flat_map(|group| group.iter().map(String::as_str)) + .collect(); + + primary_key_columns + .iter() + .all(|column| foreign_key_columns.contains(column.as_str())) +} + +fn are_columns_unique(table: &TableDef, columns: &[String]) -> bool { + if columns.is_empty() { + return false; + } + + let primary_key_columns = primary_key_columns(table); + if !primary_key_columns.is_empty() && same_column_set(columns, &primary_key_columns) { + return true; + } + + table.constraints.iter().any(|constraint| { + matches!( + constraint, + TableConstraint::Unique { columns: unique_columns, .. } + if same_column_set(columns, unique_columns) + ) + }) || inline_unique_column_groups(table) + .iter() + .any(|unique_columns| same_column_set(columns, unique_columns)) +} + +fn primary_key_columns(table: &TableDef) -> Vec { + if let Some(columns) = table.constraints.iter().find_map(|constraint| { + if let TableConstraint::PrimaryKey { columns, .. } = constraint { + Some(columns.clone()) + } else { + None + } + }) { + return columns; + } + + table + .columns + .iter() + .filter(|column| is_inline_primary_key(column)) + .map(|column| column.name.clone()) + .collect() +} + +fn foreign_key_column_groups(table: &TableDef) -> Vec> { + let mut groups: Vec> = Vec::new(); + for constraint in &table.constraints { + if let TableConstraint::ForeignKey { columns, .. } = constraint + && !groups.iter().any(|group| same_column_set(group, columns)) + { + groups.push(columns.clone()); + } + } + + for column in &table.columns { + if column.foreign_key.is_some() { + let group = vec![column.name.clone()]; + if !groups + .iter() + .any(|existing| same_column_set(existing, &group)) + { + groups.push(group); + } + } + } + + groups +} + +fn inline_unique_column_groups(table: &TableDef) -> Vec> { + let mut groups: BTreeMap> = BTreeMap::new(); + for column in &table.columns { + let Some(unique) = &column.unique else { + continue; + }; + + match unique { + StrOrBoolOrArray::Str(name) => { + groups + .entry(name.clone()) + .or_default() + .push(column.name.clone()); + } + StrOrBoolOrArray::Array(names) => { + for name in names { + groups + .entry(name.clone()) + .or_default() + .push(column.name.clone()); + } + } + StrOrBoolOrArray::Bool(true) => { + groups.insert(format!("__auto_{}", column.name), vec![column.name.clone()]); + } + _ => {} + } + } + groups.into_values().collect() +} + +fn is_inline_primary_key(column: &ColumnDef) -> bool { + matches!( + &column.primary_key, + Some(PrimaryKeySyntax::Bool(true) | PrimaryKeySyntax::Object(_)) + ) +} + +fn is_nullable_column(table: &TableDef, column_name: &str) -> bool { + table + .columns + .iter() + .any(|column| column.name == column_name && column.nullable) +} + +fn same_column_set(left: &[String], right: &[String]) -> bool { + let left: BTreeSet<&str> = left.iter().map(String::as_str).collect(); + let right: BTreeSet<&str> = right.iter().map(String::as_str).collect(); + left == right +} + +fn normalized_filter_names(names: &[String]) -> Vec { + names + .iter() + .filter_map(|name| { + let trimmed = name.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + .collect() +} + +fn filter_warnings( + all_names: &BTreeSet, + flag: &str, + filter_names: &[String], +) -> Vec { + let unknown: BTreeSet<&str> = filter_names + .iter() + .map(String::as_str) + .filter(|name| !all_names.contains(*name)) + .collect(); + + unknown + .into_iter() + .map(|name| format!("warning: ERD {flag} references unknown table '{name}'")) + .collect() +} + +fn build_fk_adjacency(tables: &[TableDef]) -> BTreeMap> { + let mut adjacency: BTreeMap> = tables + .iter() + .map(|table| (table.name.clone(), BTreeSet::new())) + .collect(); + let mut junction_parents: BTreeMap> = BTreeMap::new(); + + for relation in collect_foreign_key_relations(tables) { + if let Some(neighbors) = adjacency.get_mut(&relation.child_table) { + neighbors.insert(relation.parent_table.clone()); + } + if let Some(neighbors) = adjacency.get_mut(&relation.parent_table) { + neighbors.insert(relation.child_table.clone()); + } + if relation.cardinality == Cardinality::ManyToMany { + junction_parents + .entry(relation.child_table) + .or_default() + .insert(relation.parent_table); + } + } + + for parents in junction_parents.values() { + for parent in parents { + for peer in parents { + if parent != peer + && let Some(neighbors) = adjacency.get_mut(parent) + { + neighbors.insert(peer.clone()); + } + } + } + } + + adjacency +} diff --git a/crates/vespertide-cli/src/commands/erd/svg.rs b/crates/vespertide-cli/src/commands/erd/svg.rs new file mode 100644 index 00000000..970a292e --- /dev/null +++ b/crates/vespertide-cli/src/commands/erd/svg.rs @@ -0,0 +1,980 @@ +//! SVG ERD renderer. +//! +//! Layout-rs / Graphviz produced unattractive output (huge whitespace, +//! flat record shapes, no visual hierarchy). This module replaces it with +//! a fully custom layout + SVG emitter: +//! +//! * Tables are laid out in topological ranks (parents → children). +//! * Each table card has a dark header, rounded corners, and per-column +//! PK/FK badges. +//! * Foreign-key edges are drawn as cubic Bézier curves between the +//! nearest sides of the connected tables. + +// SVG layout is inherently a floating-point pipeline driven by integer counts +// (rows, ranks, badge counts). Cast lints add noise without catching real bugs. +// `unnecessary_wraps` is suppressed because the public API contract still +// returns `Result` to leave room for future fallible paths +// (e.g. graph validation) without breaking the caller in `mod.rs`. +#![allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_lossless, + clippy::range_plus_one, + clippy::unnecessary_wraps, + clippy::uninlined_format_args, + clippy::too_many_arguments, + clippy::too_many_lines, + clippy::similar_names +)] + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +use vespertide_core::{ColumnDef, TableDef}; + +use super::{ + ForeignKeyRelation, collect_foreign_key_relations, is_foreign_key_column, is_primary_key_column, +}; + +// --------------------------------------------------------------------------- +// Aesthetic constants +// --------------------------------------------------------------------------- + +const FONT_FAMILY: &str = "Pretendard, 'Noto Sans KR', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', \ + Roboto, 'Helvetica Neue', Arial, sans-serif"; +const MONO_FAMILY: &str = + "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Courier New', monospace"; + +const HEADER_H: f64 = 34.0; +const ROW_H: f64 = 24.0; +const TABLE_PAD_X: f64 = 14.0; +const BADGE_W: f64 = 22.0; +const BADGE_H: f64 = 14.0; +const BADGE_GAP: f64 = 6.0; +const COL_GAP_TYPE: f64 = 18.0; +const TABLE_RADIUS: f64 = 14.0; + +const TITLE_FS: f64 = 14.0; +const TITLE_CH: f64 = 7.9; +const NAME_FS: f64 = 12.0; +const NAME_CH: f64 = 6.7; +const TYPE_FS: f64 = 11.0; +const TYPE_CH: f64 = 5.8; +const BADGE_FS: f64 = 9.0; + +const RANK_GAP: f64 = 80.0; +const NODE_GAP: f64 = 32.0; +const VIEW_PAD: f64 = 40.0; + +// Palette (modern, light, neutral) +// DevFive (devfive.kr) brand palette. +// Signature purple #5b34f7, light bg #f7f8fb, accent yellow #ffe139. +const BG: &str = "#f7f8fb"; +const CARD_BG: &str = "#ffffff"; +const CARD_BORDER: &str = "#eaeaed"; +const HEADER_FILL: &str = "url(#vespHeader)"; +const HEADER_FG: &str = "#ffffff"; +const HEADER_SUB: &str = "#e9defe"; +const ROW_FG: &str = "#1a1a1a"; +const ROW_FG_MUTED: &str = "#50505d"; +const ROW_ALT_BG: &str = "#fafbfd"; +const ROW_DIVIDER: &str = "#f0f0f4"; +const PK_BG: &str = "#fff7d4"; +const PK_FG: &str = "#8a6d04"; +const FK_BG: &str = "#f0e9ff"; +const FK_FG: &str = "#5b34f7"; +const EDGE_STROKE: &str = "#b5a4f6"; +const EDGE_END: &str = "#5b34f7"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +pub fn render_svg(tables: &[TableDef]) -> Result { + if tables.is_empty() { + return Ok(render_empty()); + } + + let mut boxes = build_boxes(tables); + let relations = collect_foreign_key_relations(tables); + let edges = build_edges(tables, &boxes, &relations); + + let ranks = compute_ranks(&boxes, &edges); + layout_grid(&mut boxes, &ranks); + + let (vw, vh) = view_size(&boxes); + Ok(render_doc(&boxes, &edges, vw, vh)) +} + +// --------------------------------------------------------------------------- +// Box / edge model +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct TableBox { + name: String, + rows: Vec, + width: f64, + height: f64, + x: f64, + y: f64, + /// Column-name → row index, for fast FK row lookup. + row_index: BTreeMap, + /// First PK row index, used as anchor for incoming edges. + pk_row: Option, +} + +#[derive(Debug, Clone)] +struct RowSpec { + name: String, + type_str: String, + is_pk: bool, + is_fk: bool, + nullable: bool, +} + +#[derive(Debug, Clone)] +struct EdgeSpec { + child_idx: usize, + parent_idx: usize, + child_row: usize, + parent_row: usize, + label: String, + cardinality_label: &'static str, + /// 0-based index among parallel edges sharing the same (child, parent) + /// unordered pair. Used to spread cardinality labels along the curve. + parallel_index: u32, + /// Total number of parallel edges in the same group. + parallel_count: u32, +} + +fn build_boxes(tables: &[TableDef]) -> Vec { + tables + .iter() + .map(|table| { + let rows: Vec = table + .columns + .iter() + .map(|column| build_row(table, column)) + .collect(); + + let mut row_index = BTreeMap::new(); + let mut pk_row = None; + for (idx, row) in rows.iter().enumerate() { + row_index.insert(row.name.clone(), idx); + if row.is_pk && pk_row.is_none() { + pk_row = Some(idx); + } + } + + let width = measure_table_width(&table.name, &rows); + let height = HEADER_H + ROW_H * rows.len() as f64; + + TableBox { + name: table.name.clone(), + rows, + width, + height, + x: 0.0, + y: 0.0, + row_index, + pk_row, + } + }) + .collect() +} + +fn build_row(table: &TableDef, column: &ColumnDef) -> RowSpec { + RowSpec { + name: column.name.clone(), + type_str: column.r#type.to_display_string(), + is_pk: is_primary_key_column(table, &column.name), + is_fk: is_foreign_key_column(table, &column.name), + nullable: column.nullable, + } +} + +fn measure_table_width(name: &str, rows: &[RowSpec]) -> f64 { + let title_w = name.chars().count() as f64 * TITLE_CH + TABLE_PAD_X * 2.0; + + let row_max = rows + .iter() + .map(|row| { + let badges = badge_block_width(row); + let name_w = row.name.chars().count() as f64 * NAME_CH; + let type_w = row.type_str.chars().count() as f64 * TYPE_CH; + TABLE_PAD_X * 2.0 + badges + name_w + COL_GAP_TYPE + type_w + }) + .fold(0.0_f64, f64::max); + + let raw = title_w.max(row_max).max(180.0); + // Round up to a nice 4-pixel grid for crispness. + (raw / 4.0).ceil() * 4.0 +} + +fn badge_block_width(row: &RowSpec) -> f64 { + let mut count = 0; + if row.is_pk { + count += 1; + } + if row.is_fk { + count += 1; + } + if count == 0 { + return 0.0; + } + count as f64 * BADGE_W + (count as f64 - 1.0).max(0.0) * 4.0 + BADGE_GAP +} + +fn build_edges( + tables: &[TableDef], + boxes: &[TableBox], + relations: &std::collections::BTreeSet, +) -> Vec { + let name_idx: BTreeMap<&str, usize> = tables + .iter() + .enumerate() + .map(|(i, t)| (t.name.as_str(), i)) + .collect(); + + let mut edges = Vec::new(); + for rel in relations { + let Some(&child_idx) = name_idx.get(rel.child_table.as_str()) else { + continue; + }; + let Some(&parent_idx) = name_idx.get(rel.parent_table.as_str()) else { + continue; + }; + if child_idx == parent_idx { + // Self-reference: skip drawing (rare and hard to route nicely). + continue; + } + + let child_row = rel + .child_columns + .first() + .and_then(|c| boxes[child_idx].row_index.get(c).copied()) + .unwrap_or(0); + let parent_row = rel + .parent_columns + .first() + .and_then(|c| boxes[parent_idx].row_index.get(c).copied()) + .or(boxes[parent_idx].pk_row) + .unwrap_or(0); + + let label = format!( + "{} → {}", + rel.child_columns.join(", "), + rel.parent_columns.join(", ") + ); + + edges.push(EdgeSpec { + child_idx, + parent_idx, + child_row, + parent_row, + label, + cardinality_label: rel.cardinality.label(), + parallel_index: 0, + parallel_count: 1, + }); + } + + // Group parallel edges sharing the same unordered (child, parent) pair so + // labels and curves can be spread along the bundle instead of stacking. + let mut group_map: BTreeMap<(usize, usize), Vec> = BTreeMap::new(); + for (i, edge) in edges.iter().enumerate() { + let lo = edge.child_idx.min(edge.parent_idx); + let hi = edge.child_idx.max(edge.parent_idx); + group_map.entry((lo, hi)).or_default().push(i); + } + for indices in group_map.values() { + let count = u32::try_from(indices.len()).unwrap_or(1); + for (slot, &edge_idx) in indices.iter().enumerate() { + let parallel_index = u32::try_from(slot).unwrap_or(0); + edges[edge_idx].parallel_index = parallel_index; + edges[edge_idx].parallel_count = count; + } + } + + edges +} + +// --------------------------------------------------------------------------- +// Rank assignment + grid layout +// --------------------------------------------------------------------------- + +fn compute_ranks(boxes: &[TableBox], edges: &[EdgeSpec]) -> Vec { + let n = boxes.len(); + let mut parents: Vec> = vec![Vec::new(); n]; + for edge in edges { + parents[edge.child_idx].push(edge.parent_idx); + } + + let mut ranks = vec![0_usize; n]; + // Iterative fixed-point; cap iterations to avoid cycles spiralling. + for _ in 0..(n + 1) { + let mut changed = false; + for i in 0..n { + let candidate = parents[i] + .iter() + .map(|&p| ranks[p].saturating_add(1)) + .max() + .unwrap_or(0); + if candidate > ranks[i] { + ranks[i] = candidate; + changed = true; + } + } + if !changed { + break; + } + } + ranks +} + +fn layout_grid(boxes: &mut [TableBox], ranks: &[usize]) { + let max_rank = ranks.iter().copied().max().unwrap_or(0); + let num_ranks = max_rank + 1; + + // Bucket by rank. + let mut groups: Vec> = vec![Vec::new(); num_ranks]; + for (i, &r) in ranks.iter().enumerate() { + groups[r].push(i); + } + + // Stable order inside each rank: by name. + for group in &mut groups { + group.sort_by(|&a, &b| boxes[a].name.cmp(&boxes[b].name)); + } + + // If the layout is very lopsided (one rank stuffed full while another is sparse), + // rebalance by splitting the largest rank. + rebalance_groups(&mut groups, boxes.len()); + + // Compute per-rank column width as max box width. + let col_widths: Vec = groups + .iter() + .map(|group| { + group + .iter() + .map(|&i| boxes[i].width) + .fold(180.0_f64, f64::max) + }) + .collect(); + + // X positions per rank (left edge of column). + let mut col_x = Vec::with_capacity(groups.len()); + let mut cursor = VIEW_PAD; + for w in &col_widths { + col_x.push(cursor); + cursor += *w + RANK_GAP; + } + + // Place inside each column, centered horizontally on the column's width. + for (rank_idx, group) in groups.iter().enumerate() { + let mut y = VIEW_PAD; + let column_x = col_x[rank_idx]; + let column_w = col_widths[rank_idx]; + for &i in group { + let bx = &mut boxes[i]; + bx.x = column_x + (column_w - bx.width) / 2.0; + bx.y = y; + y += bx.height + NODE_GAP; + } + } +} + +fn rebalance_groups(groups: &mut Vec>, total: usize) { + if groups.is_empty() { + return; + } + let target_max = ((total as f64).sqrt().ceil() as usize).max(3); + + let mut i = 0; + while i < groups.len() { + if groups[i].len() > target_max { + let overflow: Vec = groups[i].split_off(target_max); + groups.insert(i + 1, overflow); + } + i += 1; + } +} + +fn view_size(boxes: &[TableBox]) -> (f64, f64) { + let mut w = 0.0_f64; + let mut h = 0.0_f64; + for bx in boxes { + w = w.max(bx.x + bx.width); + h = h.max(bx.y + bx.height); + } + (w + VIEW_PAD, h + VIEW_PAD) +} + +// --------------------------------------------------------------------------- +// SVG emission +// --------------------------------------------------------------------------- + +fn render_doc(boxes: &[TableBox], edges: &[EdgeSpec], vw: f64, vh: f64) -> String { + let mut out = String::with_capacity(4096); + + let _ = writeln!( + out, + "", + w = vw, + h = vh, + ff = FONT_FAMILY, + ); + + render_defs(&mut out); + + let _ = writeln!( + out, + " ", + w = vw, + h = vh, + bg = BG, + ); + + // Pass 1: draw every edge path. Doing all paths before any labels + // guarantees label pills are never overdrawn by another edge in a + // dense bundle (junction tables, self-references, etc.). + out.push_str(" \n"); + for edge in edges { + render_edge_path( + &mut out, + &boxes[edge.child_idx], + &boxes[edge.parent_idx], + edge, + ); + } + out.push_str(" \n"); + + // Tables — rendered above edge paths but below labels so column rows are + // legible and FK badges line up with their anchor points. + out.push_str(" \n"); + for bx in boxes { + render_table(&mut out, bx); + } + out.push_str(" \n"); + + // Pass 2: cardinality labels (pill + text). Always on top so they stay + // readable regardless of how many curves cross their location. + out.push_str(" \n"); + for edge in edges { + render_edge_label( + &mut out, + &boxes[edge.child_idx], + &boxes[edge.parent_idx], + edge, + ); + } + out.push_str(" \n"); + + out.push_str("\n"); + out +} + +fn render_defs(out: &mut String) { + out.push_str(" \n"); + out.push_str( + " \n\ + \x20 \n\ + \x20 \n\ + \x20 \n", + ); + out.push_str( + " \n\ + \x20 \n\ + \x20 \n", + ); + out.push_str( + " \n\ + \x20 \n\ + \x20 \n", + ); + out.push_str( + " \n\ + \x20 \n\ + \x20 \n", + ); + out.push_str(" \n"); +} + +fn render_table(out: &mut String, bx: &TableBox) { + let _ = writeln!( + out, + " ", + x = bx.x, + y = bx.y, + ); + + // Card background with shadow. + let _ = writeln!( + out, + " ", + w = bx.width, + h = bx.height, + r = TABLE_RADIUS, + cbg = CARD_BG, + cb = CARD_BORDER, + ); + + // Header band — use a path so only the top corners are rounded. + let header_path = rounded_top_path(bx.width, HEADER_H, TABLE_RADIUS); + let _ = writeln!( + out, + " ", + path = header_path, + fill = HEADER_FILL, + ); + + // Title. + let _ = writeln!( + out, + " {name}", + tx = TABLE_PAD_X, + ty = HEADER_H / 2.0 + TITLE_FS / 2.0 - 2.0, + fg = HEADER_FG, + fs = TITLE_FS, + name = escape_xml(&bx.name), + ); + + // Column count hint, right-aligned in header. + let count_str = format!("{} cols", bx.rows.len()); + let _ = writeln!( + out, + " {count}", + cx = bx.width - TABLE_PAD_X, + cy = HEADER_H / 2.0 + 4.0, + sub = HEADER_SUB, + count = escape_xml(&count_str), + ); + + // Rows. + for (idx, row) in bx.rows.iter().enumerate() { + render_row(out, bx, idx, row); + } + + out.push_str(" \n"); +} + +fn render_row(out: &mut String, bx: &TableBox, idx: usize, row: &RowSpec) { + let y = HEADER_H + idx as f64 * ROW_H; + let is_last = idx == bx.rows.len() - 1; + + // Alt background for zebra striping. Skip the very last row's stripe to keep + // the rounded bottom corners clean (the card border handles the visual). + if idx % 2 == 1 { + if is_last { + let path = rounded_bottom_path(bx.width, y, ROW_H, TABLE_RADIUS); + let _ = writeln!(out, " "); + } else { + let _ = writeln!( + out, + " ", + y = y, + w = bx.width, + h = ROW_H, + bg = ROW_ALT_BG, + ); + } + } + + // Top divider (skip on the first row — header bottom acts as divider). + if idx > 0 { + let _ = writeln!( + out, + " ", + x1 = 1.0, + x2 = bx.width - 1.0, + y = y, + c = ROW_DIVIDER, + ); + } + + // Badges. + let mut badge_x = TABLE_PAD_X; + if row.is_pk { + render_badge( + out, + badge_x, + y + (ROW_H - BADGE_H) / 2.0, + "PK", + PK_BG, + PK_FG, + ); + badge_x += BADGE_W + 4.0; + } + if row.is_fk { + render_badge( + out, + badge_x, + y + (ROW_H - BADGE_H) / 2.0, + "FK", + FK_BG, + FK_FG, + ); + badge_x += BADGE_W + 4.0; + } + + let name_x = if row.is_pk || row.is_fk { + badge_x + BADGE_GAP - 4.0 + } else { + TABLE_PAD_X + }; + + // Column name. + let name_weight = if row.is_pk { "600" } else { "500" }; + let _ = writeln!( + out, + " {name}", + nx = name_x, + ty = y + ROW_H / 2.0 + NAME_FS / 2.0 - 2.0, + fg = ROW_FG, + fs = NAME_FS, + w = name_weight, + name = escape_xml(&row.name), + ); + + // Type, right-aligned in monospace. + let type_display = if row.nullable { + format!("{}?", row.type_str) + } else { + row.type_str.clone() + }; + let _ = writeln!( + out, + " {t}", + tx = bx.width - TABLE_PAD_X, + ty = y + ROW_H / 2.0 + TYPE_FS / 2.0 - 2.0, + fg = ROW_FG_MUTED, + fs = TYPE_FS, + ff = MONO_FAMILY, + t = escape_xml(&type_display), + ); +} + +fn render_badge(out: &mut String, x: f64, y: f64, label: &str, bg: &str, fg: &str) { + let _ = writeln!( + out, + " \ + {label}", + x = x, + y = y, + w = BADGE_W, + h = BADGE_H, + bg = bg, + tx = x + BADGE_W / 2.0, + ty = y + BADGE_H / 2.0 + BADGE_FS / 2.0 - 1.5, + fg = fg, + fs = BADGE_FS, + label = label, + ); +} + +fn rounded_top_path(w: f64, h: f64, r: f64) -> String { + format!( + "M 0 {h:.1} L 0 {r:.1} Q 0 0 {r:.1} 0 L {wr:.1} 0 Q {w:.1} 0 {w:.1} {r:.1} \ + L {w:.1} {h:.1} Z", + w = w, + h = h, + r = r, + wr = w - r, + ) +} + +fn rounded_bottom_path(w: f64, top_y: f64, h: f64, r: f64) -> String { + let bot = top_y + h; + format!( + "M 0 {top:.1} L {w:.1} {top:.1} L {w:.1} {br:.1} Q {w:.1} {bot:.1} {wr:.1} {bot:.1} \ + L {r:.1} {bot:.1} Q 0 {bot:.1} 0 {br:.1} Z", + top = top_y, + w = w, + bot = bot, + br = bot - r, + wr = w - r, + r = r, + ) +} + +// --------------------------------------------------------------------------- +// Edge routing +// --------------------------------------------------------------------------- + +fn edge_geometry( + child: &TableBox, + parent: &TableBox, + edge: &EdgeSpec, +) -> (f64, f64, f64, f64, Side, Side, f64) { + let child_y = child.y + HEADER_H + edge.child_row as f64 * ROW_H + ROW_H / 2.0; + let parent_y = parent.y + HEADER_H + edge.parent_row as f64 * ROW_H + ROW_H / 2.0; + let (sx, sy, ex, ey, sdir, edir) = pick_anchors(child, parent, child_y, parent_y); + let curvature = parallel_curvature_offset(edge.parallel_index, edge.parallel_count); + (sx, sy, ex, ey, sdir, edir, curvature) +} + +fn render_edge_path(out: &mut String, child: &TableBox, parent: &TableBox, edge: &EdgeSpec) { + let (sx, sy, ex, ey, sdir, edir, curvature) = edge_geometry(child, parent, edge); + let path = bezier_path(sx, sy, ex, ey, sdir, edir, curvature); + + // Two-layer stroke: subtle wide halo + crisp narrow stroke for a soft look. + let _ = writeln!( + out, + " ", + ); + let _ = writeln!( + out, + " \ + {title}", + stroke = EDGE_STROKE, + title = escape_xml(&format!("{} {} → {}", child.name, edge.label, parent.name)), + ); +} + +fn render_edge_label(out: &mut String, child: &TableBox, parent: &TableBox, edge: &EdgeSpec) { + let (sx, sy, ex, ey, sdir, edir, curvature) = edge_geometry(child, parent, edge); + + // Label position is spread along the curve for parallel edges so the + // cardinality badges no longer stack on top of one another. + let label_t = label_t_for_parallel(edge.parallel_index, edge.parallel_count); + let (label_x, label_y) = bezier_at(sx, sy, ex, ey, sdir, edir, curvature, label_t); + + // Pill-shaped white background guarantees the label stays readable when + // curves or other labels cross it. + let char_count = edge.cardinality_label.chars().count() as f64; + let pill_w = (char_count * 5.6 + 12.0).max(22.0); + let pill_h = 15.0; + let _ = writeln!( + out, + " ", + x = label_x - pill_w / 2.0, + y = label_y - pill_h / 2.0, + w = pill_w, + h = pill_h, + border = CARD_BORDER, + ); + + let _ = writeln!( + out, + " {label}", + x = label_x, + y = label_y, + fg = EDGE_END, + label = escape_xml(edge.cardinality_label), + ); +} + +/// Sideways offset applied to a curve's control points so parallel edges fan +/// out instead of collapsing onto the same arc. +fn parallel_curvature_offset(index: u32, count: u32) -> f64 { + if count <= 1 { + return 0.0; + } + let center = (f64::from(count) - 1.0) / 2.0; + (f64::from(index) - center) * 28.0 +} + +/// Parameter `t ∈ [0, 1]` along the curve where the cardinality label sits. +/// For single edges we keep the visual centre (`0.5`); for `N`-way bundles we +/// spread labels evenly between `0.30` and `0.70`. +fn label_t_for_parallel(index: u32, count: u32) -> f64 { + if count <= 1 { + return 0.5; + } + let span = 0.40; + let start = 0.30; + start + (f64::from(index) / f64::from(count - 1)) * span +} + +#[derive(Copy, Clone, PartialEq, Eq)] +enum Side { + Left, + Right, + Top, + Bottom, +} + +fn pick_anchors( + child: &TableBox, + parent: &TableBox, + child_y: f64, + parent_y: f64, +) -> (f64, f64, f64, f64, Side, Side) { + let child_left = child.x; + let child_right = child.x + child.width; + let parent_left = parent.x; + let parent_right = parent.x + parent.width; + + // Prefer horizontal connections — they read cleaner for ERDs. + let horizontal_separation = parent_left > child_right || child_left > parent_right; + if horizontal_separation { + if parent_left >= child_right { + // Parent is to the right of the child. + return ( + child_right, + child_y, + parent_left, + parent_y, + Side::Right, + Side::Left, + ); + } + // Parent is to the left of the child. + return ( + child_left, + child_y, + parent_right, + parent_y, + Side::Left, + Side::Right, + ); + } + + // Otherwise route top/bottom. + if parent.y + parent.height <= child.y { + let sx = child.x + child.width / 2.0; + let ex = parent.x + parent.width / 2.0; + return ( + sx, + child.y, + ex, + parent.y + parent.height, + Side::Top, + Side::Bottom, + ); + } + let sx = child.x + child.width / 2.0; + let ex = parent.x + parent.width / 2.0; + ( + sx, + child.y + child.height, + ex, + parent.y, + Side::Bottom, + Side::Top, + ) +} + +fn bezier_path( + sx: f64, + sy: f64, + ex: f64, + ey: f64, + s_side: Side, + e_side: Side, + lateral_offset: f64, +) -> String { + let dx = (ex - sx).abs(); + let dy = (ey - sy).abs(); + let pull = dx.max(dy).max(40.0) * 0.5; + + let (cs_x, cs_y) = control_point(sx, sy, s_side, pull, lateral_offset); + let (ce_x, ce_y) = control_point(ex, ey, e_side, pull, lateral_offset); + + format!( + "M {sx:.1} {sy:.1} C {csx:.1} {csy:.1} {cex:.1} {cey:.1} {ex:.1} {ey:.1}", + sx = sx, + sy = sy, + csx = cs_x, + csy = cs_y, + cex = ce_x, + cey = ce_y, + ex = ex, + ey = ey, + ) +} + +/// Evaluate a cubic Bezier at parameter `t ∈ [0, 1]`. +/// Used to place cardinality labels at varying positions along an edge. +fn bezier_at( + sx: f64, + sy: f64, + ex: f64, + ey: f64, + s_side: Side, + e_side: Side, + lateral_offset: f64, + t: f64, +) -> (f64, f64) { + let dx = (ex - sx).abs(); + let dy = (ey - sy).abs(); + let pull = dx.max(dy).max(40.0) * 0.5; + let (cs_x, cs_y) = control_point(sx, sy, s_side, pull, lateral_offset); + let (ce_x, ce_y) = control_point(ex, ey, e_side, pull, lateral_offset); + + let one_minus_t = 1.0 - t; + let b0 = one_minus_t * one_minus_t * one_minus_t; + let b1 = 3.0 * one_minus_t * one_minus_t * t; + let b2 = 3.0 * one_minus_t * t * t; + let b3 = t * t * t; + ( + b0 * sx + b1 * cs_x + b2 * ce_x + b3 * ex, + b0 * sy + b1 * cs_y + b2 * ce_y + b3 * ey, + ) +} + +/// Compute a cubic-Bezier control point relative to an anchor side. +/// `lateral_offset` perpendicular to the pull direction lets parallel edges +/// fan out so multi-edge bundles don't collapse onto a single arc. +fn control_point(x: f64, y: f64, side: Side, pull: f64, lateral_offset: f64) -> (f64, f64) { + match side { + Side::Left => (x - pull, y + lateral_offset), + Side::Right => (x + pull, y + lateral_offset), + Side::Top => (x + lateral_offset, y - pull), + Side::Bottom => (x + lateral_offset, y + pull), + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn render_empty() -> String { + format!( + "\n\ + \x20 \n\ + \x20 No tables to render\n\ + \n", + ff = FONT_FAMILY, + bg = BG, + ) +} + +fn escape_xml(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} diff --git a/crates/vespertide-cli/src/commands/erd/tests.rs b/crates/vespertide-cli/src/commands/erd/tests.rs new file mode 100644 index 00000000..1f74c920 --- /dev/null +++ b/crates/vespertide-cli/src/commands/erd/tests.rs @@ -0,0 +1,465 @@ +use insta::assert_snapshot; +use vespertide_core::schema::foreign_key::ForeignKeySyntax; +use vespertide_core::schema::primary_key::PrimaryKeySyntax; +use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, StrOrBoolOrArray, TableDef}; + +use super::dot::render_dot; +use super::mermaid::render_mermaid; +use super::svg::render_svg; +use super::{Cardinality, collect_foreign_key_relations, filter_tables_with_warnings}; + +fn integer() -> ColumnType { + ColumnType::Simple(SimpleColumnType::Integer) +} + +fn text() -> ColumnType { + ColumnType::Simple(SimpleColumnType::Text) +} + +fn column(name: &str, column_type: ColumnType) -> ColumnDef { + ColumnDef::new(name, column_type, false) +} + +fn primary_key(name: &str, column_type: ColumnType) -> ColumnDef { + column(name, column_type).primary_key(PrimaryKeySyntax::Bool(true)) +} + +fn foreign_key(name: &str, reference: &str) -> ColumnDef { + column(name, integer()).foreign_key(ForeignKeySyntax::String(reference.to_string())) +} + +fn nullable_foreign_key(name: &str, reference: &str) -> ColumnDef { + ColumnDef::new(name, integer(), true).foreign_key(ForeignKeySyntax::String(reference.into())) +} + +fn unique_foreign_key(name: &str, reference: &str) -> ColumnDef { + foreign_key(name, reference).unique(StrOrBoolOrArray::Bool(true)) +} + +fn table(name: &str, columns: Vec) -> TableDef { + TableDef { + name: name.to_string(), + description: None, + columns, + constraints: Vec::new(), + } +} + +fn normalize(table: &TableDef) -> TableDef { + table.normalize().unwrap() +} + +fn simple_schema() -> Vec { + vec![ + normalize(&table( + "user", + vec![ + primary_key("id", integer()), + column("email", text()), + column("name", text()), + ], + )), + normalize(&table( + "article", + vec![ + primary_key("id", integer()), + foreign_key("author_id", "user.id"), + column("title", text()), + ], + )), + ] +} + +fn cardinality_schema() -> Vec { + vec![ + normalize(&table("user", vec![primary_key("id", integer())])), + normalize(&table("tag", vec![primary_key("id", integer())])), + normalize(&table( + "article", + vec![ + primary_key("id", integer()), + foreign_key("author_id", "user.id"), + ], + )), + normalize(&table( + "profile", + vec![ + primary_key("id", integer()), + unique_foreign_key("user_id", "user.id"), + ], + )), + normalize(&table( + "photo", + vec![ + primary_key("id", integer()), + nullable_foreign_key("user_id", "user.id"), + ], + )), + normalize(&table( + "user_tag", + vec![ + primary_key("user_id", integer()) + .foreign_key(ForeignKeySyntax::String("user.id".into())), + primary_key("tag_id", integer()) + .foreign_key(ForeignKeySyntax::String("tag.id".into())), + ], + )), + ] +} + +fn filter_schema() -> Vec { + vec![ + normalize(&table("user", vec![primary_key("id", integer())])), + normalize(&table( + "media", + vec![ + primary_key("id", integer()), + foreign_key("owner_id", "user.id"), + ], + )), + normalize(&table( + "article", + vec![ + primary_key("id", integer()), + foreign_key("media_id", "media.id"), + ], + )), + normalize(&table( + "article_user", + vec![ + primary_key("article_id", integer()) + .foreign_key(ForeignKeySyntax::String("article.id".into())), + primary_key("user_id", integer()) + .foreign_key(ForeignKeySyntax::String("user.id".into())), + ], + )), + normalize(&table( + "comment", + vec![ + primary_key("id", integer()), + foreign_key("article_id", "article.id"), + ], + )), + ] +} + +fn table_names(tables: &[TableDef]) -> Vec<&str> { + tables.iter().map(|table| table.name.as_str()).collect() +} + +fn only_include(names: &[&str]) -> Vec { + names.iter().map(ToString::to_string).collect() +} + +fn relation_cardinality(schema: &[TableDef], child_table: &str) -> Cardinality { + collect_foreign_key_relations(schema) + .into_iter() + .find(|relation| relation.child_table == child_table) + .expect("relation exists") + .cardinality +} + +#[test] +fn test_render_mermaid_simple_two_tables() { + assert_snapshot!(render_mermaid(&simple_schema()), @r###" +erDiagram + user { + int id PK + string email + string name + } + article { + int id PK + int author_id FK + string title + } + user ||--o{ article : "author_id" +"###); +} + +#[test] +fn test_render_dot_simple_two_tables() { + assert_snapshot!(render_dot(&simple_schema()), @r###" +digraph { + rankdir=LR; + bgcolor="transparent"; + node [shape=record, fontname="Helvetica"]; + edge [fontname="Helvetica"]; + user [shape=record, label="{user|id: integer (PK)|email: text|name: text}"]; + article [shape=record, label="{article|id: integer (PK)|author_id: integer (FK)|title: text}"]; + article -> user [label="1:N: author_id -> id"]; +} +"###); +} + +#[test] +fn test_render_svg_produces_valid_svg() { + let svg = render_svg(&simple_schema()).unwrap(); + + assert!(svg.contains(" user [label="1:N: author_id -> id"]; + photo -> user [label="0..1:N: user_id -> id"]; + profile -> user [label="1:1: user_id -> id"]; + user_tag -> tag [label="M:N: tag_id -> id"]; + user_tag -> user [label="M:N: user_id -> id"]; +} +"###); +} + +#[test] +fn test_render_svg_cardinality_snapshot() { + assert_snapshot!(svg_cardinality_labels(&cardinality_schema()), @r###" +1:N +0..1:N +1:1 +M:N +M:N"###); +} + +#[test] +fn test_render_empty_schema() { + assert_snapshot!(render_mermaid(&[]), @r###" +erDiagram +"###); + + assert_snapshot!(render_dot(&[]), @r###" +digraph { + rankdir=LR; + bgcolor="transparent"; + node [shape=record, fontname="Helvetica"]; + edge [fontname="Helvetica"]; +} +"###); +} + +#[test] +fn test_render_with_composite_pk() { + let schema = vec![ + normalize(&table("user", vec![primary_key("id", integer())])), + normalize(&table("role", vec![primary_key("id", integer())])), + normalize(&table( + "user_role", + vec![ + primary_key("user_id", integer()) + .foreign_key(ForeignKeySyntax::String("user.id".into())), + primary_key("role_id", integer()) + .foreign_key(ForeignKeySyntax::String("role.id".into())), + ], + )), + ]; + + assert_snapshot!(render_mermaid(&schema), @r###" +erDiagram + user { + int id PK + } + role { + int id PK + } + user_role { + int user_id PK FK + int role_id PK FK + } + user_role }o--|| role : "role_id" + user_role }o--|| user : "user_id" +"###); +} + +fn svg_cardinality_labels(schema: &[TableDef]) -> String { + render_svg(schema) + .unwrap() + .lines() + .filter_map(|line| { + if !line.contains("edge-cardinality") { + return None; + } + let start = line.find('>')? + 1; + let end = line.find("")?; + Some(line[start..end].to_string()) + }) + .collect::>() + .join("\n") +} diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index da274037..d42a573b 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -4,11 +4,13 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::ValueEnum; use futures::future::try_join_all; +use rayon::prelude::*; use tokio::fs; use vespertide_config::VespertideConfig; use vespertide_core::TableDef; use vespertide_exporter::{Orm, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig}; +use crate::parallel_config::{EXPORT_RENDER_PAR_MIN_LEN, EXPORT_RENDER_PAR_THRESHOLD}; use crate::utils::load_config; #[derive(Copy, Clone, Debug, ValueEnum)] @@ -77,27 +79,29 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> // Create SeaORM exporter with config if needed let seaorm_exporter = SeaOrmExporterWithConfig::new(config.seaorm(), config.prefix()); + let render_context = ExportRenderContext { + target_root: &target_root, + all_tables: &all_tables, + module_paths: &module_paths, + crate_prefix: &crate_prefix, + seaorm_exporter: &seaorm_exporter, + orm_kind, + }; // Generate all entity code (CPU-bound, done synchronously) - let entities: Vec<(String, PathBuf, String)> = normalized_models - .iter() - .map(|(table, rel_path)| { - let code = match orm_kind { - Orm::SeaOrm => seaorm_exporter - .render_entity_with_schema_and_paths( - table, - &all_tables, - &module_paths, - &crate_prefix, - ) - .map_err(|e| anyhow::anyhow!(e)), - _ => render_entity_with_schema(orm_kind, table, &all_tables) - .map_err(|e| anyhow::anyhow!(e)), - }?; - let out_path = build_output_path(&target_root, rel_path, orm_kind); - Ok((table.name.clone(), out_path, code)) - }) - .collect::>>()?; + let entities: Vec<(String, PathBuf, String)> = + if normalized_models.len() < EXPORT_RENDER_PAR_THRESHOLD { + normalized_models + .iter() + .map(|model| render_export_entity(model, &render_context)) + .collect::>>()? + } else { + normalized_models + .par_iter() + .with_min_len(EXPORT_RENDER_PAR_MIN_LEN) + .map(|model| render_export_entity(model, &render_context)) + .collect::>>()? + }; // Write all files in parallel let write_futures: Vec<_> = entities @@ -136,6 +140,37 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> Ok(()) } +struct ExportRenderContext<'a> { + target_root: &'a Path, + all_tables: &'a [TableDef], + module_paths: &'a HashMap>, + crate_prefix: &'a str, + seaorm_exporter: &'a SeaOrmExporterWithConfig<'a>, + orm_kind: Orm, +} + +fn render_export_entity( + (table, rel_path): &(TableDef, PathBuf), + context: &ExportRenderContext<'_>, +) -> Result<(String, PathBuf, String)> { + let code = match context.orm_kind { + Orm::SeaOrm => context + .seaorm_exporter + .render_entity_with_schema_and_paths( + table, + context.all_tables, + context.module_paths, + context.crate_prefix, + ) + .map_err(|e| anyhow::anyhow!(e)), + _ => render_entity_with_schema(context.orm_kind, table, context.all_tables) + .map_err(|e| anyhow::anyhow!(e)), + }?; + let out_path = build_output_path(context.target_root, rel_path, context.orm_kind); + + Ok((table.name.clone(), out_path, code)) +} + /// Derive `crate::` prefix from the export directory path. /// /// For example: `src/models` → `crate::models`, `src/db/entities` → `crate::db::entities`. @@ -165,7 +200,7 @@ fn rel_path_to_module_segments(rel_path: &Path) -> Vec { if let std::path::Component::Normal(name) = component && let Some(s) = name.to_str() { - segments.push(sanitize_filename(s).to_string()); + segments.push(sanitize_filename(s).clone()); } } } @@ -178,7 +213,7 @@ fn rel_path_to_module_segments(rel_path: &Path) -> Vec { (file_name, "") }; let stem = stem.strip_suffix(".vespertide").unwrap_or(stem); - segments.push(sanitize_filename(stem).to_string()); + segments.push(sanitize_filename(stem).clone()); } segments @@ -302,7 +337,7 @@ fn build_output_path(root: &Path, rel_path: &Path, orm: Orm) -> PathBuf { } else { sanitized }; - out.set_file_name(format!("{}.{}", file_stem, ext)); + out.set_file_name(format!("{file_stem}.{ext}")); } out @@ -356,11 +391,7 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { }; let mut comps: Vec = path_stripped .components() - .filter_map(|c| { - c.as_os_str() - .to_str() - .map(|s| sanitize_filename(s).to_string()) - }) + .filter_map(|c| c.as_os_str().to_str().map(|s| sanitize_filename(s).clone())) .collect(); if comps.is_empty() { return Ok(()); @@ -412,7 +443,7 @@ async fn walk_models( continue; } let ext = path.extension().and_then(|s| s.to_str()); - if !matches!(ext, Some("json") | Some("yaml") | Some("yml")) { + if !matches!(ext, Some("json" | "yaml" | "yml")) { continue; } let content = fs::read_to_string(&path) diff --git a/crates/vespertide-cli/src/commands/mod.rs b/crates/vespertide-cli/src/commands/mod.rs index c6368806..d2caaa22 100644 --- a/crates/vespertide-cli/src/commands/mod.rs +++ b/crates/vespertide-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod diff; +pub mod erd; pub mod export; pub mod init; pub mod log; @@ -8,6 +9,7 @@ pub mod sql; pub mod status; pub use diff::cmd_diff; +pub use erd::cmd_erd_with_filters; pub use export::cmd_export; pub use init::cmd_init; pub use log::cmd_log; diff --git a/crates/vespertide-cli/src/commands/new.rs b/crates/vespertide-cli/src/commands/new.rs index 4dc5899b..b793b84f 100644 --- a/crates/vespertide-cli/src/commands/new.rs +++ b/crates/vespertide-cli/src/commands/new.rs @@ -6,7 +6,7 @@ use serde_json::Value; use tokio::fs; use vespertide_core::TableDef; -use crate::utils::load_config; +use crate::utils::{load_config, schema_url}; use vespertide_config::FileFormat; pub async fn cmd_new(name: String, format: Option) -> Result<()> { @@ -25,7 +25,7 @@ pub async fn cmd_new(name: String, format: Option) -> Result<()> { FileFormat::Yml => "yml", }; - let schema_url = schema_url_for(format); + let schema_url = schema_url("model.schema.json"); let path = dir.join(format!("{name}.vespertide.{ext}")); if path.exists() { bail!("model file already exists: {}", path.display()); @@ -51,20 +51,6 @@ pub async fn cmd_new(name: String, format: Option) -> Result<()> { Ok(()) } -fn schema_url_for(format: FileFormat) -> String { - // If not set, default to public raw GitHub schema location. - // Users can override via VESP_SCHEMA_BASE_URL. - let base = std::env::var("VESP_SCHEMA_BASE_URL").ok(); - let base = base.as_deref().unwrap_or( - "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas", - ); - let base = base.trim_end_matches('/'); - match format { - FileFormat::Json => format!("{}/model.schema.json", base), - FileFormat::Yaml | FileFormat::Yml => format!("{}/model.schema.json", base), - } -} - async fn write_json_with_schema(path: &Path, table: &TableDef, schema_url: &str) -> Result<()> { let mut value = serde_json::to_value(table).context("serialize table to json")?; if let Value::Object(ref mut map) = value { @@ -133,7 +119,7 @@ mod tests { async fn cmd_new_creates_json_with_schema() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(tmp.path()); - let expected_schema = schema_url_for(FileFormat::Json); + let expected_schema = schema_url("model.schema.json"); write_config(FileFormat::Json); cmd_new("users".into(), None).await.unwrap(); @@ -155,7 +141,7 @@ mod tests { async fn cmd_new_creates_yaml_with_schema() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(tmp.path()); - let expected_schema = schema_url_for(FileFormat::Yaml); + let expected_schema = schema_url("model.schema.json"); write_config(FileFormat::Yaml); cmd_new("orders".into(), None).await.unwrap(); @@ -181,7 +167,7 @@ mod tests { async fn cmd_new_creates_yml_with_schema() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(tmp.path()); - let expected_schema = schema_url_for(FileFormat::Yml); + let expected_schema = schema_url("model.schema.json"); write_config(FileFormat::Yml); cmd_new("products".into(), None).await.unwrap(); diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs deleted file mode 100644 index 1aa21a6e..00000000 --- a/crates/vespertide-cli/src/commands/revision.rs +++ /dev/null @@ -1,3417 +0,0 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::Path; - -use anyhow::{Context, Result}; -use chrono::Utc; -use colored::Colorize; -use dialoguer::{Confirm, Input, Select}; -use serde_json::Value; -use tokio::fs; -use vespertide_config::FileFormat; -use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef}; -use vespertide_planner::{ - EnumFillWithRequired, FillWithRequired, find_missing_enum_fill_with, find_missing_fill_with, - plan_next_migration, schema_from_plans, -}; - -use crate::utils::{ - load_config, load_migrations, load_models, migration_filename_with_format_and_pattern, -}; - -/// Parse fill_with arguments from CLI. -/// Format: table.column=value -fn parse_fill_with_args(args: &[String]) -> HashMap<(String, String), String> { - let mut map = HashMap::new(); - for arg in args { - if let Some((key, value)) = arg.split_once('=') - && let Some((table, column)) = key.split_once('.') - { - map.insert((table.to_string(), column.to_string()), value.to_string()); - } - } - map -} - -/// Parse delete_null_rows arguments from CLI. -/// Format: table.column -fn parse_delete_null_rows_args(args: &[String]) -> HashSet<(String, String)> { - let mut set = HashSet::new(); - for arg in args { - if let Some((table, column)) = arg.split_once('.') { - set.insert((table.to_string(), column.to_string())); - } - } - set -} - -/// Format the type info string for display. -/// Includes column type and default value hint if available. -fn format_type_info(column_type: &str, default_value: &str) -> String { - format!(" ({}, default: {})", column_type, default_value) -} - -/// Format a single fill_with item for display. -fn format_fill_with_item(table: &str, column: &str, type_info: &str, action_type: &str) -> String { - format!( - " {} {}.{}{}\n {} {}", - "•".bright_cyan(), - table.bright_white(), - column.bright_green(), - type_info.bright_black(), - "Action:".bright_black(), - action_type.bright_magenta() - ) -} - -/// Format the prompt string for interactive input. -fn format_fill_with_prompt(table: &str, column: &str) -> String { - format!( - " Enter fill value for {}.{}", - table.bright_white(), - column.bright_green() - ) -} - -/// Print the header for fill_with prompts. -fn print_fill_with_header() { - println!( - "\n{} {}", - "⚠".bright_yellow(), - "The following columns require fill_with values:".bright_yellow() - ); - println!("{}", "─".repeat(60).bright_black()); -} - -/// Print the footer for fill_with prompts. -fn print_fill_with_footer() { - println!("{}", "─".repeat(60).bright_black()); -} - -/// Print a fill_with item and return the formatted prompt. -fn print_fill_with_item_and_get_prompt( - table: &str, - column: &str, - column_type: &str, - default_value: &str, - action_type: &str, -) -> String { - let type_info = format_type_info(column_type, default_value); - let item_display = format_fill_with_item(table, column, &type_info, action_type); - println!("{}", item_display); - format_fill_with_prompt(table, column) -} - -/// Wrap a value with single quotes if it contains spaces and isn't already quoted. -fn wrap_if_spaces(value: String) -> String { - if value.is_empty() { - return value; - } - // Already wrapped with single quotes - if value.starts_with('\'') && value.ends_with('\'') { - return value; - } - // Contains spaces: wrap with single quotes - if value.contains(' ') { - return format!("'{}'", value); - } - value -} - -/// Prompt the user for a fill_with value using dialoguer. -/// This function wraps terminal I/O and cannot be unit tested without a real terminal. -#[cfg(not(tarpaulin_include))] -fn prompt_fill_with_value(prompt: &str, default: &str) -> Result { - let value: String = Input::new() - .with_prompt(prompt) - .default(default.to_string()) - .interact_text() - .context("failed to read input")?; - Ok(wrap_if_spaces(value)) -} - -/// Prompt the user to select an enum value using dialoguer Select. -/// Returns the selected value wrapped in single quotes for SQL. -#[cfg(not(tarpaulin_include))] -fn prompt_enum_value(prompt: &str, enum_values: &[String]) -> Result { - let selection = Select::new() - .with_prompt(prompt) - .items(enum_values) - .default(0) - .interact() - .context("failed to read selection")?; - // Return the selected value with single quotes for SQL enum literal - Ok(format!("'{}'", enum_values[selection])) -} - -/// Prompt for enum value selection and return bare (unquoted) value. -/// Used by `cmd_revision` for enum fill_with collection where BTreeMap stores bare names. -#[cfg(not(tarpaulin_include))] -fn prompt_enum_value_bare(prompt: &str, values: &[String]) -> Result { - let selected = prompt_enum_value(prompt, values)?; - Ok(strip_enum_quotes(selected)) -} - -/// Strip SQL single-quotes from an enum value string. -/// BTreeMap stores bare enum names; the SQL layer handles quoting via `Expr::val()`. -fn strip_enum_quotes(value: String) -> String { - value - .trim_start_matches('\'') - .trim_end_matches('\'') - .to_string() -} - -/// Collect fill_with values interactively for missing columns. -/// The `prompt_fn` parameter allows injecting a mock for testing. -/// The `enum_prompt_fn` parameter handles enum type columns with selection UI. -fn collect_fill_with_values( - missing: &[vespertide_planner::FillWithRequired], - fill_values: &mut HashMap<(String, String), String>, - prompt_fn: F, - enum_prompt_fn: E, -) -> Result<()> -where - F: Fn(&str, &str) -> Result, - E: Fn(&str, &[String]) -> Result, -{ - print_fill_with_header(); - - for item in missing { - let prompt = print_fill_with_item_and_get_prompt( - &item.table, - &item.column, - &item.column_type, - &item.default_value, - item.action_type, - ); - - let value = if let Some(enum_values) = &item.enum_values { - // Use selection UI for enum types - enum_prompt_fn(&prompt, enum_values)? - } else { - // Use text input with default pre-filled - prompt_fn(&prompt, &item.default_value)? - }; - fill_values.insert((item.table.clone(), item.column.clone()), value); - } - - print_fill_with_footer(); - Ok(()) -} - -/// Apply fill_with values to a migration plan. -fn apply_fill_with_to_plan( - plan: &mut MigrationPlan, - fill_values: &HashMap<(String, String), String>, -) { - for action in &mut plan.actions { - match action { - MigrationAction::AddColumn { - table, - column, - fill_with, - } => { - if fill_with.is_none() - && let Some(value) = fill_values.get(&(table.clone(), column.name.clone())) - { - *fill_with = Some(value.clone()); - } - } - MigrationAction::ModifyColumnNullable { - table, - column, - fill_with, - .. - } => { - if fill_with.is_none() - && let Some(value) = fill_values.get(&(table.clone(), column.clone())) - { - *fill_with = Some(value.clone()); - } - } - _ => {} - } - } -} - -/// Apply delete_null_rows flags to matching ModifyColumnNullable actions. -fn apply_delete_null_rows_to_plan( - plan: &mut MigrationPlan, - delete_set: &HashSet<(String, String)>, -) { - for action in &mut plan.actions { - if let MigrationAction::ModifyColumnNullable { - table, - column, - nullable, - delete_null_rows, - .. - } = action - && !*nullable - && delete_null_rows.is_none() - && delete_set.contains(&(table.clone(), column.clone())) - { - *delete_null_rows = Some(true); - } - } -} - -/// Handle interactive fill_with collection if there are missing values. -/// Returns the updated fill_values map after collecting from user. -#[cfg(test)] -fn handle_missing_fill_with( - plan: &mut MigrationPlan, - fill_values: &mut HashMap<(String, String), String>, - current_schema: &[TableDef], - prompt_fn: F, - enum_prompt_fn: E, -) -> Result<()> -where - F: Fn(&str, &str) -> Result, - E: Fn(&str, &[String]) -> Result, -{ - let missing = find_missing_fill_with(plan, current_schema); - - if !missing.is_empty() { - collect_fill_with_values(&missing, fill_values, prompt_fn, enum_prompt_fn)?; - - // Apply the collected fill_with values - apply_fill_with_to_plan(plan, fill_values); - } - - Ok(()) -} - -#[cfg(not(tarpaulin_include))] -fn prompt_delete_null_rows(table: &str, column: &str) -> Result { - let confirmed = Confirm::new() - .with_prompt(format!(" Delete rows where {}.{} IS NULL?", table, column)) - .default(false) - .interact() - .context("failed to read confirmation")?; - Ok(confirmed) -} - -fn handle_delete_null_rows( - plan: &mut MigrationPlan, - missing: &mut Vec, - delete_set: &HashSet<(String, String)>, - prompt_fn: F, -) -> Result<()> -where - F: Fn(&str, &str) -> Result, -{ - let mut to_delete = Vec::new(); - let mut remaining = Vec::new(); - - for item in missing.drain(..) { - if item.has_foreign_key && !delete_set.contains(&(item.table.clone(), item.column.clone())) - { - // FK column without CLI arg — prompt user - println!( - " {} {}.{} has a foreign key constraint — fill_with may not work.", - "\u{2022}".bright_cyan(), - item.table.bright_white(), - item.column.bright_green() - ); - if prompt_fn(&item.table, &item.column)? { - to_delete.push((item.table.clone(), item.column.clone())); - } else { - remaining.push(item); - } - } else if delete_set.contains(&(item.table.clone(), item.column.clone())) { - to_delete.push((item.table.clone(), item.column.clone())); - } else { - remaining.push(item); - } - } - - // Apply delete_null_rows to plan - for (table, column) in &to_delete { - for action in &mut plan.actions { - if let MigrationAction::ModifyColumnNullable { - table: t, - column: c, - delete_null_rows, - .. - } = action - && t == table - && c == column - { - *delete_null_rows = Some(true); - } - } - } - - *missing = remaining; - Ok(()) -} - -/// Collect enum fill_with values interactively for removed enum values. -/// The `enum_prompt_fn` parameter handles enum type columns with selection UI. -fn collect_enum_fill_with_values( - missing: &[EnumFillWithRequired], - enum_prompt_fn: E, -) -> Result)>> -where - E: Fn(&str, &[String]) -> Result, -{ - let mut results = Vec::new(); - - println!( - "\n{} {}", - "\u{26a0}".bright_yellow(), - "The following enum value removals require replacement mappings:".bright_yellow() - ); - println!("{}", "\u{2500}".repeat(60).bright_black()); - - for item in missing { - println!( - " {} {}.{}: removing enum values", - "\u{2022}".bright_cyan(), - item.table.bright_white(), - item.column.bright_green() - ); - - let mut mappings = BTreeMap::new(); - for removed in &item.removed_values { - let prompt = format!( - " Replace '{}' in {}.{} with", - removed.bright_red(), - item.table.bright_white(), - item.column.bright_green() - ); - let value = enum_prompt_fn(&prompt, &item.remaining_values)?; - mappings.insert(removed.clone(), value); - } - results.push((item.action_index, mappings)); - } - - println!("{}", "\u{2500}".repeat(60).bright_black()); - Ok(results) -} - -/// Apply collected enum fill_with mappings to the migration plan. -fn apply_enum_fill_with_to_plan( - plan: &mut MigrationPlan, - collected: &[(usize, BTreeMap)], -) { - for (action_index, mappings) in collected { - if let Some(MigrationAction::ModifyColumnType { fill_with, .. }) = - plan.actions.get_mut(*action_index) - { - match fill_with { - Some(existing) => { - existing.extend(mappings.clone()); - } - None => { - *fill_with = Some(mappings.clone()); - } - } - } - } -} - -/// Handle interactive enum fill_with collection if there are missing values. -fn handle_missing_enum_fill_with( - plan: &mut MigrationPlan, - current_schema: &[TableDef], - enum_prompt_fn: E, -) -> Result<()> -where - E: Fn(&str, &[String]) -> Result, -{ - let missing = find_missing_enum_fill_with(plan, current_schema); - - if !missing.is_empty() { - let collected = collect_enum_fill_with_values(&missing, enum_prompt_fn)?; - apply_enum_fill_with_to_plan(plan, &collected); - } - - Ok(()) -} - -/// Reason why a table needs to be recreated. -#[derive(Debug, Clone, PartialEq, Eq)] -enum RecreateReason { - /// A new non-nullable FK column is being added. - AddColumnWithFk, - /// A FK constraint is being added to an existing non-nullable column. - AddFkToExistingColumn, -} - -/// A table that needs to be recreated because of a non-nullable FK constraint issue. -#[derive(Debug, Clone, PartialEq, Eq)] -struct RecreateTableRequired { - table: String, - column: String, - reason: RecreateReason, -} - -/// Find actions that require table recreation due to non-nullable FK constraints. -/// -/// Two cases are detected: -/// 1. **AddColumn with FK**: A new non-nullable FK column is being added (no default). -/// 2. **AddConstraint(FK) on existing column**: A FK constraint is being added to an -/// existing non-nullable column without a default. -/// -/// In both cases, existing rows cannot satisfy the foreign key constraint, -/// so the table must be recreated (DeleteTable + CreateTable). -fn find_non_nullable_fk_add_columns( - plan: &MigrationPlan, - current_models: &[TableDef], -) -> Vec { - use std::collections::HashSet; - - // Collect FK columns from AddConstraint actions - let mut fk_columns: HashSet<(String, String)> = HashSet::new(); - for action in &plan.actions { - if let MigrationAction::AddConstraint { - table, - constraint: TableConstraint::ForeignKey { columns, .. }, - } = action - { - for col in columns { - fk_columns.insert((table.clone(), col.to_string())); - } - } - } - - // Collect columns being added in this migration (to distinguish new vs existing) - let mut added_columns: HashSet<(String, String)> = HashSet::new(); - for action in &plan.actions { - if let MigrationAction::AddColumn { table, column, .. } = action { - added_columns.insert((table.clone(), column.name.clone())); - } - } - - let mut result = Vec::new(); - - // Case 1: AddColumn with FK (new non-nullable FK column) - for action in &plan.actions { - if let MigrationAction::AddColumn { table, column, .. } = action { - let has_fk = column.foreign_key.is_some() - || fk_columns.contains(&(table.clone(), column.name.to_string())); - if has_fk && !column.nullable && column.default.is_none() { - result.push(RecreateTableRequired { - table: table.clone(), - column: column.name.clone(), - reason: RecreateReason::AddColumnWithFk, - }); - } - } - } - - // Case 2: AddConstraint(FK) on existing non-nullable column - for action in &plan.actions { - if let MigrationAction::AddConstraint { - table, - constraint: TableConstraint::ForeignKey { columns, .. }, - } = action - { - for col_name in columns { - // Skip if this column is being added in this migration (handled by Case 1) - if added_columns.contains(&(table.clone(), col_name.to_string())) { - continue; - } - // Look up column in current models to check nullability - if let Some(model) = current_models - .iter() - .find(|m| m.name.as_str() == table.as_str()) - && let Some(col_def) = model - .columns - .iter() - .find(|c| c.name.as_str() == col_name.as_str()) - && !col_def.nullable - && col_def.default.is_none() - { - result.push(RecreateTableRequired { - table: table.clone(), - column: col_name.clone(), - reason: RecreateReason::AddFkToExistingColumn, - }); - } - } - } - } - - result -} - -/// Prompt the user to confirm table recreation. -/// Returns true if the user confirms, false otherwise. -#[cfg(not(tarpaulin_include))] -fn prompt_recreate_tables(tables: &[RecreateTableRequired]) -> Result { - println!( - "\n{} {}", - "\u{26a0}".bright_yellow(), - "The following tables need to be RECREATED:".bright_yellow() - ); - println!("{}", "\u{2500}".repeat(60).bright_black()); - - for item in tables { - let reason_msg = match item.reason { - RecreateReason::AddColumnWithFk => "adding required FK column", - RecreateReason::AddFkToExistingColumn => "adding FK to existing required column", - }; - println!( - " {} Table {} \u{2014} {} {}", - "\u{2022}".bright_cyan(), - item.table.bright_white(), - reason_msg, - item.column.bright_green() - ); - } - - println!("{}", "\u{2500}".repeat(60).bright_black()); - println!( - " {} {}", - "\u{26a0}".bright_red(), - "ALL DATA in these tables will be DELETED.".bright_red() - ); - - let confirmed = Confirm::new() - .with_prompt(" Proceed with table recreation?") - .default(false) - .interact() - .context("failed to read confirmation")?; - - Ok(confirmed) -} - -/// Rewrite the migration plan to recreate tables instead of adding columns. -/// Removes all column/constraint actions targeting the recreated tables and replaces -/// them with DeleteTable + CreateTable using the full target model. -fn rewrite_plan_for_recreation( - plan: &mut MigrationPlan, - recreate_tables: &[RecreateTableRequired], - current_models: &[TableDef], -) { - use std::collections::HashSet; - - let tables_to_recreate: HashSet<&str> = - recreate_tables.iter().map(|r| r.table.as_str()).collect(); - - // Remove all column/constraint actions targeting recreated tables - plan.actions.retain(|action| { - let table = match action { - MigrationAction::AddColumn { table, .. } - | MigrationAction::DeleteColumn { table, .. } - | MigrationAction::RenameColumn { table, .. } - | MigrationAction::ModifyColumnType { table, .. } - | MigrationAction::ModifyColumnNullable { table, .. } - | MigrationAction::ModifyColumnDefault { table, .. } - | MigrationAction::ModifyColumnComment { table, .. } - | MigrationAction::AddConstraint { table, .. } - | MigrationAction::RemoveConstraint { table, .. } - | MigrationAction::ReplaceConstraint { table, .. } => Some(table.as_str()), - _ => None, - }; - table.is_none_or(|t| !tables_to_recreate.contains(t)) - }); - - // Add DeleteTable + CreateTable for each recreated table - for table_name in &tables_to_recreate { - if let Some(model) = current_models - .iter() - .find(|m| m.name.as_str() == *table_name) - { - plan.actions.push(MigrationAction::DeleteTable { - table: table_name.to_string(), - }); - plan.actions.push(MigrationAction::CreateTable { - table: model.name.clone(), - columns: model.columns.clone(), - constraints: model.constraints.clone(), - }); - } - } -} - -fn handle_recreate_requirements( - plan: &mut MigrationPlan, - current_models: &[TableDef], - prompt_fn: F, -) -> Result<()> -where - F: Fn(&[RecreateTableRequired]) -> Result, -{ - let recreate_tables = find_non_nullable_fk_add_columns(plan, current_models); - if recreate_tables.is_empty() { - return Ok(()); - } - - if !prompt_fn(&recreate_tables)? { - anyhow::bail!( - "Migration cancelled. To proceed without recreation, make the column nullable or add it with a default value that references an existing row." - ); - } - - rewrite_plan_for_recreation(plan, &recreate_tables, current_models); - Ok(()) -} - -pub async fn cmd_revision( - message: String, - fill_with_args: Vec, - delete_null_rows_args: Vec, -) -> Result<()> { - cmd_revision_core( - message, - fill_with_args, - delete_null_rows_args, - RevisionPromptFns { - recreate_prompt_fn: prompt_recreate_tables, - delete_null_rows_prompt_fn: prompt_delete_null_rows, - fill_with_prompt_fn: prompt_fill_with_value, - enum_prompt_fn: prompt_enum_value, - enum_bare_prompt_fn: prompt_enum_value_bare, - }, - ) - .await -} - -struct RevisionPromptFns { - recreate_prompt_fn: R, - delete_null_rows_prompt_fn: D, - fill_with_prompt_fn: F, - enum_prompt_fn: E, - enum_bare_prompt_fn: EB, -} - -async fn cmd_revision_core( - message: String, - fill_with_args: Vec, - delete_null_rows_args: Vec, - prompt_fns: RevisionPromptFns, -) -> Result<()> -where - R: Fn(&[RecreateTableRequired]) -> Result, - D: Fn(&str, &str) -> Result, - F: Fn(&str, &str) -> Result, - E: Fn(&str, &[String]) -> Result, - EB: Fn(&str, &[String]) -> Result, -{ - let RevisionPromptFns { - recreate_prompt_fn, - delete_null_rows_prompt_fn, - fill_with_prompt_fn, - enum_prompt_fn, - enum_bare_prompt_fn, - } = prompt_fns; - - let config = load_config()?; - let current_models = load_models(&config)?; - let applied_plans = load_migrations(&config)?; - - let mut plan = plan_next_migration(¤t_models, &applied_plans) - .map_err(|e| anyhow::anyhow!("planning error: {}", e))?; - - // Check for non-nullable FK changes that require table recreation. - handle_recreate_requirements(&mut plan, ¤t_models, recreate_prompt_fn)?; - - if plan.actions.is_empty() { - println!( - "{} {}", - "No changes detected.".bright_yellow(), - "Nothing to migrate.".bright_white() - ); - return Ok(()); - } - - // Reconstruct baseline schema for column type lookups - let baseline_schema = schema_from_plans(&applied_plans) - .map_err(|e| anyhow::anyhow!("schema reconstruction error: {}", e))?; - - // Parse CLI fill_with arguments - let mut fill_values = parse_fill_with_args(&fill_with_args); - let delete_set = parse_delete_null_rows_args(&delete_null_rows_args); - - // Apply any CLI-provided fill_with values first - apply_fill_with_to_plan(&mut plan, &fill_values); - apply_delete_null_rows_to_plan(&mut plan, &delete_set); - - // Find all missing fill_with values - let mut missing = find_missing_fill_with(&plan, &baseline_schema); - - // Handle FK columns with delete_null_rows option first - if !missing.is_empty() { - handle_delete_null_rows( - &mut plan, - &mut missing, - &delete_set, - delete_null_rows_prompt_fn, - )?; - } - - // Handle remaining missing fill_with values interactively - if !missing.is_empty() { - collect_fill_with_values( - &missing, - &mut fill_values, - fill_with_prompt_fn, - enum_prompt_fn, - )?; - apply_fill_with_to_plan(&mut plan, &fill_values); - } - - // Handle any missing enum fill_with values (for removed enum values) interactively - handle_missing_enum_fill_with(&mut plan, &baseline_schema, enum_bare_prompt_fn)?; - - plan.id = uuid::Uuid::new_v4().to_string(); - plan.comment = Some(message); - if plan.created_at.is_none() { - // Record creation time in RFC3339 (UTC). - plan.created_at = Some(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); - } - - let migrations_dir = config.migrations_dir(); - if !migrations_dir.exists() { - fs::create_dir_all(&migrations_dir) - .await - .context("create migrations directory")?; - } - - let format = config.migration_format(); - let filename = migration_filename_with_format_and_pattern( - plan.version, - plan.comment.as_deref(), - format, - config.migration_filename_pattern(), - ); - let path = migrations_dir.join(&filename); - - let schema_url = schema_url_for(format); - match format { - FileFormat::Json => write_json_with_schema(&path, &plan, &schema_url).await?, - FileFormat::Yaml | FileFormat::Yml => write_yaml(&path, &plan, &schema_url).await?, - } - - println!( - "{} {}", - "Created migration:".bright_green().bold(), - format!("{}", path.display()).bright_white() - ); - println!( - " {} {}", - "Version:".bright_cyan(), - plan.version.to_string().bright_magenta().bold() - ); - println!( - " {} {}", - "Actions:".bright_cyan(), - plan.actions.len().to_string().bright_yellow() - ); - if let Some(comment) = &plan.comment { - println!(" {} {}", "Comment:".bright_cyan(), comment.bright_white()); - } - - Ok(()) -} - -fn schema_url_for(format: FileFormat) -> String { - // If not set, default to public raw GitHub schema location. - // Users can override via VESP_SCHEMA_BASE_URL. - let base = std::env::var("VESP_SCHEMA_BASE_URL").ok(); - let base = base.as_deref().unwrap_or( - "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas", - ); - let base = base.trim_end_matches('/'); - match format { - FileFormat::Json => format!("{}/migration.schema.json", base), - FileFormat::Yaml | FileFormat::Yml => format!("{}/migration.schema.json", base), - } -} - -async fn write_json_with_schema(path: &Path, plan: &MigrationPlan, schema_url: &str) -> Result<()> { - let mut value = serde_json::to_value(plan).context("serialize migration plan to json")?; - if let Value::Object(ref mut map) = value { - map.insert("$schema".to_string(), Value::String(schema_url.to_string())); - } - let text = serde_json::to_string_pretty(&value).context("stringify json with schema")?; - fs::write(path, text) - .await - .with_context(|| format!("write file: {}", path.display()))?; - Ok(()) -} - -async fn write_yaml(path: &Path, plan: &MigrationPlan, schema_url: &str) -> Result<()> { - let mut value = serde_yaml::to_value(plan).context("serialize migration plan to yaml value")?; - if let serde_yaml::Value::Mapping(ref mut map) = value { - map.insert( - serde_yaml::Value::String("$schema".to_string()), - serde_yaml::Value::String(schema_url.to_string()), - ); - } - let text = serde_yaml::to_string(&value).context("serialize yaml with schema")?; - fs::write(path, text) - .await - .with_context(|| format!("write file: {}", path.display()))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::{env, fs as std_fs, path::PathBuf}; - use tempfile::tempdir; - use vespertide_config::{FileFormat, VespertideConfig}; - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint, TableDef}; - - struct CwdGuard { - original: PathBuf, - } - - impl CwdGuard { - fn new(dir: &PathBuf) -> Self { - let original = env::current_dir().unwrap(); - env::set_current_dir(dir).unwrap(); - Self { original } - } - } - - impl Drop for CwdGuard { - fn drop(&mut self) { - let _ = env::set_current_dir(&self.original); - } - } - - fn write_config() -> VespertideConfig { - write_config_with_format(None) - } - - fn write_config_with_format(fmt: Option) -> VespertideConfig { - let mut cfg = VespertideConfig::default(); - if let Some(f) = fmt { - cfg.migration_format = f; - } - let text = serde_json::to_string_pretty(&cfg).unwrap(); - std_fs::write("vespertide.json", text).unwrap(); - cfg - } - - fn write_model(name: &str) { - let models_dir = PathBuf::from("models"); - std_fs::create_dir_all(&models_dir).unwrap(); - let table = TableDef { - name: name.to_string(), - description: None, - columns: vec![ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }], - }; - let path = models_dir.join(format!("{name}.json")); - std_fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); - } - - #[tokio::test] - #[serial_test::serial] - async fn cmd_revision_writes_migration() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - - let cfg = write_config(); - write_model("users"); - std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); - - cmd_revision("init".into(), vec![], vec![]).await.unwrap(); - - let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()).unwrap().collect(); - assert!(!entries.is_empty()); - } - - #[tokio::test] - #[serial_test::serial] - async fn cmd_revision_no_changes_short_circuits() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - - let cfg = write_config(); - // no models, no migrations -> plan with no actions -> early return - assert!(cmd_revision("noop".into(), vec![], vec![]).await.is_ok()); - // migrations dir should not be created - assert!(!cfg.migrations_dir().exists()); - } - - #[tokio::test] - #[serial_test::serial] - async fn cmd_revision_writes_yaml_when_configured() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - - let cfg = write_config_with_format(Some(FileFormat::Yaml)); - write_model("users"); - // ensure migrations dir absent to exercise create_dir_all branch - if cfg.migrations_dir().exists() { - std_fs::remove_dir_all(cfg.migrations_dir()).unwrap(); - } - - cmd_revision("yaml".into(), vec![], vec![]).await.unwrap(); - - let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()).unwrap().collect(); - assert!(!entries.is_empty()); - let has_yaml = entries.iter().any(|e| { - e.as_ref() - .unwrap() - .path() - .extension() - .map(|s| s == "yaml") - .unwrap_or(false) - }); - assert!(has_yaml); - } - - #[test] - fn find_non_nullable_fk_add_column_detects_recreate() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: Some("1".into()), - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - let result = find_non_nullable_fk_add_columns(&plan, &[]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].table, "post"); - assert_eq!(result[0].column, "user_id"); - assert_eq!(result[0].reason, RecreateReason::AddColumnWithFk); - } - - #[test] - fn find_non_nullable_inline_fk_add_column_detects_recreate() { - use vespertide_core::schema::foreign_key::{ForeignKeyDef, ForeignKeySyntax}; - use vespertide_core::{ColumnDef, ColumnType, ReferenceAction, SimpleColumnType}; - - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef { - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::Cascade), - on_update: None, - })), - }), - fill_with: None, - }], - }; - - let result = find_non_nullable_fk_add_columns(&plan, &[]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].table, "post"); - assert_eq!(result[0].column, "user_id"); - assert_eq!(result[0].reason, RecreateReason::AddColumnWithFk); - } - - #[test] - fn find_nullable_fk_add_column_returns_empty() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: true, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - assert!(find_non_nullable_fk_add_columns(&plan, &[]).is_empty()); - } - - #[test] - fn find_non_nullable_no_fk_returns_empty() { - // Regular non-nullable column without FK should NOT trigger recreation - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id1".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - // Should return empty — this column needs fill_with but that's handled separately - assert!(find_non_nullable_fk_add_columns(&plan, &[]).is_empty()); - } - - #[test] - fn find_fk_on_existing_non_nullable_column_detects_recreate() { - // Adding FK constraint to an existing non-nullable column should trigger recreation - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }], - }; - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![], - }]; - let result = find_non_nullable_fk_add_columns(&plan, &models); - assert_eq!(result.len(), 1); - assert_eq!(result[0].table, "post"); - assert_eq!(result[0].column, "user_id"); - assert_eq!(result[0].reason, RecreateReason::AddFkToExistingColumn); - } - - #[test] - fn find_fk_on_existing_nullable_column_returns_empty() { - // Adding FK constraint to an existing nullable column should NOT trigger recreation - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }], - }; - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: true, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![], - }]; - assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); - } - - #[test] - fn find_fk_on_existing_column_with_default_returns_empty() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }], - }; - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: Some(true.into()), - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![], - }]; - - assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); - } - - #[test] - fn find_fk_on_existing_column_missing_from_model_returns_empty() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }], - }; - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ColumnDef { - name: "other_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![], - }]; - - assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); - } - - #[test] - fn rewrite_plan_replaces_actions_with_recreate() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - - let recreate = vec![RecreateTableRequired { - table: "post".into(), - column: "user_id".into(), - reason: RecreateReason::AddColumnWithFk, - }]; - - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![], - }]; - - rewrite_plan_for_recreation(&mut plan, &recreate, &models); - - assert_eq!(plan.actions.len(), 2); - assert!( - matches!(&plan.actions[0], MigrationAction::DeleteTable { table } if table == "post") - ); - assert!( - matches!(&plan.actions[1], MigrationAction::CreateTable { table, .. } if table == "post") - ); - } - - #[test] - fn rewrite_plan_keeps_non_table_actions() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![ - MigrationAction::RawSql { - sql: "select 1".into(), - }, - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - ], - }; - - let recreate = vec![RecreateTableRequired { - table: "post".into(), - column: "user_id".into(), - reason: RecreateReason::AddColumnWithFk, - }]; - - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![], - }]; - - rewrite_plan_for_recreation(&mut plan, &recreate, &models); - - assert!(matches!(&plan.actions[0], MigrationAction::RawSql { sql } if sql == "select 1")); - assert!( - matches!(&plan.actions[1], MigrationAction::DeleteTable { table } if table == "post") - ); - assert!( - matches!(&plan.actions[2], MigrationAction::CreateTable { table, .. } if table == "post") - ); - } - - #[test] - fn handle_recreate_requirements_returns_ok_when_no_fk() { - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::RawSql { - sql: "select 1".into(), - }], - }; - - handle_recreate_requirements(&mut plan, &[], |_| Ok(true)).unwrap(); - - assert_eq!(plan.actions.len(), 1); - } - - #[test] - fn handle_recreate_requirements_bails_when_prompt_rejected() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - - let err = handle_recreate_requirements(&mut plan, &[], |_| Ok(false)).unwrap_err(); - - assert!( - err.to_string() - .contains("Migration cancelled. To proceed without recreation") - ); - } - - #[test] - fn handle_recreate_requirements_empties_plan_when_model_missing() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - - handle_recreate_requirements(&mut plan, &[], |_| Ok(true)).unwrap(); - - assert!(plan.actions.is_empty()); - } - - #[test] - fn handle_recreate_requirements_rewrites_plan_when_model_exists() { - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![ - MigrationAction::AddColumn { - table: "post".into(), - column: Box::new(ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::AddConstraint { - table: "post".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - ], - }; - - let models = vec![TableDef { - name: "post".into(), - description: None, - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Uuid), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![], - }]; - - handle_recreate_requirements(&mut plan, &models, |_| Ok(true)).unwrap(); - - assert_eq!(plan.actions.len(), 2); - assert!( - matches!(&plan.actions[0], MigrationAction::DeleteTable { table } if table == "post") - ); - assert!( - matches!(&plan.actions[1], MigrationAction::CreateTable { table, .. } if table == "post") - ); - } - - #[test] - fn test_parse_fill_with_args() { - let args = vec![ - "users.email=default@example.com".to_string(), - "orders.status=pending".to_string(), - ]; - let result = parse_fill_with_args(&args); - - assert_eq!(result.len(), 2); - assert_eq!( - result.get(&("users".to_string(), "email".to_string())), - Some(&"default@example.com".to_string()) - ); - assert_eq!( - result.get(&("orders".to_string(), "status".to_string())), - Some(&"pending".to_string()) - ); - } - - #[test] - fn test_parse_fill_with_args_invalid_format() { - let args = vec![ - "invalid_format".to_string(), - "no_equals_sign".to_string(), - "users.email=valid".to_string(), - ]; - let result = parse_fill_with_args(&args); - - // Only the valid one should be parsed - assert_eq!(result.len(), 1); - assert_eq!( - result.get(&("users".to_string(), "email".to_string())), - Some(&"valid".to_string()) - ); - } - - #[test] - fn test_apply_fill_with_to_plan_add_column() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("users".to_string(), "email".to_string()), - "'default@example.com'".to_string(), - ); - - apply_fill_with_to_plan(&mut plan, &fill_values); - - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &Some("'default@example.com'".to_string())); - } - _ => panic!("Expected AddColumn action"), - } - } - - #[test] - fn test_apply_fill_with_to_plan_modify_column_nullable() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "users".into(), - column: "status".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("users".to_string(), "status".to_string()), - "'active'".to_string(), - ); - - apply_fill_with_to_plan(&mut plan, &fill_values); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { fill_with, .. } => { - assert_eq!(fill_with, &Some("'active'".to_string())); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_apply_fill_with_to_plan_skips_existing_fill_with() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: Some("'existing@example.com'".to_string()), - }], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("users".to_string(), "email".to_string()), - "'new@example.com'".to_string(), - ); - - apply_fill_with_to_plan(&mut plan, &fill_values); - - // Should keep existing value, not replace with new - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &Some("'existing@example.com'".to_string())); - } - _ => panic!("Expected AddColumn action"), - } - } - - #[test] - fn test_apply_fill_with_to_plan_no_match() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("orders".to_string(), "status".to_string()), - "'pending'".to_string(), - ); - - apply_fill_with_to_plan(&mut plan, &fill_values); - - // Should remain None since no match - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &None); - } - _ => panic!("Expected AddColumn action"), - } - } - - #[test] - fn test_apply_fill_with_to_plan_multiple_actions() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![ - MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "status".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }, - ], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("users".to_string(), "email".to_string()), - "'user@example.com'".to_string(), - ); - fill_values.insert( - ("orders".to_string(), "status".to_string()), - "'pending'".to_string(), - ); - - apply_fill_with_to_plan(&mut plan, &fill_values); - - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &Some("'user@example.com'".to_string())); - } - _ => panic!("Expected AddColumn action"), - } - - match &plan.actions[1] { - MigrationAction::ModifyColumnNullable { fill_with, .. } => { - assert_eq!(fill_with, &Some("'pending'".to_string())); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_apply_fill_with_to_plan_other_actions_ignored() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::DeleteColumn { - table: "users".into(), - column: "old_column".into(), - }], - }; - - let mut fill_values = HashMap::new(); - fill_values.insert( - ("users".to_string(), "old_column".to_string()), - "'value'".to_string(), - ); - - // Should not panic or modify anything - apply_fill_with_to_plan(&mut plan, &fill_values); - - match &plan.actions[0] { - MigrationAction::DeleteColumn { table, column } => { - assert_eq!(table, "users"); - assert_eq!(column, "old_column"); - } - _ => panic!("Expected DeleteColumn action"), - } - } - - #[test] - fn test_format_type_info_with_type_and_default() { - let result = format_type_info("integer", "0"); - assert_eq!(result, " (integer, default: 0)"); - } - - #[test] - fn test_format_type_info_with_type_only() { - let result = format_type_info("text", "''"); - assert_eq!(result, " (text, default: '')"); - } - - #[test] - fn test_format_fill_with_item() { - let result = format_fill_with_item("users", "email", " (Text)", "AddColumn"); - // The result should contain the table, column, type info, and action type - // Colors make exact matching difficult, but we can check structure - assert!(result.contains("users")); - assert!(result.contains("email")); - assert!(result.contains("(Text)")); - assert!(result.contains("AddColumn")); - assert!(result.contains("Action:")); - } - - #[test] - fn test_format_fill_with_item_empty_type_info() { - let result = format_fill_with_item("orders", "status", "", "ModifyColumnNullable"); - assert!(result.contains("orders")); - assert!(result.contains("status")); - assert!(result.contains("ModifyColumnNullable")); - } - - #[test] - fn test_format_fill_with_prompt() { - let result = format_fill_with_prompt("users", "email"); - assert!(result.contains("Enter fill value for")); - assert!(result.contains("users")); - assert!(result.contains("email")); - } - - #[test] - fn test_print_fill_with_item_and_get_prompt() { - // This function prints to stdout and returns the prompt string - let prompt = - print_fill_with_item_and_get_prompt("users", "email", "text", "''", "AddColumn"); - assert!(prompt.contains("Enter fill value for")); - assert!(prompt.contains("users")); - assert!(prompt.contains("email")); - } - - #[test] - fn test_print_fill_with_item_and_get_prompt_no_default() { - let prompt = print_fill_with_item_and_get_prompt( - "orders", - "status", - "text", - "''", - "ModifyColumnNullable", - ); - assert!(prompt.contains("Enter fill value for")); - assert!(prompt.contains("orders")); - assert!(prompt.contains("status")); - } - - #[test] - fn test_print_fill_with_item_and_get_prompt_with_default() { - let prompt = - print_fill_with_item_and_get_prompt("users", "age", "integer", "0", "AddColumn"); - assert!(prompt.contains("Enter fill value for")); - assert!(prompt.contains("users")); - assert!(prompt.contains("age")); - } - - #[test] - fn test_print_fill_with_header() { - // Just verify it doesn't panic - output goes to stdout - print_fill_with_header(); - } - - #[test] - fn test_print_fill_with_footer() { - // Just verify it doesn't panic - output goes to stdout - print_fill_with_footer(); - } - - // Mock enum prompt function for tests - returns first enum value quoted - fn mock_enum_prompt(_prompt: &str, values: &[String]) -> Result { - Ok(format!("'{}'", values[0])) - } - - #[test] - fn test_collect_fill_with_values_single_item() { - use vespertide_planner::FillWithRequired; - - let missing = vec![FillWithRequired { - action_index: 0, - table: "users".to_string(), - column: "email".to_string(), - action_type: "AddColumn", - column_type: "text".to_string(), - default_value: "''".to_string(), - enum_values: None, - has_foreign_key: false, - }]; - - let mut fill_values = HashMap::new(); - - // Mock prompt function that returns a fixed value - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - Ok("'test@example.com'".to_string()) - }; - - let result = - collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); - assert!(result.is_ok()); - assert_eq!(fill_values.len(), 1); - assert_eq!( - fill_values.get(&("users".to_string(), "email".to_string())), - Some(&"'test@example.com'".to_string()) - ); - } - - #[test] - fn test_collect_fill_with_values_multiple_items() { - use vespertide_planner::FillWithRequired; - - let missing = vec![ - FillWithRequired { - action_index: 0, - table: "users".to_string(), - column: "email".to_string(), - action_type: "AddColumn", - column_type: "text".to_string(), - default_value: "''".to_string(), - enum_values: None, - has_foreign_key: false, - }, - FillWithRequired { - action_index: 1, - table: "orders".to_string(), - column: "status".to_string(), - action_type: "ModifyColumnNullable", - column_type: "text".to_string(), - default_value: "''".to_string(), - enum_values: None, - has_foreign_key: false, - }, - ]; - - let mut fill_values = HashMap::new(); - - // Mock prompt function that returns different values based on call count - let call_count = std::cell::RefCell::new(0); - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - let mut count = call_count.borrow_mut(); - *count += 1; - match *count { - 1 => Ok("'user@example.com'".to_string()), - 2 => Ok("'pending'".to_string()), - _ => Ok("'default'".to_string()), - } - }; - - let result = - collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); - assert!(result.is_ok()); - assert_eq!(fill_values.len(), 2); - assert_eq!( - fill_values.get(&("users".to_string(), "email".to_string())), - Some(&"'user@example.com'".to_string()) - ); - assert_eq!( - fill_values.get(&("orders".to_string(), "status".to_string())), - Some(&"'pending'".to_string()) - ); - } - - #[test] - fn test_collect_fill_with_values_empty() { - let missing: Vec = vec![]; - let mut fill_values = HashMap::new(); - - // This function should handle empty list gracefully (though it won't be called in practice) - // But we can't test the header/footer without items since the function still prints them - // So we test with a mock that would fail if called - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - panic!("Should not be called for empty list"); - }; - - // Note: The function still prints header/footer even for empty list - // This is a design choice - in practice, cmd_revision won't call this with empty list - let result = - collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); - assert!(result.is_ok()); - assert!(fill_values.is_empty()); - } - - #[test] - fn test_collect_fill_with_values_prompt_error() { - use vespertide_planner::FillWithRequired; - - let missing = vec![FillWithRequired { - action_index: 0, - table: "users".to_string(), - column: "email".to_string(), - action_type: "AddColumn", - column_type: "text".to_string(), - default_value: "''".to_string(), - enum_values: None, - has_foreign_key: false, - }]; - - let mut fill_values = HashMap::new(); - - // Mock prompt function that returns an error - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - Err(anyhow::anyhow!("input cancelled")) - }; - - let result = - collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); - assert!(result.is_err()); - assert!(fill_values.is_empty()); - } - - #[test] - fn test_prompt_fill_with_value_function_exists() { - // This test verifies that prompt_fill_with_value has the correct signature. - // We cannot actually call it in tests because dialoguer::Input blocks waiting for terminal input. - // The function is excluded from coverage with #[cfg_attr(coverage_nightly, coverage(off))]. - let _: fn(&str, &str) -> Result = prompt_fill_with_value; - } - - #[test] - fn test_handle_missing_fill_with_collects_and_applies() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - - let mut fill_values = HashMap::new(); - - // Mock prompt function - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - Ok("'test@example.com'".to_string()) - }; - - let result = handle_missing_fill_with( - &mut plan, - &mut fill_values, - &[], - mock_prompt, - mock_enum_prompt, - ); - assert!(result.is_ok()); - - // Verify fill_with was applied to the plan - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &Some("'test@example.com'".to_string())); - } - _ => panic!("Expected AddColumn action"), - } - - // Verify fill_values map was updated - assert_eq!( - fill_values.get(&("users".to_string(), "email".to_string())), - Some(&"'test@example.com'".to_string()) - ); - } - - #[test] - fn test_handle_missing_fill_with_no_missing() { - use vespertide_core::MigrationPlan; - - // Plan with no missing fill_with values (nullable column) - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: true, // nullable, so no fill_with required - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - - let mut fill_values = HashMap::new(); - - // Mock prompt that should never be called - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - panic!("Should not be called when no missing fill_with values"); - }; - - let result = handle_missing_fill_with( - &mut plan, - &mut fill_values, - &[], - mock_prompt, - mock_enum_prompt, - ); - assert!(result.is_ok()); - assert!(fill_values.is_empty()); - } - - #[test] - fn test_handle_missing_fill_with_prompt_error() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }], - }; - - let mut fill_values = HashMap::new(); - - // Mock prompt that returns an error - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - Err(anyhow::anyhow!("user cancelled")) - }; - - let result = handle_missing_fill_with( - &mut plan, - &mut fill_values, - &[], - mock_prompt, - mock_enum_prompt, - ); - assert!(result.is_err()); - - // Plan should not be modified on error - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &None); - } - _ => panic!("Expected AddColumn action"), - } - } - - #[test] - fn test_handle_missing_fill_with_multiple_columns() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![ - MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }), - fill_with: None, - }, - MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "status".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }, - ], - }; - - let mut fill_values = HashMap::new(); - - // Mock prompt that returns different values based on call count - let call_count = std::cell::RefCell::new(0); - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - let mut count = call_count.borrow_mut(); - *count += 1; - match *count { - 1 => Ok("'user@example.com'".to_string()), - 2 => Ok("'pending'".to_string()), - _ => Ok("'default'".to_string()), - } - }; - - let result = handle_missing_fill_with( - &mut plan, - &mut fill_values, - &[], - mock_prompt, - mock_enum_prompt, - ); - assert!(result.is_ok()); - - // Verify both actions were updated - match &plan.actions[0] { - MigrationAction::AddColumn { fill_with, .. } => { - assert_eq!(fill_with, &Some("'user@example.com'".to_string())); - } - _ => panic!("Expected AddColumn action"), - } - - match &plan.actions[1] { - MigrationAction::ModifyColumnNullable { fill_with, .. } => { - assert_eq!(fill_with, &Some("'pending'".to_string())); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_collect_fill_with_values_enum_column() { - use vespertide_planner::FillWithRequired; - - let missing = vec![FillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "status".to_string(), - action_type: "AddColumn", - column_type: "enum".to_string(), - default_value: "''".to_string(), - enum_values: Some(vec![ - "pending".to_string(), - "confirmed".to_string(), - "shipped".to_string(), - ]), - has_foreign_key: false, - }]; - - let mut fill_values = HashMap::new(); - - // Mock prompt function that should NOT be called for enum columns - let mock_prompt = |_prompt: &str, _default: &str| -> Result { - panic!("Should not be called for enum columns"); - }; - - // Mock enum prompt that selects the second value - let mock_enum = |_prompt: &str, values: &[String]| -> Result { - // Select "confirmed" (index 1) - Ok(format!("'{}'", values[1])) - }; - - let result = collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum); - assert!(result.is_ok()); - assert_eq!(fill_values.len(), 1); - assert_eq!( - fill_values.get(&("orders".to_string(), "status".to_string())), - Some(&"'confirmed'".to_string()) - ); - } - - #[test] - fn test_wrap_if_spaces_empty() { - assert_eq!(wrap_if_spaces("".to_string()), ""); - } - - #[test] - fn test_wrap_if_spaces_no_spaces() { - assert_eq!(wrap_if_spaces("value".to_string()), "value"); - } - - #[test] - fn test_wrap_if_spaces_with_spaces() { - assert_eq!(wrap_if_spaces("my value".to_string()), "'my value'"); - } - - #[test] - fn test_wrap_if_spaces_already_quoted() { - assert_eq!( - wrap_if_spaces("'already quoted'".to_string()), - "'already quoted'" - ); - } - - #[test] - fn test_wrap_if_spaces_multiple_spaces() { - assert_eq!(wrap_if_spaces("a b c".to_string()), "'a b c'"); - } - - // ── enum fill_with tests ─────────────────────────────────────────── - - #[test] - fn test_collect_enum_fill_with_values_single_removal() { - use vespertide_planner::EnumFillWithRequired; - - let missing = vec![EnumFillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "status".to_string(), - removed_values: vec!["cancelled".to_string()], - remaining_values: vec!["pending".to_string(), "shipped".to_string()], - }]; - - // Mock prompt: always select first remaining value - let mock_enum = - |_prompt: &str, values: &[String]| -> Result { Ok(values[0].to_string()) }; - - let result = collect_enum_fill_with_values(&missing, mock_enum); - assert!(result.is_ok()); - let collected = result.unwrap(); - assert_eq!(collected.len(), 1); - assert_eq!(collected[0].0, 0); // action_index - assert_eq!( - collected[0].1.get("cancelled"), - Some(&"pending".to_string()) - ); - } - - #[test] - fn test_collect_enum_fill_with_values_multiple_removals() { - use vespertide_planner::EnumFillWithRequired; - - let missing = vec![EnumFillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "status".to_string(), - removed_values: vec!["cancelled".to_string(), "draft".to_string()], - remaining_values: vec!["pending".to_string(), "shipped".to_string()], - }]; - - // Mock prompt: always select second remaining value - let mock_enum = - |_prompt: &str, values: &[String]| -> Result { Ok(values[1].to_string()) }; - - let result = collect_enum_fill_with_values(&missing, mock_enum); - assert!(result.is_ok()); - let collected = result.unwrap(); - assert_eq!(collected[0].1.len(), 2); - assert_eq!( - collected[0].1.get("cancelled"), - Some(&"shipped".to_string()) - ); - assert_eq!(collected[0].1.get("draft"), Some(&"shipped".to_string())); - } - - #[test] - fn test_apply_enum_fill_with_to_plan() { - use vespertide_core::{ColumnType, ComplexColumnType, EnumValues}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::ModifyColumnType { - table: "orders".into(), - column: "status".into(), - new_type: ColumnType::Complex(ComplexColumnType::Enum { - name: "order_status".into(), - values: EnumValues::String(vec!["pending".into(), "shipped".into()]), - }), - fill_with: None, - }], - }; - - let mut mappings = BTreeMap::new(); - mappings.insert("cancelled".to_string(), "pending".to_string()); - let collected = vec![(0usize, mappings)]; - - apply_enum_fill_with_to_plan(&mut plan, &collected); - - if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { - let fw = fill_with.as_ref().expect("fill_with should be set"); - assert_eq!(fw.get("cancelled"), Some(&"pending".to_string())); - } else { - panic!("Expected ModifyColumnType"); - } - } - - #[test] - fn test_handle_missing_enum_fill_with_collects_and_applies() { - use vespertide_core::{ColumnDef, ColumnType, ComplexColumnType, EnumValues}; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::ModifyColumnType { - table: "orders".into(), - column: "status".into(), - new_type: ColumnType::Complex(ComplexColumnType::Enum { - name: "order_status".into(), - values: EnumValues::String(vec!["pending".into(), "shipped".into()]), - }), - fill_with: None, - }], - }; - - let baseline = vec![TableDef { - name: "orders".into(), - description: None, - columns: vec![ColumnDef { - name: "status".into(), - r#type: ColumnType::Complex(ComplexColumnType::Enum { - name: "order_status".into(), - values: EnumValues::String(vec![ - "pending".into(), - "shipped".into(), - "cancelled".into(), - ]), - }), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![], - }]; - - // Mock: always select first remaining value - let mock_enum = - |_prompt: &str, values: &[String]| -> Result { Ok(values[0].to_string()) }; - - let result = handle_missing_enum_fill_with(&mut plan, &baseline, mock_enum); - assert!(result.is_ok()); - - if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { - let fw = fill_with.as_ref().expect("fill_with should be populated"); - assert_eq!(fw.get("cancelled"), Some(&"pending".to_string())); - } else { - panic!("Expected ModifyColumnType"); - } - } - - #[test] - fn test_handle_missing_enum_fill_with_no_missing() { - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![], - }; - - let mock_enum = |_prompt: &str, _values: &[String]| -> Result { - panic!("Should not be called when nothing is missing"); - }; - - let result = handle_missing_enum_fill_with(&mut plan, &[], mock_enum); - assert!(result.is_ok()); - } - - #[test] - fn test_apply_enum_fill_with_to_plan_extends_existing() { - use vespertide_core::{ColumnType, ComplexColumnType, EnumValues}; - - // Start with a fill_with that already has one entry - let mut existing_fw = BTreeMap::new(); - existing_fw.insert("draft".to_string(), "pending".to_string()); - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 2, - actions: vec![MigrationAction::ModifyColumnType { - table: "orders".into(), - column: "status".into(), - new_type: ColumnType::Complex(ComplexColumnType::Enum { - name: "order_status".into(), - values: EnumValues::String(vec!["pending".into(), "shipped".into()]), - }), - fill_with: Some(existing_fw), - }], - }; - - // Collect additional mappings - let mut new_mappings = BTreeMap::new(); - new_mappings.insert("cancelled".to_string(), "shipped".to_string()); - let collected = vec![(0usize, new_mappings)]; - - apply_enum_fill_with_to_plan(&mut plan, &collected); - - if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { - let fw = fill_with.as_ref().expect("fill_with should be set"); - // Original entry preserved - assert_eq!(fw.get("draft"), Some(&"pending".to_string())); - // New entry added - assert_eq!(fw.get("cancelled"), Some(&"shipped".to_string())); - // Total 2 entries - assert_eq!(fw.len(), 2); - } else { - panic!("Expected ModifyColumnType"); - } - } - - #[test] - fn test_strip_enum_quotes_with_quotes() { - assert_eq!(strip_enum_quotes("'active'".to_string()), "active"); - } - - #[test] - fn test_strip_enum_quotes_bare_value() { - assert_eq!(strip_enum_quotes("active".to_string()), "active"); - } - - #[test] - fn test_strip_enum_quotes_empty() { - assert_eq!(strip_enum_quotes(String::new()), ""); - } - - #[test] - fn test_strip_enum_quotes_only_leading() { - assert_eq!(strip_enum_quotes("'active".to_string()), "active"); - } - - #[test] - fn test_strip_enum_quotes_only_trailing() { - assert_eq!(strip_enum_quotes("active'".to_string()), "active"); - } - - #[test] - fn test_parse_delete_null_rows_args() { - let args = vec!["users.email".to_string(), "orders.user_id".to_string()]; - let result = parse_delete_null_rows_args(&args); - assert_eq!(result.len(), 2); - assert!(result.contains(&("users".to_string(), "email".to_string()))); - assert!(result.contains(&("orders".to_string(), "user_id".to_string()))); - } - - #[test] - fn test_parse_delete_null_rows_args_invalid_format() { - let args = vec!["invalid_no_dot".to_string(), "valid.column".to_string()]; - let result = parse_delete_null_rows_args(&args); - assert_eq!(result.len(), 1); - assert!(result.contains(&("valid".to_string(), "column".to_string()))); - } - - #[test] - fn test_parse_delete_null_rows_args_empty() { - let result = parse_delete_null_rows_args(&[]); - assert!(result.is_empty()); - } - - #[test] - fn test_apply_delete_null_rows_to_plan() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut delete_set = HashSet::new(); - delete_set.insert(("orders".to_string(), "user_id".to_string())); - apply_delete_null_rows_to_plan(&mut plan, &delete_set); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &Some(true)); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_apply_delete_null_rows_to_plan_skips_nullable_true() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: true, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut delete_set = HashSet::new(); - delete_set.insert(("orders".to_string(), "user_id".to_string())); - apply_delete_null_rows_to_plan(&mut plan, &delete_set); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &None); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_apply_delete_null_rows_to_plan_skips_already_set() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: Some(false), - }], - }; - - let mut delete_set = HashSet::new(); - delete_set.insert(("orders".to_string(), "user_id".to_string())); - apply_delete_null_rows_to_plan(&mut plan, &delete_set); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &Some(false)); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_apply_delete_null_rows_to_plan_no_match() { - use vespertide_core::MigrationPlan; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut delete_set = HashSet::new(); - delete_set.insert(("other_table".to_string(), "other_col".to_string())); - apply_delete_null_rows_to_plan(&mut plan, &delete_set); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &None); - } - _ => panic!("Expected ModifyColumnNullable action"), - } - } - - #[test] - fn test_handle_delete_null_rows_fk_accepted() { - use vespertide_core::MigrationPlan; - use vespertide_planner::FillWithRequired; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut missing = vec![FillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "user_id".to_string(), - action_type: "ModifyColumnNullable", - column_type: "integer".to_string(), - default_value: "0".to_string(), - enum_values: None, - has_foreign_key: true, - }]; - - let delete_set = HashSet::new(); - - let mock_prompt = |_table: &str, _column: &str| -> Result { Ok(true) }; - - let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); - assert!(result.is_ok()); - - assert!(missing.is_empty()); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &Some(true)); - } - _ => panic!("Expected ModifyColumnNullable"), - } - } - - #[test] - fn test_handle_delete_null_rows_fk_declined() { - use vespertide_core::MigrationPlan; - use vespertide_planner::FillWithRequired; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut missing = vec![FillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "user_id".to_string(), - action_type: "ModifyColumnNullable", - column_type: "integer".to_string(), - default_value: "0".to_string(), - enum_values: None, - has_foreign_key: true, - }]; - - let delete_set = HashSet::new(); - - let mock_prompt = |_table: &str, _column: &str| -> Result { Ok(false) }; - - let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); - assert!(result.is_ok()); - - assert_eq!(missing.len(), 1); - assert_eq!(missing[0].table, "orders"); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &None); - } - _ => panic!("Expected ModifyColumnNullable"), - } - } - - #[test] - fn test_handle_delete_null_rows_cli_provided() { - use vespertide_core::MigrationPlan; - use vespertide_planner::FillWithRequired; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut missing = vec![FillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "user_id".to_string(), - action_type: "ModifyColumnNullable", - column_type: "integer".to_string(), - default_value: "0".to_string(), - enum_values: None, - has_foreign_key: false, - }]; - - let mut delete_set = HashSet::new(); - delete_set.insert(("orders".to_string(), "user_id".to_string())); - - let mock_prompt = |_table: &str, _column: &str| -> Result { - panic!("Should not be called for CLI-provided items"); - }; - - let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); - assert!(result.is_ok()); - - assert!(missing.is_empty()); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &Some(true)); - } - _ => panic!("Expected ModifyColumnNullable"), - } - } - - #[test] - fn test_handle_delete_null_rows_non_fk_passthrough() { - use vespertide_core::MigrationPlan; - use vespertide_planner::FillWithRequired; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "users".into(), - column: "email".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut missing = vec![FillWithRequired { - action_index: 0, - table: "users".to_string(), - column: "email".to_string(), - action_type: "ModifyColumnNullable", - column_type: "text".to_string(), - default_value: "''".to_string(), - enum_values: None, - has_foreign_key: false, - }]; - - let delete_set = HashSet::new(); - - let mock_prompt = |_table: &str, _column: &str| -> Result { - panic!("Should not be called for non-FK items"); - }; - - let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); - assert!(result.is_ok()); - - assert_eq!(missing.len(), 1); - assert_eq!(missing[0].column, "email"); - - match &plan.actions[0] { - MigrationAction::ModifyColumnNullable { - delete_null_rows, .. - } => { - assert_eq!(delete_null_rows, &None); - } - _ => panic!("Expected ModifyColumnNullable"), - } - } - - #[test] - fn test_handle_delete_null_rows_prompt_error() { - use vespertide_core::MigrationPlan; - use vespertide_planner::FillWithRequired; - - let mut plan = MigrationPlan { - id: String::new(), - comment: None, - created_at: None, - version: 1, - actions: vec![MigrationAction::ModifyColumnNullable { - table: "orders".into(), - column: "user_id".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }], - }; - - let mut missing = vec![FillWithRequired { - action_index: 0, - table: "orders".to_string(), - column: "user_id".to_string(), - action_type: "ModifyColumnNullable", - column_type: "integer".to_string(), - default_value: "0".to_string(), - enum_values: None, - has_foreign_key: true, - }]; - - let delete_set = HashSet::new(); - - let mock_prompt = |_table: &str, _column: &str| -> Result { - Err(anyhow::anyhow!("user cancelled")) - }; - - let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); - assert!(result.is_err()); - } - - /// Integration test: FK column nullable→not-null triggers handle_delete_null_rows (line 489) - #[tokio::test] - #[serial_test::serial] - async fn cmd_revision_core_handles_delete_null_rows_for_fk_column() { - use vespertide_core::MigrationPlan; - use vespertide_core::schema::foreign_key::{ForeignKeyDef, ForeignKeySyntax}; - - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - - let cfg = write_config(); - std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); - - // Write v1 migration: create "orders" table with nullable user_id - let v1 = MigrationPlan { - id: "v1-id".to_string(), - comment: Some("init".to_string()), - created_at: None, - version: 1, - actions: vec![MigrationAction::CreateTable { - table: "orders".into(), - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: true, // nullable in v1 - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }, - TableConstraint::ForeignKey { - name: Some("fk_orders__user_id".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - ], - }], - }; - let v1_path = cfg.migrations_dir().join("0001_init.vespertide.json"); - std_fs::write(&v1_path, serde_json::to_string_pretty(&v1).unwrap()).unwrap(); - - // Write updated model: user_id is now NOT NULL - let models_dir = PathBuf::from("models"); - std_fs::create_dir_all(&models_dir).unwrap(); - let users_model = TableDef { - name: "users".to_string(), - description: None, - columns: vec![ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }], - }; - std_fs::write( - models_dir.join("users.json"), - serde_json::to_string_pretty(&users_model).unwrap(), - ) - .unwrap(); - - let model = TableDef { - name: "orders".to_string(), - description: None, - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "user_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, // NOT NULL now - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef { - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - })), - }, - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }], - }; - std_fs::write( - models_dir.join("orders.json"), - serde_json::to_string_pretty(&model).unwrap(), - ) - .unwrap(); - - // Mock prompts - let recreate_prompt = |_: &[RecreateTableRequired]| -> Result { Ok(true) }; - let delete_prompt = |_table: &str, _col: &str| -> Result { Ok(true) }; - let fill_prompt = |_p: &str, _d: &str| -> Result { - panic!("fill prompt should not be called — FK handled by delete_null_rows"); - }; - let enum_prompt = |_p: &str, _v: &[String]| -> Result { - panic!("enum prompt should not be called"); - }; - let enum_bare_prompt = |_p: &str, _v: &[String]| -> Result { - panic!("enum bare prompt should not be called"); - }; - - let result = cmd_revision_core( - "make user_id required".into(), - vec![], - vec![], - RevisionPromptFns { - recreate_prompt_fn: recreate_prompt, - delete_null_rows_prompt_fn: delete_prompt, - fill_with_prompt_fn: fill_prompt, - enum_prompt_fn: enum_prompt, - enum_bare_prompt_fn: enum_bare_prompt, - }, - ) - .await; - - assert!( - result.is_ok(), - "cmd_revision_core failed: {:?}", - result.err() - ); - - // Verify migration was created - let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()) - .unwrap() - .filter_map(|e| e.ok()) - .collect(); - // Should have 2 files: v1 + new v2 - assert_eq!(entries.len(), 2); - } - - /// Integration test: non-FK column nullable→not-null triggers collect_fill_with_values (lines 494-495) - #[tokio::test] - #[serial_test::serial] - async fn cmd_revision_core_handles_fill_with_for_non_fk_column() { - use vespertide_core::MigrationPlan; - - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - - let cfg = write_config(); - std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); - - // Write v1 migration: create "users" table with nullable email - let v1 = MigrationPlan { - id: "v1-id".to_string(), - comment: Some("init".to_string()), - created_at: None, - version: 1, - actions: vec![MigrationAction::CreateTable { - table: "users".into(), - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: true, // nullable in v1 - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }], - }], - }; - let v1_path = cfg.migrations_dir().join("0001_init.vespertide.json"); - std_fs::write(&v1_path, serde_json::to_string_pretty(&v1).unwrap()).unwrap(); - - // Write updated model: email is now NOT NULL (no default) - let models_dir = PathBuf::from("models"); - std_fs::create_dir_all(&models_dir).unwrap(); - let model = TableDef { - name: "users".to_string(), - description: None, - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: false, // NOT NULL now - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }], - }; - std_fs::write( - models_dir.join("users.json"), - serde_json::to_string_pretty(&model).unwrap(), - ) - .unwrap(); - - // Mock prompts - let recreate_prompt = |_: &[RecreateTableRequired]| -> Result { Ok(true) }; - let delete_prompt = |_table: &str, _col: &str| -> Result { Ok(false) }; - let fill_prompt = |_p: &str, _d: &str| -> Result { Ok("'unknown'".to_string()) }; - let enum_prompt = |_p: &str, _v: &[String]| -> Result { - panic!("enum prompt should not be called"); - }; - let enum_bare_prompt = |_p: &str, _v: &[String]| -> Result { - panic!("enum bare prompt should not be called"); - }; - - let result = cmd_revision_core( - "make email required".into(), - vec![], - vec![], - RevisionPromptFns { - recreate_prompt_fn: recreate_prompt, - delete_null_rows_prompt_fn: delete_prompt, - fill_with_prompt_fn: fill_prompt, - enum_prompt_fn: enum_prompt, - enum_bare_prompt_fn: enum_bare_prompt, - }, - ) - .await; - - assert!( - result.is_ok(), - "cmd_revision_core failed: {:?}", - result.err() - ); - - // Verify migration was written with fill_with - let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()) - .unwrap() - .filter_map(|e| e.ok()) - .collect(); - assert_eq!(entries.len(), 2); - - // Read the v2 migration and verify fill_with was applied - let v2_path = entries - .iter() - .find(|e| e.file_name().to_string_lossy().contains("0002")) - .expect("v2 migration not found"); - let v2_content = std_fs::read_to_string(v2_path.path()).unwrap(); - assert!( - v2_content.contains("fill_with"), - "Expected fill_with in migration, got: {}", - v2_content - ); - } -} diff --git a/crates/vespertide-cli/src/commands/revision/emit.rs b/crates/vespertide-cli/src/commands/revision/emit.rs new file mode 100644 index 00000000..69cd8ea9 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/emit.rs @@ -0,0 +1,255 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use anyhow::Result; +use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef}; + +/// Apply `fill_with` values to a migration plan. +pub(super) fn apply_fill_with_to_plan( + plan: &mut MigrationPlan, + fill_values: &HashMap<(String, String), String>, +) { + for action in &mut plan.actions { + match action { + MigrationAction::AddColumn { + table, + column, + fill_with, + } => { + if fill_with.is_none() + && let Some(value) = fill_values.get(&(table.clone(), column.name.clone())) + { + *fill_with = Some(value.clone()); + } + } + MigrationAction::ModifyColumnNullable { + table, + column, + fill_with, + .. + } => { + if fill_with.is_none() + && let Some(value) = fill_values.get(&(table.clone(), column.clone())) + { + *fill_with = Some(value.clone()); + } + } + _ => {} + } + } +} + +/// Apply `delete_null_rows` flags to matching `ModifyColumnNullable` actions. +pub(super) fn apply_delete_null_rows_to_plan( + plan: &mut MigrationPlan, + delete_set: &HashSet<(String, String)>, +) { + for action in &mut plan.actions { + if let MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + delete_null_rows, + .. + } = action + && !*nullable + && delete_null_rows.is_none() + && delete_set.contains(&(table.clone(), column.clone())) + { + *delete_null_rows = Some(true); + } + } +} +/// Apply collected enum `fill_with` mappings to the migration plan. +pub(super) fn apply_enum_fill_with_to_plan( + plan: &mut MigrationPlan, + collected: &[(usize, BTreeMap)], +) { + for (action_index, mappings) in collected { + if let Some(MigrationAction::ModifyColumnType { fill_with, .. }) = + plan.actions.get_mut(*action_index) + { + match fill_with { + Some(existing) => { + existing.extend(mappings.clone()); + } + None => { + *fill_with = Some(mappings.clone()); + } + } + } + } +} +/// Reason why a table needs to be recreated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum RecreateReason { + /// A new non-nullable FK column is being added. + AddColumnWithFk, + /// A FK constraint is being added to an existing non-nullable column. + AddFkToExistingColumn, +} + +/// A table that needs to be recreated because of a non-nullable FK constraint issue. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RecreateTableRequired { + pub(super) table: String, + pub(super) column: String, + pub(super) reason: RecreateReason, +} + +/// Find actions that require table recreation due to non-nullable FK constraints. +/// +/// Two cases are detected: +/// 1. **`AddColumn` with FK**: A new non-nullable FK column is being added (no default). +/// 2. **AddConstraint(FK) on existing column**: A FK constraint is being added to an +/// existing non-nullable column without a default. +/// +/// In both cases, existing rows cannot satisfy the foreign key constraint, +/// so the table must be recreated (`DeleteTable` + `CreateTable`). +pub(super) fn find_non_nullable_fk_add_columns( + plan: &MigrationPlan, + current_models: &[TableDef], +) -> Vec { + // Collect FK columns from AddConstraint actions; lookup-only, ordering unused. + let mut fk_columns: HashSet<(String, String)> = HashSet::new(); + for action in &plan.actions { + if let MigrationAction::AddConstraint { + table, + constraint: TableConstraint::ForeignKey { columns, .. }, + } = action + { + for col in columns { + fk_columns.insert((table.clone(), col.clone())); + } + } + } + + // Collect columns being added in this migration (to distinguish new vs existing); lookup-only, ordering unused. + let mut added_columns: HashSet<(String, String)> = HashSet::new(); + for action in &plan.actions { + if let MigrationAction::AddColumn { table, column, .. } = action { + added_columns.insert((table.clone(), column.name.clone())); + } + } + + let mut result = Vec::new(); + + // Case 1: AddColumn with FK (new non-nullable FK column) + for action in &plan.actions { + if let MigrationAction::AddColumn { table, column, .. } = action { + let has_fk = column.foreign_key.is_some() + || fk_columns.contains(&(table.clone(), column.name.clone())); + if has_fk && !column.nullable && column.default.is_none() { + result.push(RecreateTableRequired { + table: table.clone(), + column: column.name.clone(), + reason: RecreateReason::AddColumnWithFk, + }); + } + } + } + + // Case 2: AddConstraint(FK) on existing non-nullable column + for action in &plan.actions { + if let MigrationAction::AddConstraint { + table, + constraint: TableConstraint::ForeignKey { columns, .. }, + } = action + { + for col_name in columns { + // Skip if this column is being added in this migration (handled by Case 1) + if added_columns.contains(&(table.clone(), col_name.clone())) { + continue; + } + // Look up column in current models to check nullability + if let Some(model) = current_models + .iter() + .find(|m| m.name.as_str() == table.as_str()) + && let Some(col_def) = model + .columns + .iter() + .find(|c| c.name.as_str() == col_name.as_str()) + && !col_def.nullable + && col_def.default.is_none() + { + result.push(RecreateTableRequired { + table: table.clone(), + column: col_name.clone(), + reason: RecreateReason::AddFkToExistingColumn, + }); + } + } + } + } + + result +} + +/// Rewrite the migration plan to recreate tables instead of adding columns. +/// Removes all column/constraint actions targeting the recreated tables and replaces +/// them with `DeleteTable` + `CreateTable` using the full target model. +pub(super) fn rewrite_plan_for_recreation( + plan: &mut MigrationPlan, + recreate_tables: &[RecreateTableRequired], + current_models: &[TableDef], +) { + let tables_to_recreate: BTreeSet<&str> = + recreate_tables.iter().map(|r| r.table.as_str()).collect(); + + // Remove all column/constraint actions targeting recreated tables + plan.actions.retain(|action| { + let table = match action { + MigrationAction::AddColumn { table, .. } + | MigrationAction::DeleteColumn { table, .. } + | MigrationAction::RenameColumn { table, .. } + | MigrationAction::ModifyColumnType { table, .. } + | MigrationAction::ModifyColumnNullable { table, .. } + | MigrationAction::ModifyColumnDefault { table, .. } + | MigrationAction::ModifyColumnComment { table, .. } + | MigrationAction::AddConstraint { table, .. } + | MigrationAction::RemoveConstraint { table, .. } + | MigrationAction::ReplaceConstraint { table, .. } => Some(table.as_str()), + _ => None, + }; + table.is_none_or(|t| !tables_to_recreate.contains(t)) + }); + + // Add DeleteTable + CreateTable for each recreated table + for table_name in &tables_to_recreate { + if let Some(model) = current_models + .iter() + .find(|m| m.name.as_str() == *table_name) + { + plan.actions.push(MigrationAction::DeleteTable { + table: table_name.to_string(), + }); + plan.actions.push(MigrationAction::CreateTable { + table: model.name.clone(), + columns: model.columns.clone(), + constraints: model.constraints.clone(), + }); + } + } +} + +pub(super) fn handle_recreate_requirements( + plan: &mut MigrationPlan, + current_models: &[TableDef], + prompt_fn: F, +) -> Result<()> +where + F: Fn(&[RecreateTableRequired]) -> Result, +{ + let recreate_tables = find_non_nullable_fk_add_columns(plan, current_models); + if recreate_tables.is_empty() { + return Ok(()); + } + + if !prompt_fn(&recreate_tables)? { + anyhow::bail!( + "Migration cancelled. To proceed without recreation, make the column nullable or add it with a default value that references an existing row." + ); + } + + rewrite_plan_for_recreation(plan, &recreate_tables, current_models); + Ok(()) +} diff --git a/crates/vespertide-cli/src/commands/revision/mod.rs b/crates/vespertide-cli/src/commands/revision/mod.rs new file mode 100644 index 00000000..03364e34 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/mod.rs @@ -0,0 +1,161 @@ +use anyhow::Result; +use chrono::Utc; +use colored::Colorize; +use vespertide_planner::{find_missing_fill_with, plan_next_migration, schema_from_plans}; + +use crate::utils::{load_config, load_migrations, load_models}; + +mod emit; +mod parse; +mod prompts; +mod write; + +#[cfg(test)] +mod tests; + +#[cfg(test)] +use emit::*; +#[cfg(test)] +use parse::*; +#[cfg(test)] +use prompts::*; + +use emit::RecreateTableRequired; + +pub async fn cmd_revision( + message: String, + fill_with_args: Vec, + delete_null_rows_args: Vec, +) -> Result<()> { + cmd_revision_core( + message, + fill_with_args, + delete_null_rows_args, + RevisionPromptFns { + recreate: prompts::prompt_recreate_tables, + delete_null_rows: prompts::prompt_delete_null_rows, + fill_with: prompts::prompt_fill_with_value, + enum_quoted: prompts::prompt_enum_value, + enum_bare: prompts::prompt_enum_value_bare, + }, + ) + .await +} + +struct RevisionPromptFns { + recreate: R, + delete_null_rows: D, + fill_with: F, + enum_quoted: E, + enum_bare: EB, +} + +async fn cmd_revision_core( + message: String, + fill_with_args: Vec, + delete_null_rows_args: Vec, + prompt_fns: RevisionPromptFns, +) -> Result<()> +where + R: Fn(&[RecreateTableRequired]) -> Result, + D: Fn(&str, &str) -> Result, + F: Fn(&str, &str) -> Result, + E: Fn(&str, &[String]) -> Result, + EB: Fn(&str, &[String]) -> Result, +{ + let RevisionPromptFns { + recreate: recreate_prompt_fn, + delete_null_rows: delete_null_rows_prompt_fn, + fill_with: fill_with_prompt_fn, + enum_quoted: enum_prompt_fn, + enum_bare: enum_bare_prompt_fn, + } = prompt_fns; + + let config = load_config()?; + let current_models = load_models(&config)?; + let applied_plans = load_migrations(&config)?; + + let mut plan = plan_next_migration(¤t_models, &applied_plans) + .map_err(|e| anyhow::anyhow!("planning error: {e}"))?; + + // Check for non-nullable FK changes that require table recreation. + emit::handle_recreate_requirements(&mut plan, ¤t_models, recreate_prompt_fn)?; + + if plan.actions.is_empty() { + println!( + "{} {}", + "No changes detected.".bright_yellow(), + "Nothing to migrate.".bright_white() + ); + return Ok(()); + } + + // Reconstruct baseline schema for column type lookups + let baseline_schema = schema_from_plans(&applied_plans) + .map_err(|e| anyhow::anyhow!("schema reconstruction error: {e}"))?; + + // Parse CLI fill_with arguments + let mut fill_values = parse::parse_fill_with_args(&fill_with_args); + let delete_set = parse::parse_delete_null_rows_args(&delete_null_rows_args); + + // Apply any CLI-provided fill_with values first + emit::apply_fill_with_to_plan(&mut plan, &fill_values); + emit::apply_delete_null_rows_to_plan(&mut plan, &delete_set); + + // Find all missing fill_with values + let mut missing = find_missing_fill_with(&plan, &baseline_schema); + + // Handle FK columns with delete_null_rows option first + if !missing.is_empty() { + prompts::handle_delete_null_rows( + &mut plan, + &mut missing, + &delete_set, + delete_null_rows_prompt_fn, + )?; + } + + // Handle remaining missing fill_with values interactively + if !missing.is_empty() { + prompts::collect_fill_with_values( + &missing, + &mut fill_values, + fill_with_prompt_fn, + enum_prompt_fn, + )?; + emit::apply_fill_with_to_plan(&mut plan, &fill_values); + } + + // Handle any missing enum fill_with values (for removed enum values) interactively + prompts::handle_missing_enum_fill_with(&mut plan, &baseline_schema, enum_bare_prompt_fn)?; + + plan.id = uuid::Uuid::new_v4().to_string(); + plan.comment = Some(message); + if plan.created_at.is_none() { + // Record creation time in RFC3339 (UTC). + plan.created_at = Some(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); + } + + let path = write::write_migration_file(&config, &plan).await?; + + println!( + "{} {}", + "Created migration:".bright_green().bold(), + format!("{}", path.display()).bright_white() + ); + println!( + " {} {}", + "Version:".bright_cyan(), + plan.version.to_string().bright_magenta().bold() + ); + println!( + " {} {}", + "Actions:".bright_cyan(), + plan.actions.len().to_string().bright_yellow() + ); + if let Some(comment) = &plan.comment { + println!(" {} {}", "Comment:".bright_cyan(), comment.bright_white()); + } + + Ok(()) +} diff --git a/crates/vespertide-cli/src/commands/revision/parse.rs b/crates/vespertide-cli/src/commands/revision/parse.rs new file mode 100644 index 00000000..0db77727 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/parse.rs @@ -0,0 +1,27 @@ +use std::collections::{HashMap, HashSet}; + +/// Parse `fill_with` arguments from CLI. +/// Format: table.column=value +pub(super) fn parse_fill_with_args(args: &[String]) -> HashMap<(String, String), String> { + let mut map = HashMap::new(); + for arg in args { + if let Some((key, value)) = arg.split_once('=') + && let Some((table, column)) = key.split_once('.') + { + map.insert((table.to_string(), column.to_string()), value.to_string()); + } + } + map +} + +/// Parse `delete_null_rows` arguments from CLI. +/// Format: table.column +pub(super) fn parse_delete_null_rows_args(args: &[String]) -> HashSet<(String, String)> { + let mut set = HashSet::new(); + for arg in args { + if let Some((table, column)) = arg.split_once('.') { + set.insert((table.to_string(), column.to_string())); + } + } + set +} diff --git a/crates/vespertide-cli/src/commands/revision/prompts.rs b/crates/vespertide-cli/src/commands/revision/prompts.rs new file mode 100644 index 00000000..29418ced --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/prompts.rs @@ -0,0 +1,364 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use anyhow::{Context, Result}; +use colored::Colorize; +use dialoguer::{Confirm, Input, Select}; +use vespertide_core::{MigrationAction, MigrationPlan, TableDef}; +#[cfg(test)] +use vespertide_planner::find_missing_fill_with; +use vespertide_planner::{EnumFillWithRequired, FillWithRequired, find_missing_enum_fill_with}; + +#[cfg(test)] +use super::emit::apply_fill_with_to_plan; +use super::emit::{RecreateReason, RecreateTableRequired, apply_enum_fill_with_to_plan}; + +/// Format the type info string for display. +/// Includes column type and default value hint if available. +pub(super) fn format_type_info(column_type: &str, default_value: &str) -> String { + format!(" ({column_type}, default: {default_value})") +} + +/// Format a single `fill_with` item for display. +pub(super) fn format_fill_with_item( + table: &str, + column: &str, + type_info: &str, + action_type: &str, +) -> String { + format!( + " {} {}.{}{}\n {} {}", + "•".bright_cyan(), + table.bright_white(), + column.bright_green(), + type_info.bright_black(), + "Action:".bright_black(), + action_type.bright_magenta() + ) +} + +/// Format the prompt string for interactive input. +pub(super) fn format_fill_with_prompt(table: &str, column: &str) -> String { + format!( + " Enter fill value for {}.{}", + table.bright_white(), + column.bright_green() + ) +} + +/// Print the header for `fill_with` prompts. +pub(super) fn print_fill_with_header() { + println!( + "\n{} {}", + "⚠".bright_yellow(), + "The following columns require fill_with values:".bright_yellow() + ); + println!("{}", "─".repeat(60).bright_black()); +} + +/// Print the footer for `fill_with` prompts. +pub(super) fn print_fill_with_footer() { + println!("{}", "─".repeat(60).bright_black()); +} + +/// Print a `fill_with` item and return the formatted prompt. +pub(super) fn print_fill_with_item_and_get_prompt( + table: &str, + column: &str, + column_type: &str, + default_value: &str, + action_type: &str, +) -> String { + let type_info = format_type_info(column_type, default_value); + let item_display = format_fill_with_item(table, column, &type_info, action_type); + println!("{item_display}"); + format_fill_with_prompt(table, column) +} + +/// Wrap a value with single quotes if it contains spaces and isn't already quoted. +pub(super) fn wrap_if_spaces(value: String) -> String { + if value.is_empty() { + return value; + } + // Already wrapped with single quotes + if value.starts_with('\'') && value.ends_with('\'') { + return value; + } + // Contains spaces: wrap with single quotes + if value.contains(' ') { + return format!("'{value}'"); + } + value +} + +/// Prompt the user for a `fill_with` value using dialoguer. +/// This function wraps terminal I/O and cannot be unit tested without a real terminal. +#[cfg(not(tarpaulin_include))] +pub(super) fn prompt_fill_with_value(prompt: &str, default: &str) -> Result { + let value: String = Input::new() + .with_prompt(prompt) + .default(default.to_string()) + .interact_text() + .context("failed to read input")?; + Ok(wrap_if_spaces(value)) +} + +/// Prompt the user to select an enum value using dialoguer Select. +/// Returns the selected value wrapped in single quotes for SQL. +#[cfg(not(tarpaulin_include))] +pub(super) fn prompt_enum_value(prompt: &str, enum_values: &[String]) -> Result { + let selection = Select::new() + .with_prompt(prompt) + .items(enum_values) + .default(0) + .interact() + .context("failed to read selection")?; + // Return the selected value with single quotes for SQL enum literal + Ok(format!("'{}'", enum_values[selection])) +} + +/// Prompt for enum value selection and return bare (unquoted) value. +/// Used by `cmd_revision` for enum `fill_with` collection where `BTreeMap` stores bare names. +#[cfg(not(tarpaulin_include))] +pub(super) fn prompt_enum_value_bare(prompt: &str, values: &[String]) -> Result { + let selected = prompt_enum_value(prompt, values)?; + Ok(strip_enum_quotes(&selected)) +} + +/// Strip SQL single-quotes from an enum value string. +/// `BTreeMap` stores bare enum names; the SQL layer handles quoting via `Expr::val()`. +pub(super) fn strip_enum_quotes(value: &str) -> String { + value + .trim_start_matches('\'') + .trim_end_matches('\'') + .to_string() +} + +/// Collect `fill_with` values interactively for missing columns. +/// The `prompt_fn` parameter allows injecting a mock for testing. +/// The `enum_prompt_fn` parameter handles enum type columns with selection UI. +pub(super) fn collect_fill_with_values( + missing: &[vespertide_planner::FillWithRequired], + fill_values: &mut HashMap<(String, String), String>, + prompt_fn: F, + enum_prompt_fn: E, +) -> Result<()> +where + F: Fn(&str, &str) -> Result, + E: Fn(&str, &[String]) -> Result, +{ + print_fill_with_header(); + + for item in missing { + let prompt = print_fill_with_item_and_get_prompt( + &item.table, + &item.column, + &item.column_type, + &item.default_value, + item.action_type, + ); + + let value = if let Some(enum_values) = &item.enum_values { + // Use selection UI for enum types + enum_prompt_fn(&prompt, enum_values)? + } else { + // Use text input with default pre-filled + prompt_fn(&prompt, &item.default_value)? + }; + fill_values.insert((item.table.clone(), item.column.clone()), value); + } + + print_fill_with_footer(); + Ok(()) +} +/// Handle interactive `fill_with` collection if there are missing values. +/// Returns the updated `fill_values` map after collecting from user. +#[cfg(test)] +pub(super) fn handle_missing_fill_with( + plan: &mut MigrationPlan, + fill_values: &mut HashMap<(String, String), String>, + current_schema: &[TableDef], + prompt_fn: F, + enum_prompt_fn: E, +) -> Result<()> +where + F: Fn(&str, &str) -> Result, + E: Fn(&str, &[String]) -> Result, +{ + let missing = find_missing_fill_with(plan, current_schema); + + if !missing.is_empty() { + collect_fill_with_values(&missing, fill_values, prompt_fn, enum_prompt_fn)?; + + // Apply the collected fill_with values + apply_fill_with_to_plan(plan, fill_values); + } + + Ok(()) +} + +#[cfg(not(tarpaulin_include))] +pub(super) fn prompt_delete_null_rows(table: &str, column: &str) -> Result { + let confirmed = Confirm::new() + .with_prompt(format!(" Delete rows where {table}.{column} IS NULL?")) + .default(false) + .interact() + .context("failed to read confirmation")?; + Ok(confirmed) +} + +pub(super) fn handle_delete_null_rows( + plan: &mut MigrationPlan, + missing: &mut Vec, + delete_set: &HashSet<(String, String)>, + prompt_fn: F, +) -> Result<()> +where + F: Fn(&str, &str) -> Result, +{ + let mut to_delete = Vec::new(); + let mut remaining = Vec::new(); + + for item in missing.drain(..) { + if item.has_foreign_key && !delete_set.contains(&(item.table.clone(), item.column.clone())) + { + // FK column without CLI arg — prompt user + println!( + " {} {}.{} has a foreign key constraint — fill_with may not work.", + "\u{2022}".bright_cyan(), + item.table.bright_white(), + item.column.bright_green() + ); + if prompt_fn(&item.table, &item.column)? { + to_delete.push((item.table.clone(), item.column.clone())); + } else { + remaining.push(item); + } + } else if delete_set.contains(&(item.table.clone(), item.column.clone())) { + to_delete.push((item.table.clone(), item.column.clone())); + } else { + remaining.push(item); + } + } + + // Apply delete_null_rows to plan + for (table, column) in &to_delete { + for action in &mut plan.actions { + if let MigrationAction::ModifyColumnNullable { + table: t, + column: c, + delete_null_rows, + .. + } = action + && t == table + && c == column + { + *delete_null_rows = Some(true); + } + } + } + + *missing = remaining; + Ok(()) +} +/// Collect enum `fill_with` values interactively for removed enum values. +/// The `enum_prompt_fn` parameter handles enum type columns with selection UI. +pub(super) fn collect_enum_fill_with_values( + missing: &[EnumFillWithRequired], + enum_prompt_fn: E, +) -> Result)>> +where + E: Fn(&str, &[String]) -> Result, +{ + let mut results = Vec::new(); + + println!( + "\n{} {}", + "\u{26a0}".bright_yellow(), + "The following enum value removals require replacement mappings:".bright_yellow() + ); + println!("{}", "\u{2500}".repeat(60).bright_black()); + + for item in missing { + println!( + " {} {}.{}: removing enum values", + "\u{2022}".bright_cyan(), + item.table.bright_white(), + item.column.bright_green() + ); + + let mut mappings = BTreeMap::new(); + for removed in &item.removed_values { + let prompt = format!( + " Replace '{}' in {}.{} with", + removed.bright_red(), + item.table.bright_white(), + item.column.bright_green() + ); + let value = enum_prompt_fn(&prompt, &item.remaining_values)?; + mappings.insert(removed.clone(), value); + } + results.push((item.action_index, mappings)); + } + + println!("{}", "\u{2500}".repeat(60).bright_black()); + Ok(results) +} + +/// Handle interactive enum `fill_with` collection if there are missing values. +pub(super) fn handle_missing_enum_fill_with( + plan: &mut MigrationPlan, + current_schema: &[TableDef], + enum_prompt_fn: E, +) -> Result<()> +where + E: Fn(&str, &[String]) -> Result, +{ + let missing = find_missing_enum_fill_with(plan, current_schema); + + if !missing.is_empty() { + let collected = collect_enum_fill_with_values(&missing, enum_prompt_fn)?; + apply_enum_fill_with_to_plan(plan, &collected); + } + + Ok(()) +} +/// Prompt the user to confirm table recreation. +/// Returns true if the user confirms, false otherwise. +#[cfg(not(tarpaulin_include))] +pub(super) fn prompt_recreate_tables(tables: &[RecreateTableRequired]) -> Result { + println!( + "\n{} {}", + "\u{26a0}".bright_yellow(), + "The following tables need to be RECREATED:".bright_yellow() + ); + println!("{}", "\u{2500}".repeat(60).bright_black()); + + for item in tables { + let reason_msg = match item.reason { + RecreateReason::AddColumnWithFk => "adding required FK column", + RecreateReason::AddFkToExistingColumn => "adding FK to existing required column", + }; + println!( + " {} Table {} \u{2014} {} {}", + "\u{2022}".bright_cyan(), + item.table.bright_white(), + reason_msg, + item.column.bright_green() + ); + } + + println!("{}", "\u{2500}".repeat(60).bright_black()); + println!( + " {} {}", + "\u{26a0}".bright_red(), + "ALL DATA in these tables will be DELETED.".bright_red() + ); + + let confirmed = Confirm::new() + .with_prompt(" Proceed with table recreation?") + .default(false) + .interact() + .context("failed to read confirmation")?; + + Ok(confirmed) +} diff --git a/crates/vespertide-cli/src/commands/revision/tests.rs b/crates/vespertide-cli/src/commands/revision/tests.rs new file mode 100644 index 00000000..170283ad --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests.rs @@ -0,0 +1,78 @@ +use super::*; +pub(super) use anyhow::Result; +pub(super) use std::{ + collections::{BTreeMap, HashMap, HashSet}, + env, fs as std_fs, + path::PathBuf, +}; +pub(super) use tempfile::tempdir; +pub(super) use vespertide_config::{FileFormat, VespertideConfig}; +pub(super) use vespertide_core::{ + ColumnDef, ColumnType, MigrationAction, MigrationPlan, SimpleColumnType, TableConstraint, + TableDef, +}; + +struct CwdGuard { + original: PathBuf, +} + +impl CwdGuard { + fn new(dir: &PathBuf) -> Self { + let original = env::current_dir().unwrap(); + env::set_current_dir(dir).unwrap(); + Self { original } + } +} + +impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = env::set_current_dir(&self.original); + } +} + +fn write_config() -> VespertideConfig { + write_config_with_format(None) +} + +fn write_config_with_format(fmt: Option) -> VespertideConfig { + let mut cfg = VespertideConfig::default(); + if let Some(f) = fmt { + cfg.migration_format = f; + } + let text = serde_json::to_string_pretty(&cfg).unwrap(); + std_fs::write("vespertide.json", text).unwrap(); + cfg +} + +fn write_model(name: &str) { + let models_dir = PathBuf::from("models"); + std_fs::create_dir_all(&models_dir).unwrap(); + let table = TableDef { + name: name.to_string(), + description: None, + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }; + let path = models_dir.join(format!("{name}.json")); + std_fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); +} + +mod delete_null_rows; +mod fill_with; +mod integration; +#[path = "tests/prompts.rs"] +mod prompt_tests; +mod recreate; diff --git a/crates/vespertide-cli/src/commands/revision/tests/delete_null_rows.rs b/crates/vespertide-cli/src/commands/revision/tests/delete_null_rows.rs new file mode 100644 index 00000000..b1cbeb21 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests/delete_null_rows.rs @@ -0,0 +1,394 @@ +use super::*; + +#[test] +fn test_parse_delete_null_rows_args() { + let args = vec!["users.email".to_string(), "orders.user_id".to_string()]; + let result = parse_delete_null_rows_args(&args); + assert_eq!(result.len(), 2); + assert!(result.contains(&("users".to_string(), "email".to_string()))); + assert!(result.contains(&("orders".to_string(), "user_id".to_string()))); +} + +#[test] +fn test_parse_delete_null_rows_args_invalid_format() { + let args = vec!["invalid_no_dot".to_string(), "valid.column".to_string()]; + let result = parse_delete_null_rows_args(&args); + assert_eq!(result.len(), 1); + assert!(result.contains(&("valid".to_string(), "column".to_string()))); +} + +#[test] +fn test_parse_delete_null_rows_args_empty() { + let result = parse_delete_null_rows_args(&[]); + assert!(result.is_empty()); +} + +#[test] +fn test_apply_delete_null_rows_to_plan() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut delete_set = HashSet::new(); + delete_set.insert(("orders".to_string(), "user_id".to_string())); + apply_delete_null_rows_to_plan(&mut plan, &delete_set); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &Some(true)); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_apply_delete_null_rows_to_plan_skips_nullable_true() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: true, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut delete_set = HashSet::new(); + delete_set.insert(("orders".to_string(), "user_id".to_string())); + apply_delete_null_rows_to_plan(&mut plan, &delete_set); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &None); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_apply_delete_null_rows_to_plan_skips_already_set() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: Some(false), + }], + }; + + let mut delete_set = HashSet::new(); + delete_set.insert(("orders".to_string(), "user_id".to_string())); + apply_delete_null_rows_to_plan(&mut plan, &delete_set); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &Some(false)); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_apply_delete_null_rows_to_plan_no_match() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut delete_set = HashSet::new(); + delete_set.insert(("other_table".to_string(), "other_col".to_string())); + apply_delete_null_rows_to_plan(&mut plan, &delete_set); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &None); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_handle_delete_null_rows_fk_accepted() { + use vespertide_core::MigrationPlan; + use vespertide_planner::FillWithRequired; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut missing = vec![FillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "user_id".to_string(), + action_type: "ModifyColumnNullable", + column_type: "integer".to_string(), + default_value: "0".to_string(), + enum_values: None, + has_foreign_key: true, + }]; + + let delete_set = HashSet::new(); + + let mock_prompt = |_table: &str, _column: &str| -> Result { Ok(true) }; + + let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); + assert!(result.is_ok()); + + assert!(missing.is_empty()); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &Some(true)); + } + _ => panic!("Expected ModifyColumnNullable"), + } +} + +#[test] +fn test_handle_delete_null_rows_fk_declined() { + use vespertide_core::MigrationPlan; + use vespertide_planner::FillWithRequired; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut missing = vec![FillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "user_id".to_string(), + action_type: "ModifyColumnNullable", + column_type: "integer".to_string(), + default_value: "0".to_string(), + enum_values: None, + has_foreign_key: true, + }]; + + let delete_set = HashSet::new(); + + let mock_prompt = |_table: &str, _column: &str| -> Result { Ok(false) }; + + let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); + assert!(result.is_ok()); + + assert_eq!(missing.len(), 1); + assert_eq!(missing[0].table, "orders"); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &None); + } + _ => panic!("Expected ModifyColumnNullable"), + } +} + +#[test] +fn test_handle_delete_null_rows_cli_provided() { + use vespertide_core::MigrationPlan; + use vespertide_planner::FillWithRequired; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut missing = vec![FillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "user_id".to_string(), + action_type: "ModifyColumnNullable", + column_type: "integer".to_string(), + default_value: "0".to_string(), + enum_values: None, + has_foreign_key: false, + }]; + + let mut delete_set = HashSet::new(); + delete_set.insert(("orders".to_string(), "user_id".to_string())); + + let mock_prompt = |_table: &str, _column: &str| -> Result { + panic!("Should not be called for CLI-provided items"); + }; + + let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); + assert!(result.is_ok()); + + assert!(missing.is_empty()); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &Some(true)); + } + _ => panic!("Expected ModifyColumnNullable"), + } +} + +#[test] +fn test_handle_delete_null_rows_non_fk_passthrough() { + use vespertide_core::MigrationPlan; + use vespertide_planner::FillWithRequired; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "email".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut missing = vec![FillWithRequired { + action_index: 0, + table: "users".to_string(), + column: "email".to_string(), + action_type: "ModifyColumnNullable", + column_type: "text".to_string(), + default_value: "''".to_string(), + enum_values: None, + has_foreign_key: false, + }]; + + let delete_set = HashSet::new(); + + let mock_prompt = |_table: &str, _column: &str| -> Result { + panic!("Should not be called for non-FK items"); + }; + + let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); + assert!(result.is_ok()); + + assert_eq!(missing.len(), 1); + assert_eq!(missing[0].column, "email"); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { + delete_null_rows, .. + } => { + assert_eq!(delete_null_rows, &None); + } + _ => panic!("Expected ModifyColumnNullable"), + } +} + +#[test] +fn test_handle_delete_null_rows_prompt_error() { + use vespertide_core::MigrationPlan; + use vespertide_planner::FillWithRequired; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "user_id".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut missing = vec![FillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "user_id".to_string(), + action_type: "ModifyColumnNullable", + column_type: "integer".to_string(), + default_value: "0".to_string(), + enum_values: None, + has_foreign_key: true, + }]; + + let delete_set = HashSet::new(); + + let mock_prompt = + |_table: &str, _column: &str| -> Result { Err(anyhow::anyhow!("user cancelled")) }; + + let result = handle_delete_null_rows(&mut plan, &mut missing, &delete_set, mock_prompt); + assert!(result.is_err()); +} diff --git a/crates/vespertide-cli/src/commands/revision/tests/fill_with.rs b/crates/vespertide-cli/src/commands/revision/tests/fill_with.rs new file mode 100644 index 00000000..5cd324f4 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests/fill_with.rs @@ -0,0 +1,294 @@ +use super::*; + +#[test] +fn test_parse_fill_with_args() { + let args = vec![ + "users.email=default@example.com".to_string(), + "orders.status=pending".to_string(), + ]; + let result = parse_fill_with_args(&args); + + assert_eq!(result.len(), 2); + assert_eq!( + result.get(&("users".to_string(), "email".to_string())), + Some(&"default@example.com".to_string()) + ); + assert_eq!( + result.get(&("orders".to_string(), "status".to_string())), + Some(&"pending".to_string()) + ); +} + +#[test] +fn test_parse_fill_with_args_invalid_format() { + let args = vec![ + "invalid_format".to_string(), + "no_equals_sign".to_string(), + "users.email=valid".to_string(), + ]; + let result = parse_fill_with_args(&args); + + // Only the valid one should be parsed + assert_eq!(result.len(), 1); + assert_eq!( + result.get(&("users".to_string(), "email".to_string())), + Some(&"valid".to_string()) + ); +} + +#[test] +fn test_apply_fill_with_to_plan_add_column() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("users".to_string(), "email".to_string()), + "'default@example.com'".to_string(), + ); + + apply_fill_with_to_plan(&mut plan, &fill_values); + + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &Some("'default@example.com'".to_string())); + } + _ => panic!("Expected AddColumn action"), + } +} + +#[test] +fn test_apply_fill_with_to_plan_modify_column_nullable() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "status".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("users".to_string(), "status".to_string()), + "'active'".to_string(), + ); + + apply_fill_with_to_plan(&mut plan, &fill_values); + + match &plan.actions[0] { + MigrationAction::ModifyColumnNullable { fill_with, .. } => { + assert_eq!(fill_with, &Some("'active'".to_string())); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_apply_fill_with_to_plan_skips_existing_fill_with() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: Some("'existing@example.com'".to_string()), + }], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("users".to_string(), "email".to_string()), + "'new@example.com'".to_string(), + ); + + apply_fill_with_to_plan(&mut plan, &fill_values); + + // Should keep existing value, not replace with new + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &Some("'existing@example.com'".to_string())); + } + _ => panic!("Expected AddColumn action"), + } +} + +#[test] +fn test_apply_fill_with_to_plan_no_match() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("orders".to_string(), "status".to_string()), + "'pending'".to_string(), + ); + + apply_fill_with_to_plan(&mut plan, &fill_values); + + // Should remain None since no match + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &None); + } + _ => panic!("Expected AddColumn action"), + } +} + +#[test] +fn test_apply_fill_with_to_plan_multiple_actions() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "status".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }, + ], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("users".to_string(), "email".to_string()), + "'user@example.com'".to_string(), + ); + fill_values.insert( + ("orders".to_string(), "status".to_string()), + "'pending'".to_string(), + ); + + apply_fill_with_to_plan(&mut plan, &fill_values); + + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &Some("'user@example.com'".to_string())); + } + _ => panic!("Expected AddColumn action"), + } + + match &plan.actions[1] { + MigrationAction::ModifyColumnNullable { fill_with, .. } => { + assert_eq!(fill_with, &Some("'pending'".to_string())); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_apply_fill_with_to_plan_other_actions_ignored() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::DeleteColumn { + table: "users".into(), + column: "old_column".into(), + }], + }; + + let mut fill_values = HashMap::new(); + fill_values.insert( + ("users".to_string(), "old_column".to_string()), + "'value'".to_string(), + ); + + // Should not panic or modify anything + apply_fill_with_to_plan(&mut plan, &fill_values); + + match &plan.actions[0] { + MigrationAction::DeleteColumn { table, column } => { + assert_eq!(table, "users"); + assert_eq!(column, "old_column"); + } + _ => panic!("Expected DeleteColumn action"), + } +} diff --git a/crates/vespertide-cli/src/commands/revision/tests/integration.rs b/crates/vespertide-cli/src/commands/revision/tests/integration.rs new file mode 100644 index 00000000..202ee844 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests/integration.rs @@ -0,0 +1,322 @@ +use super::*; + +/// Integration test: FK column nullable→not-null triggers `handle_delete_null_rows` (line 489) +#[tokio::test] +#[serial_test::serial] +async fn cmd_revision_core_handles_delete_null_rows_for_fk_column() { + use vespertide_core::MigrationPlan; + use vespertide_core::schema::foreign_key::{ForeignKeyDef, ForeignKeySyntax}; + + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config(); + std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); + + // Write v1 migration: create "orders" table with nullable user_id + let v1 = MigrationPlan { + id: "v1-id".to_string(), + comment: Some("init".to_string()), + created_at: None, + version: 1, + actions: vec![MigrationAction::CreateTable { + table: "orders".into(), + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: true, // nullable in v1 + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }, + TableConstraint::ForeignKey { + name: Some("fk_orders__user_id".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + ], + }], + }; + let v1_path = cfg.migrations_dir().join("0001_init.vespertide.json"); + std_fs::write(&v1_path, serde_json::to_string_pretty(&v1).unwrap()).unwrap(); + + // Write updated model: user_id is now NOT NULL + let models_dir = PathBuf::from("models"); + std_fs::create_dir_all(&models_dir).unwrap(); + let users_model = TableDef { + name: "users".to_string(), + description: None, + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }; + std_fs::write( + models_dir.join("users.json"), + serde_json::to_string_pretty(&users_model).unwrap(), + ) + .unwrap(); + + let model = TableDef { + name: "orders".to_string(), + description: None, + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, // NOT NULL now + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef { + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + })), + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }; + std_fs::write( + models_dir.join("orders.json"), + serde_json::to_string_pretty(&model).unwrap(), + ) + .unwrap(); + + // Mock prompts + let recreate_prompt = |_: &[RecreateTableRequired]| -> Result { Ok(true) }; + let delete_prompt = |_table: &str, _col: &str| -> Result { Ok(true) }; + let fill_prompt = |_p: &str, _d: &str| -> Result { + panic!("fill prompt should not be called — FK handled by delete_null_rows"); + }; + let enum_prompt = |_p: &str, _v: &[String]| -> Result { + panic!("enum prompt should not be called"); + }; + let enum_bare_prompt = |_p: &str, _v: &[String]| -> Result { + panic!("enum bare prompt should not be called"); + }; + + let result = cmd_revision_core( + "make user_id required".into(), + vec![], + vec![], + RevisionPromptFns { + recreate: recreate_prompt, + delete_null_rows: delete_prompt, + fill_with: fill_prompt, + enum_quoted: enum_prompt, + enum_bare: enum_bare_prompt, + }, + ) + .await; + + assert!( + result.is_ok(), + "cmd_revision_core failed: {:?}", + result.err() + ); + + // Verify migration was created + let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()) + .unwrap() + .filter_map(std::result::Result::ok) + .collect(); + // Should have 2 files: v1 + new v2 + assert_eq!(entries.len(), 2); +} + +/// Integration test: non-FK column nullable→not-null triggers `collect_fill_with_values` (lines 494-495) +#[tokio::test] +#[serial_test::serial] +async fn cmd_revision_core_handles_fill_with_for_non_fk_column() { + use vespertide_core::MigrationPlan; + + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config(); + std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); + + // Write v1 migration: create "users" table with nullable email + let v1 = MigrationPlan { + id: "v1-id".to_string(), + comment: Some("init".to_string()), + created_at: None, + version: 1, + actions: vec![MigrationAction::CreateTable { + table: "users".into(), + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, // nullable in v1 + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }], + }; + let v1_path = cfg.migrations_dir().join("0001_init.vespertide.json"); + std_fs::write(&v1_path, serde_json::to_string_pretty(&v1).unwrap()).unwrap(); + + // Write updated model: email is now NOT NULL (no default) + let models_dir = PathBuf::from("models"); + std_fs::create_dir_all(&models_dir).unwrap(); + let model = TableDef { + name: "users".to_string(), + description: None, + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, // NOT NULL now + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }; + std_fs::write( + models_dir.join("users.json"), + serde_json::to_string_pretty(&model).unwrap(), + ) + .unwrap(); + + // Mock prompts + let recreate_prompt = |_: &[RecreateTableRequired]| -> Result { Ok(true) }; + let delete_prompt = |_table: &str, _col: &str| -> Result { Ok(false) }; + let fill_prompt = |_p: &str, _d: &str| -> Result { Ok("'unknown'".to_string()) }; + let enum_prompt = |_p: &str, _v: &[String]| -> Result { + panic!("enum prompt should not be called"); + }; + let enum_bare_prompt = |_p: &str, _v: &[String]| -> Result { + panic!("enum bare prompt should not be called"); + }; + + let result = cmd_revision_core( + "make email required".into(), + vec![], + vec![], + RevisionPromptFns { + recreate: recreate_prompt, + delete_null_rows: delete_prompt, + fill_with: fill_prompt, + enum_quoted: enum_prompt, + enum_bare: enum_bare_prompt, + }, + ) + .await; + + assert!( + result.is_ok(), + "cmd_revision_core failed: {:?}", + result.err() + ); + + // Verify migration was written with fill_with + let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()) + .unwrap() + .filter_map(std::result::Result::ok) + .collect(); + assert_eq!(entries.len(), 2); + + // Read the v2 migration and verify fill_with was applied + let v2_path = entries + .iter() + .find(|e| e.file_name().to_string_lossy().contains("0002")) + .expect("v2 migration not found"); + let v2_content = std_fs::read_to_string(v2_path.path()).unwrap(); + assert!( + v2_content.contains("fill_with"), + "Expected fill_with in migration, got: {v2_content}" + ); +} diff --git a/crates/vespertide-cli/src/commands/revision/tests/prompts.rs b/crates/vespertide-cli/src/commands/revision/tests/prompts.rs new file mode 100644 index 00000000..06fc4339 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests/prompts.rs @@ -0,0 +1,762 @@ +use super::*; + +#[test] +fn test_format_type_info_with_type_and_default() { + let result = format_type_info("integer", "0"); + assert_eq!(result, " (integer, default: 0)"); +} + +#[test] +fn test_format_type_info_with_type_only() { + let result = format_type_info("text", "''"); + assert_eq!(result, " (text, default: '')"); +} + +#[test] +fn test_format_fill_with_item() { + let result = format_fill_with_item("users", "email", " (Text)", "AddColumn"); + // The result should contain the table, column, type info, and action type + // Colors make exact matching difficult, but we can check structure + assert!(result.contains("users")); + assert!(result.contains("email")); + assert!(result.contains("(Text)")); + assert!(result.contains("AddColumn")); + assert!(result.contains("Action:")); +} + +#[test] +fn test_format_fill_with_item_empty_type_info() { + let result = format_fill_with_item("orders", "status", "", "ModifyColumnNullable"); + assert!(result.contains("orders")); + assert!(result.contains("status")); + assert!(result.contains("ModifyColumnNullable")); +} + +#[test] +fn test_format_fill_with_prompt() { + let result = format_fill_with_prompt("users", "email"); + assert!(result.contains("Enter fill value for")); + assert!(result.contains("users")); + assert!(result.contains("email")); +} + +#[test] +fn test_print_fill_with_item_and_get_prompt() { + // This function prints to stdout and returns the prompt string + let prompt = print_fill_with_item_and_get_prompt("users", "email", "text", "''", "AddColumn"); + assert!(prompt.contains("Enter fill value for")); + assert!(prompt.contains("users")); + assert!(prompt.contains("email")); +} + +#[test] +fn test_print_fill_with_item_and_get_prompt_no_default() { + let prompt = print_fill_with_item_and_get_prompt( + "orders", + "status", + "text", + "''", + "ModifyColumnNullable", + ); + assert!(prompt.contains("Enter fill value for")); + assert!(prompt.contains("orders")); + assert!(prompt.contains("status")); +} + +#[test] +fn test_print_fill_with_item_and_get_prompt_with_default() { + let prompt = print_fill_with_item_and_get_prompt("users", "age", "integer", "0", "AddColumn"); + assert!(prompt.contains("Enter fill value for")); + assert!(prompt.contains("users")); + assert!(prompt.contains("age")); +} + +#[test] +fn test_print_fill_with_header() { + // Just verify it doesn't panic - output goes to stdout + print_fill_with_header(); +} + +#[test] +fn test_print_fill_with_footer() { + // Just verify it doesn't panic - output goes to stdout + print_fill_with_footer(); +} + +// Mock enum prompt function for tests - returns first enum value quoted +fn mock_enum_prompt(_prompt: &str, values: &[String]) -> Result { + let first = values + .first() + .ok_or_else(|| anyhow::anyhow!("mock enum prompt requires at least one value"))?; + Ok(format!("'{first}'")) +} + +#[test] +fn test_collect_fill_with_values_single_item() { + use vespertide_planner::FillWithRequired; + + let missing = vec![FillWithRequired { + action_index: 0, + table: "users".to_string(), + column: "email".to_string(), + action_type: "AddColumn", + column_type: "text".to_string(), + default_value: "''".to_string(), + enum_values: None, + has_foreign_key: false, + }]; + + let mut fill_values = HashMap::new(); + + // Mock prompt function that returns a fixed value + let mock_prompt = + |_prompt: &str, _default: &str| -> Result { Ok("'test@example.com'".to_string()) }; + + let result = + collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); + assert!(result.is_ok()); + assert_eq!(fill_values.len(), 1); + assert_eq!( + fill_values.get(&("users".to_string(), "email".to_string())), + Some(&"'test@example.com'".to_string()) + ); +} + +#[test] +fn test_collect_fill_with_values_multiple_items() { + use vespertide_planner::FillWithRequired; + + let missing = vec![ + FillWithRequired { + action_index: 0, + table: "users".to_string(), + column: "email".to_string(), + action_type: "AddColumn", + column_type: "text".to_string(), + default_value: "''".to_string(), + enum_values: None, + has_foreign_key: false, + }, + FillWithRequired { + action_index: 1, + table: "orders".to_string(), + column: "status".to_string(), + action_type: "ModifyColumnNullable", + column_type: "text".to_string(), + default_value: "''".to_string(), + enum_values: None, + has_foreign_key: false, + }, + ]; + + let mut fill_values = HashMap::new(); + + // Mock prompt function that returns different values based on call count + let call_count = std::cell::RefCell::new(0); + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + let mut count = call_count.borrow_mut(); + *count += 1; + match *count { + 1 => Ok("'user@example.com'".to_string()), + 2 => Ok("'pending'".to_string()), + _ => Ok("'default'".to_string()), + } + }; + + let result = + collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); + assert!(result.is_ok()); + assert_eq!(fill_values.len(), 2); + assert_eq!( + fill_values.get(&("users".to_string(), "email".to_string())), + Some(&"'user@example.com'".to_string()) + ); + assert_eq!( + fill_values.get(&("orders".to_string(), "status".to_string())), + Some(&"'pending'".to_string()) + ); +} + +#[test] +fn test_collect_fill_with_values_empty() { + let missing: Vec = vec![]; + let mut fill_values = HashMap::new(); + + // This function should handle empty list gracefully (though it won't be called in practice) + // But we can't test the header/footer without items since the function still prints them + // So we test with a mock that would fail if called + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + panic!("Should not be called for empty list"); + }; + + // Note: The function still prints header/footer even for empty list + // This is a design choice - in practice, cmd_revision won't call this with empty list + let result = + collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); + assert!(result.is_ok()); + assert!(fill_values.is_empty()); +} + +#[test] +fn test_collect_fill_with_values_prompt_error() { + use vespertide_planner::FillWithRequired; + + let missing = vec![FillWithRequired { + action_index: 0, + table: "users".to_string(), + column: "email".to_string(), + action_type: "AddColumn", + column_type: "text".to_string(), + default_value: "''".to_string(), + enum_values: None, + has_foreign_key: false, + }]; + + let mut fill_values = HashMap::new(); + + // Mock prompt function that returns an error + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + Err(anyhow::anyhow!("input cancelled")) + }; + + let result = + collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); + assert!(result.is_err()); + assert!(fill_values.is_empty()); +} + +#[test] +fn test_prompt_fill_with_value_function_exists() { + // This test verifies that prompt_fill_with_value has the correct signature. + // We cannot actually call it in tests because dialoguer::Input blocks waiting for terminal input. + // The function is excluded from coverage with #[cfg_attr(coverage_nightly, coverage(off))]. + let _: fn(&str, &str) -> Result = prompt_fill_with_value; +} + +#[test] +fn test_handle_missing_fill_with_collects_and_applies() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + + let mut fill_values = HashMap::new(); + + // Mock prompt function + let mock_prompt = + |_prompt: &str, _default: &str| -> Result { Ok("'test@example.com'".to_string()) }; + + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); + assert!(result.is_ok()); + + // Verify fill_with was applied to the plan + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &Some("'test@example.com'".to_string())); + } + _ => panic!("Expected AddColumn action"), + } + + // Verify fill_values map was updated + assert_eq!( + fill_values.get(&("users".to_string(), "email".to_string())), + Some(&"'test@example.com'".to_string()) + ); +} + +#[test] +fn test_handle_missing_fill_with_no_missing() { + use vespertide_core::MigrationPlan; + + // Plan with no missing fill_with values (nullable column) + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, // nullable, so no fill_with required + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + + let mut fill_values = HashMap::new(); + + // Mock prompt that should never be called + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + panic!("Should not be called when no missing fill_with values"); + }; + + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); + assert!(result.is_ok()); + assert!(fill_values.is_empty()); +} + +#[test] +fn test_handle_missing_fill_with_prompt_error() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + + let mut fill_values = HashMap::new(); + + // Mock prompt that returns an error + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + Err(anyhow::anyhow!("user cancelled")) + }; + + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); + assert!(result.is_err()); + + // Plan should not be modified on error + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &None); + } + _ => panic!("Expected AddColumn action"), + } +} + +#[test] +fn test_handle_missing_fill_with_multiple_columns() { + use vespertide_core::MigrationPlan; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::ModifyColumnNullable { + table: "orders".into(), + column: "status".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }, + ], + }; + + let mut fill_values = HashMap::new(); + + // Mock prompt that returns different values based on call count + let call_count = std::cell::RefCell::new(0); + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + let mut count = call_count.borrow_mut(); + *count += 1; + match *count { + 1 => Ok("'user@example.com'".to_string()), + 2 => Ok("'pending'".to_string()), + _ => Ok("'default'".to_string()), + } + }; + + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); + assert!(result.is_ok()); + + // Verify both actions were updated + match &plan.actions[0] { + MigrationAction::AddColumn { fill_with, .. } => { + assert_eq!(fill_with, &Some("'user@example.com'".to_string())); + } + _ => panic!("Expected AddColumn action"), + } + + match &plan.actions[1] { + MigrationAction::ModifyColumnNullable { fill_with, .. } => { + assert_eq!(fill_with, &Some("'pending'".to_string())); + } + _ => panic!("Expected ModifyColumnNullable action"), + } +} + +#[test] +fn test_collect_fill_with_values_enum_column() { + use vespertide_planner::FillWithRequired; + + let missing = vec![FillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "status".to_string(), + action_type: "AddColumn", + column_type: "enum".to_string(), + default_value: "''".to_string(), + enum_values: Some(vec![ + "pending".to_string(), + "confirmed".to_string(), + "shipped".to_string(), + ]), + has_foreign_key: false, + }]; + + let mut fill_values = HashMap::new(); + + // Mock prompt function that should NOT be called for enum columns + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + panic!("Should not be called for enum columns"); + }; + + // Mock enum prompt that selects the second value + let mock_enum = |_prompt: &str, values: &[String]| -> Result { + // Select "confirmed" (index 1) + Ok(format!("'{}'", values[1])) + }; + + let result = collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum); + assert!(result.is_ok()); + assert_eq!(fill_values.len(), 1); + assert_eq!( + fill_values.get(&("orders".to_string(), "status".to_string())), + Some(&"'confirmed'".to_string()) + ); +} + +#[test] +fn test_wrap_if_spaces_empty() { + assert_eq!(wrap_if_spaces(String::new()), ""); +} + +#[test] +fn test_wrap_if_spaces_no_spaces() { + assert_eq!(wrap_if_spaces("value".to_string()), "value"); +} + +#[test] +fn test_wrap_if_spaces_with_spaces() { + assert_eq!(wrap_if_spaces("my value".to_string()), "'my value'"); +} + +#[test] +fn test_wrap_if_spaces_already_quoted() { + assert_eq!( + wrap_if_spaces("'already quoted'".to_string()), + "'already quoted'" + ); +} + +#[test] +fn test_wrap_if_spaces_multiple_spaces() { + assert_eq!(wrap_if_spaces("a b c".to_string()), "'a b c'"); +} + +// ── enum fill_with tests ─────────────────────────────────────────── + +#[test] +fn test_collect_enum_fill_with_values_single_removal() { + use vespertide_planner::EnumFillWithRequired; + + let missing = vec![EnumFillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "status".to_string(), + removed_values: vec!["cancelled".to_string()], + remaining_values: vec!["pending".to_string(), "shipped".to_string()], + }]; + + // Mock prompt: always select first remaining value + let mock_enum = |_prompt: &str, values: &[String]| -> Result { Ok(values[0].clone()) }; + + let result = collect_enum_fill_with_values(&missing, mock_enum); + assert!(result.is_ok()); + let collected = result.unwrap(); + assert_eq!(collected.len(), 1); + assert_eq!(collected[0].0, 0); // action_index + assert_eq!( + collected[0].1.get("cancelled"), + Some(&"pending".to_string()) + ); +} + +#[test] +fn test_collect_enum_fill_with_values_multiple_removals() { + use vespertide_planner::EnumFillWithRequired; + + let missing = vec![EnumFillWithRequired { + action_index: 0, + table: "orders".to_string(), + column: "status".to_string(), + removed_values: vec!["cancelled".to_string(), "draft".to_string()], + remaining_values: vec!["pending".to_string(), "shipped".to_string()], + }]; + + // Mock prompt: always select second remaining value + let mock_enum = |_prompt: &str, values: &[String]| -> Result { Ok(values[1].clone()) }; + + let result = collect_enum_fill_with_values(&missing, mock_enum); + assert!(result.is_ok()); + let collected = result.unwrap(); + assert_eq!(collected[0].1.len(), 2); + assert_eq!( + collected[0].1.get("cancelled"), + Some(&"shipped".to_string()) + ); + assert_eq!(collected[0].1.get("draft"), Some(&"shipped".to_string())); +} + +#[test] +fn test_apply_enum_fill_with_to_plan() { + use vespertide_core::{ColumnType, ComplexColumnType, EnumValues}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::ModifyColumnType { + table: "orders".into(), + column: "status".into(), + new_type: ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec!["pending".into(), "shipped".into()]), + }), + fill_with: None, + }], + }; + + let mut mappings = BTreeMap::new(); + mappings.insert("cancelled".to_string(), "pending".to_string()); + let collected = vec![(0usize, mappings)]; + + apply_enum_fill_with_to_plan(&mut plan, &collected); + + if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { + let fw = fill_with.as_ref().expect("fill_with should be set"); + assert_eq!(fw.get("cancelled"), Some(&"pending".to_string())); + } else { + panic!("Expected ModifyColumnType"); + } +} + +#[test] +fn test_handle_missing_enum_fill_with_collects_and_applies() { + use vespertide_core::{ColumnDef, ColumnType, ComplexColumnType, EnumValues}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::ModifyColumnType { + table: "orders".into(), + column: "status".into(), + new_type: ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec!["pending".into(), "shipped".into()]), + }), + fill_with: None, + }], + }; + + let baseline = vec![TableDef { + name: "orders".into(), + description: None, + columns: vec![ColumnDef { + name: "status".into(), + r#type: ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec![ + "pending".into(), + "shipped".into(), + "cancelled".into(), + ]), + }), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + }]; + + // Mock: always select first remaining value + let mock_enum = |_prompt: &str, values: &[String]| -> Result { Ok(values[0].clone()) }; + + let result = handle_missing_enum_fill_with(&mut plan, &baseline, mock_enum); + assert!(result.is_ok()); + + if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { + let fw = fill_with.as_ref().expect("fill_with should be populated"); + assert_eq!(fw.get("cancelled"), Some(&"pending".to_string())); + } else { + panic!("Expected ModifyColumnType"); + } +} + +#[test] +fn test_handle_missing_enum_fill_with_no_missing() { + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![], + }; + + let mock_enum = |_prompt: &str, _values: &[String]| -> Result { + panic!("Should not be called when nothing is missing"); + }; + + let result = handle_missing_enum_fill_with(&mut plan, &[], mock_enum); + assert!(result.is_ok()); +} + +#[test] +fn test_apply_enum_fill_with_to_plan_extends_existing() { + use vespertide_core::{ColumnType, ComplexColumnType, EnumValues}; + + // Start with a fill_with that already has one entry + let mut existing_fw = BTreeMap::new(); + existing_fw.insert("draft".to_string(), "pending".to_string()); + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::ModifyColumnType { + table: "orders".into(), + column: "status".into(), + new_type: ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec!["pending".into(), "shipped".into()]), + }), + fill_with: Some(existing_fw), + }], + }; + + // Collect additional mappings + let mut new_mappings = BTreeMap::new(); + new_mappings.insert("cancelled".to_string(), "shipped".to_string()); + let collected = vec![(0usize, new_mappings)]; + + apply_enum_fill_with_to_plan(&mut plan, &collected); + + if let MigrationAction::ModifyColumnType { fill_with, .. } = &plan.actions[0] { + let fw = fill_with.as_ref().expect("fill_with should be set"); + // Original entry preserved + assert_eq!(fw.get("draft"), Some(&"pending".to_string())); + // New entry added + assert_eq!(fw.get("cancelled"), Some(&"shipped".to_string())); + // Total 2 entries + assert_eq!(fw.len(), 2); + } else { + panic!("Expected ModifyColumnType"); + } +} + +#[test] +fn test_strip_enum_quotes_with_quotes() { + assert_eq!(strip_enum_quotes("'active'"), "active"); +} + +#[test] +fn test_strip_enum_quotes_bare_value() { + assert_eq!(strip_enum_quotes("active"), "active"); +} + +#[test] +fn test_strip_enum_quotes_empty() { + assert_eq!(strip_enum_quotes(""), ""); +} + +#[test] +fn test_strip_enum_quotes_only_leading() { + assert_eq!(strip_enum_quotes("'active"), "active"); +} + +#[test] +fn test_strip_enum_quotes_only_trailing() { + assert_eq!(strip_enum_quotes("active'"), "active"); +} diff --git a/crates/vespertide-cli/src/commands/revision/tests/recreate.rs b/crates/vespertide-cli/src/commands/revision/tests/recreate.rs new file mode 100644 index 00000000..b3edcb55 --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/tests/recreate.rs @@ -0,0 +1,720 @@ +use super::*; + +#[tokio::test] +#[serial_test::serial] +async fn cmd_revision_writes_migration() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config(); + write_model("users"); + std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); + + cmd_revision("init".into(), vec![], vec![]).await.unwrap(); + + let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()).unwrap().collect(); + assert!(!entries.is_empty()); +} + +#[tokio::test] +#[serial_test::serial] +async fn cmd_revision_no_changes_short_circuits() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config(); + // no models, no migrations -> plan with no actions -> early return + assert!(cmd_revision("noop".into(), vec![], vec![]).await.is_ok()); + // migrations dir should not be created + assert!(!cfg.migrations_dir().exists()); +} + +#[tokio::test] +#[serial_test::serial] +async fn cmd_revision_writes_yaml_when_configured() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config_with_format(Some(FileFormat::Yaml)); + write_model("users"); + // ensure migrations dir absent to exercise create_dir_all branch + if cfg.migrations_dir().exists() { + std_fs::remove_dir_all(cfg.migrations_dir()).unwrap(); + } + + cmd_revision("yaml".into(), vec![], vec![]).await.unwrap(); + + let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()).unwrap().collect(); + assert!(!entries.is_empty()); + let has_yaml = entries.iter().any(|e| { + e.as_ref() + .unwrap() + .path() + .extension() + .is_some_and(|s| s == "yaml") + }); + assert!(has_yaml); +} + +#[test] +fn find_non_nullable_fk_add_column_detects_recreate() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: Some("1".into()), + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + let result = find_non_nullable_fk_add_columns(&plan, &[]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].table, "post"); + assert_eq!(result[0].column, "user_id"); + assert_eq!(result[0].reason, RecreateReason::AddColumnWithFk); +} + +#[test] +fn find_non_nullable_inline_fk_add_column_detects_recreate() { + use vespertide_core::schema::foreign_key::{ForeignKeyDef, ForeignKeySyntax}; + use vespertide_core::{ColumnDef, ColumnType, ReferenceAction, SimpleColumnType}; + + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: Some(ForeignKeySyntax::Object(ForeignKeyDef { + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + })), + }), + fill_with: None, + }], + }; + + let result = find_non_nullable_fk_add_columns(&plan, &[]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].table, "post"); + assert_eq!(result[0].column, "user_id"); + assert_eq!(result[0].reason, RecreateReason::AddColumnWithFk); +} + +#[test] +fn find_nullable_fk_add_column_returns_empty() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + assert!(find_non_nullable_fk_add_columns(&plan, &[]).is_empty()); +} + +#[test] +fn find_non_nullable_no_fk_returns_empty() { + // Regular non-nullable column without FK should NOT trigger recreation + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id1".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + // Should return empty — this column needs fill_with but that's handled separately + assert!(find_non_nullable_fk_add_columns(&plan, &[]).is_empty()); +} + +#[test] +fn find_fk_on_existing_non_nullable_column_detects_recreate() { + // Adding FK constraint to an existing non-nullable column should trigger recreation + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }], + }; + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }]; + let result = find_non_nullable_fk_add_columns(&plan, &models); + assert_eq!(result.len(), 1); + assert_eq!(result[0].table, "post"); + assert_eq!(result[0].column, "user_id"); + assert_eq!(result[0].reason, RecreateReason::AddFkToExistingColumn); +} + +#[test] +fn find_fk_on_existing_nullable_column_returns_empty() { + // Adding FK constraint to an existing nullable column should NOT trigger recreation + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }], + }; + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + }]; + assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); +} + +#[test] +fn find_fk_on_existing_column_with_default_returns_empty() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }], + }; + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: Some(true.into()), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + }]; + + assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); +} + +#[test] +fn find_fk_on_existing_column_missing_from_model_returns_empty() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }], + }; + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ColumnDef { + name: "other_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + }]; + + assert!(find_non_nullable_fk_add_columns(&plan, &models).is_empty()); +} + +#[test] +fn rewrite_plan_replaces_actions_with_recreate() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + + let recreate = vec![RecreateTableRequired { + table: "post".into(), + column: "user_id".into(), + reason: RecreateReason::AddColumnWithFk, + }]; + + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }]; + + rewrite_plan_for_recreation(&mut plan, &recreate, &models); + + assert_eq!(plan.actions.len(), 2); + assert!(matches!(&plan.actions[0], MigrationAction::DeleteTable { table } if table == "post")); + assert!( + matches!(&plan.actions[1], MigrationAction::CreateTable { table, .. } if table == "post") + ); +} + +#[test] +fn rewrite_plan_keeps_non_table_actions() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 2, + actions: vec![ + MigrationAction::RawSql { + sql: "select 1".into(), + }, + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + ], + }; + + let recreate = vec![RecreateTableRequired { + table: "post".into(), + column: "user_id".into(), + reason: RecreateReason::AddColumnWithFk, + }]; + + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + }]; + + rewrite_plan_for_recreation(&mut plan, &recreate, &models); + + assert!(matches!(&plan.actions[0], MigrationAction::RawSql { sql } if sql == "select 1")); + assert!(matches!(&plan.actions[1], MigrationAction::DeleteTable { table } if table == "post")); + assert!( + matches!(&plan.actions[2], MigrationAction::CreateTable { table, .. } if table == "post") + ); +} + +#[test] +fn handle_recreate_requirements_returns_ok_when_no_fk() { + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::RawSql { + sql: "select 1".into(), + }], + }; + + handle_recreate_requirements(&mut plan, &[], |_| Ok(true)).unwrap(); + + assert_eq!(plan.actions.len(), 1); +} + +#[test] +fn handle_recreate_requirements_bails_when_prompt_rejected() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + + let err = handle_recreate_requirements(&mut plan, &[], |_| Ok(false)).unwrap_err(); + + assert!( + err.to_string() + .contains("Migration cancelled. To proceed without recreation") + ); +} + +#[test] +fn handle_recreate_requirements_empties_plan_when_model_missing() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + + handle_recreate_requirements(&mut plan, &[], |_| Ok(true)).unwrap(); + + assert!(plan.actions.is_empty()); +} + +#[test] +fn handle_recreate_requirements_rewrites_plan_when_model_exists() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + + let mut plan = MigrationPlan { + id: String::new(), + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + + let models = vec![TableDef { + name: "post".into(), + description: None, + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }]; + + handle_recreate_requirements(&mut plan, &models, |_| Ok(true)).unwrap(); + + assert_eq!(plan.actions.len(), 2); + assert!(matches!(&plan.actions[0], MigrationAction::DeleteTable { table } if table == "post")); + assert!( + matches!(&plan.actions[1], MigrationAction::CreateTable { table, .. } if table == "post") + ); +} diff --git a/crates/vespertide-cli/src/commands/revision/write.rs b/crates/vespertide-cli/src/commands/revision/write.rs new file mode 100644 index 00000000..dfb12fda --- /dev/null +++ b/crates/vespertide-cli/src/commands/revision/write.rs @@ -0,0 +1,69 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde_json::Value; +use tokio::fs; +use vespertide_config::{FileFormat, VespertideConfig}; +use vespertide_core::MigrationPlan; + +use crate::utils::{migration_filename_with_format_and_pattern, schema_url}; + +pub(super) async fn write_migration_file( + config: &VespertideConfig, + plan: &MigrationPlan, +) -> Result { + let migrations_dir = config.migrations_dir(); + if !migrations_dir.exists() { + fs::create_dir_all(&migrations_dir) + .await + .context("create migrations directory")?; + } + + let format = config.migration_format(); + let filename = migration_filename_with_format_and_pattern( + plan.version, + plan.comment.as_deref(), + format, + config.migration_filename_pattern(), + ); + let path = migrations_dir.join(&filename); + + let schema_url = schema_url("migration.schema.json"); + match format { + FileFormat::Json => write_json_with_schema(&path, plan, &schema_url).await?, + FileFormat::Yaml | FileFormat::Yml => write_yaml(&path, plan, &schema_url).await?, + } + + Ok(path) +} + +pub(super) async fn write_json_with_schema( + path: &Path, + plan: &MigrationPlan, + schema_url: &str, +) -> Result<()> { + let mut value = serde_json::to_value(plan).context("serialize migration plan to json")?; + if let Value::Object(ref mut map) = value { + map.insert("$schema".to_string(), Value::String(schema_url.to_string())); + } + let text = serde_json::to_string_pretty(&value).context("stringify json with schema")?; + fs::write(path, text) + .await + .with_context(|| format!("write file: {}", path.display()))?; + Ok(()) +} + +pub(super) async fn write_yaml(path: &Path, plan: &MigrationPlan, schema_url: &str) -> Result<()> { + let mut value = serde_yaml::to_value(plan).context("serialize migration plan to yaml value")?; + if let serde_yaml::Value::Mapping(ref mut map) = value { + map.insert( + serde_yaml::Value::String("$schema".to_string()), + serde_yaml::Value::String(schema_url.to_string()), + ); + } + let text = serde_yaml::to_string(&value).context("serialize yaml with schema")?; + fs::write(path, text) + .await + .with_context(|| format!("write file: {}", path.display()))?; + Ok(()) +} diff --git a/crates/vespertide-cli/src/commands/sql.rs b/crates/vespertide-cli/src/commands/sql.rs index b40538a8..55f678b5 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -17,12 +17,12 @@ pub async fn cmd_sql(backend: DatabaseBackend) -> Result<()> { .map(|p| p.with_prefix(prefix)) .collect(); let baseline_schema = schema_from_plans(&prefixed_plans) - .map_err(|e| anyhow::anyhow!("failed to reconstruct schema: {}", e))?; + .map_err(|e| anyhow::anyhow!("failed to reconstruct schema: {e}"))?; // Plan next migration using the pre-computed baseline let plan = plan_next_migration_with_baseline(¤t_models, &prefixed_plans, &baseline_schema) - .map_err(|e| anyhow::anyhow!("planning error: {}", e))?; + .map_err(|e| anyhow::anyhow!("planning error: {e}"))?; // Apply prefix to the new plan for SQL generation let prefixed_plan = plan.with_prefix(prefix); @@ -45,7 +45,7 @@ fn emit_sql( } let plan_queries = build_plan_queries(plan, current_schema) - .map_err(|e| anyhow::anyhow!("query build error: {}", e))?; + .map_err(|e| anyhow::anyhow!("query build error: {e}"))?; // Select queries for the specified backend let queries: Vec<_> = plan_queries @@ -102,7 +102,7 @@ fn emit_sql( if queries.len() > 1 { format!("-{}", j + 1) } else { - "".to_string() + String::new() } .bright_magenta() .bold(), diff --git a/crates/vespertide-cli/src/commands/status.rs b/crates/vespertide-cli/src/commands/status.rs index 6fc69b66..746efd4f 100644 --- a/crates/vespertide-cli/src/commands/status.rs +++ b/crates/vespertide-cli/src/commands/status.rs @@ -5,6 +5,10 @@ use vespertide_planner::schema_from_plans; use crate::utils::{load_config, load_migrations, load_models}; use std::collections::HashSet; +#[expect( + clippy::too_many_lines, + reason = "status command output is a single linear report" +)] pub async fn cmd_status() -> Result<()> { let config = load_config()?; let current_models = load_models(&config)?; @@ -50,7 +54,9 @@ pub async fn cmd_status() -> Result<()> { applied_plans.len().to_string().bright_yellow() ); if !applied_plans.is_empty() { - let latest = applied_plans.last().unwrap(); + let Some(latest) = applied_plans.last() else { + return Ok(()); + }; println!( " {} {}", "Latest version:".cyan(), @@ -112,7 +118,7 @@ pub async fn cmd_status() -> Result<()> { if !applied_plans.is_empty() { let baseline = schema_from_plans(&applied_plans) - .map_err(|e| anyhow::anyhow!("schema reconstruction error: {}", e))?; + .map_err(|e| anyhow::anyhow!("schema reconstruction error: {e}"))?; let baseline_tables: HashSet<_> = baseline.iter().map(|t| &t.name).collect(); let current_tables: HashSet<_> = current_models.iter().map(|t| &t.name).collect(); @@ -274,6 +280,26 @@ mod tests { cmd_status().await.unwrap(); } + #[tokio::test] + #[serial] + async fn cmd_status_empty_migration_list_returns_ok() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + let cfg = write_config(); + fs::create_dir_all(cfg.models_dir()).unwrap(); + fs::create_dir_all(cfg.migrations_dir()).unwrap(); + + cmd_status().await.unwrap(); + } + + #[test] + fn cmd_status_does_not_unwrap_latest_migration() { + let source = include_str!("status.rs"); + let needle = ["applied_plans.last()", ".unwrap()"].join(""); + + assert!(!source.contains(&needle)); + } + #[tokio::test] #[serial] async fn cmd_status_models_no_migrations_prints_hint() { diff --git a/crates/vespertide-cli/src/main.rs b/crates/vespertide-cli/src/main.rs index 18b47a0c..a760009e 100644 --- a/crates/vespertide-cli/src/main.rs +++ b/crates/vespertide-cli/src/main.rs @@ -2,10 +2,13 @@ use anyhow::Result; use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; mod commands; +mod parallel_config; mod utils; +use crate::commands::erd::ErdFormat; use crate::commands::export::OrmArg; use commands::{ - cmd_diff, cmd_export, cmd_init, cmd_log, cmd_new, cmd_revision, cmd_sql, cmd_status, + cmd_diff, cmd_erd_with_filters, cmd_export, cmd_init, cmd_log, cmd_new, cmd_revision, cmd_sql, + cmd_status, }; use vespertide_config::FileFormat; use vespertide_query::DatabaseBackend; @@ -85,6 +88,24 @@ enum Commands { #[arg(short = 'd', long = "export-dir")] export_dir: Option, }, + /// Export schema as ERD diagrams (SVG, Mermaid, Graphviz DOT). + Erd { + /// Output format: svg|mermaid|dot. + #[arg(short = 'f', long = "format", value_enum, default_value = "svg")] + format: ErdFormat, + /// Output file path (defaults to stdout if not specified). + #[arg(short = 'o', long = "output")] + output: Option, + /// Include only these tables, plus FK-graph neighbors from --depth. + #[arg(long, value_delimiter = ',')] + include: Vec, + /// Exclude these tables after applying --include and --depth. + #[arg(long, value_delimiter = ',')] + exclude: Vec, + /// FK-graph hop distance from --include tables. 0 = include set only. + #[arg(long, default_value = "0")] + depth: usize, + }, } #[cfg(not(tarpaulin_include))] @@ -104,6 +125,13 @@ async fn main() -> Result<()> { }) => cmd_revision(message, fill_with, delete_null_rows).await, Some(Commands::Init) => cmd_init().await, Some(Commands::Export { orm, export_dir }) => cmd_export(orm, export_dir).await, + Some(Commands::Erd { + format, + output, + include, + exclude, + depth, + }) => cmd_erd_with_filters(format, output, include, exclude, depth).await, None => { // No subcommand: show help and exit successfully. Cli::command().print_help()?; diff --git a/crates/vespertide-cli/src/parallel_config.rs b/crates/vespertide-cli/src/parallel_config.rs new file mode 100644 index 00000000..932f139e --- /dev/null +++ b/crates/vespertide-cli/src/parallel_config.rs @@ -0,0 +1,9 @@ +//! Empirically tuned Rayon thresholds. +//! +//! See `docs/PARALLELIZATION.md` for the Wave 6 measurement notes. +//! +//! CLI export iterates at most four ORM variants, but per-table render work is +//! CPU-bound. Wave 6 kept the Wave 1 threshold unchanged. + +pub(crate) const EXPORT_RENDER_PAR_THRESHOLD: usize = 50; +pub(crate) const EXPORT_RENDER_PAR_MIN_LEN: usize = 32; diff --git a/crates/vespertide-cli/src/utils.rs b/crates/vespertide-cli/src/utils.rs index fbc4a48a..e9b4b7a4 100644 --- a/crates/vespertide-cli/src/utils.rs +++ b/crates/vespertide-cli/src/utils.rs @@ -1,8 +1,20 @@ +use std::fmt::Write as _; use vespertide_config::FileFormat; // Re-export loader functions for convenience pub use vespertide_loader::{load_config, load_migrations, load_models}; +pub(crate) fn schema_url(schema_filename: &str) -> String { + // If not set, default to public raw GitHub schema location. + // Users can override via VESP_SCHEMA_BASE_URL. + let base = std::env::var("VESP_SCHEMA_BASE_URL").ok(); + let base = base.as_deref().unwrap_or( + "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas", + ); + let base = base.trim_end_matches('/'); + format!("{base}/{schema_filename}") +} + /// Generate a migration filename from version and optional comment with format and pattern. pub fn migration_filename_with_format_and_pattern( version: u32, @@ -43,7 +55,7 @@ fn sanitize_comment(comment: Option<&str>) -> String { } fn render_migration_name(pattern: &str, version: u32, sanitized_comment: &str) -> String { - let default_version = format!("{:04}", version); + let default_version = format!("{version:04}"); let chars: Vec = pattern.chars().collect(); let mut i = 0; let mut out = String::new(); @@ -73,7 +85,7 @@ fn render_migration_name(pattern: &str, version: u32, sanitized_comment: &str) - if w == 0 { out.push_str(&default_version); } else { - out.push_str(&format!("{:0width$}", version, width = w)); + let _ = write!(out, "{version:0w$}"); } i = j + 1; continue; diff --git a/crates/vespertide-cli/tests/export_parallel.rs b/crates/vespertide-cli/tests/export_parallel.rs new file mode 100644 index 00000000..7c7c26ea --- /dev/null +++ b/crates/vespertide-cli/tests/export_parallel.rs @@ -0,0 +1,105 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use assert_cmd::Command; +use assert_cmd::cargo; +use rstest::rstest; +use tempfile::TempDir; + +fn vespertide() -> Command { + Command::new(cargo::cargo_bin!("vespertide")) +} + +#[rstest] +#[case(1)] +#[case(49)] +#[case(50)] +#[case(200)] +fn export_output_is_byte_stable_across_rayon_thread_counts(#[case] table_count: usize) { + let single_threaded = export_output(table_count, "1"); + let multi_threaded = export_output(table_count, "4"); + + assert_eq!(single_threaded, multi_threaded); +} + +fn export_output(table_count: usize, rayon_threads: &str) -> Vec<(PathBuf, Vec)> { + let temp_dir = TempDir::new().expect("create temp dir"); + write_project(temp_dir.path(), table_count); + + vespertide() + .current_dir(temp_dir.path()) + .env("RAYON_NUM_THREADS", rayon_threads) + .args(["export", "--orm", "seaorm", "--export-dir", "generated"]) + .assert() + .success(); + + read_output_tree(&temp_dir.path().join("generated")) +} + +fn write_project(root: &Path, table_count: usize) { + fs::write( + root.join("vespertide.json"), + r#"{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "modelFormat": "json", + "migrationFormat": "json", + "modelExportDir": "generated", + "seaorm": { + "extraEnumDerives": [], + "vesperaSchemaType": false + } +}"#, + ) + .expect("write config"); + + let models_dir = root.join("models"); + fs::create_dir(&models_dir).expect("create models dir"); + + for index in 0..table_count { + let table_name = format!("table_{index:03}"); + let model = format!( + r#"{{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "{table_name}", + "columns": [ + {{ "name": "id", "type": "integer", "nullable": false, "primary_key": {{ "auto_increment": true }} }}, + {{ "name": "name", "type": {{ "kind": "varchar", "length": 100 }}, "nullable": false }}, + {{ "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()" }} + ] +}}"# + ); + fs::write(models_dir.join(format!("{table_name}.json")), model).expect("write model"); + } +} + +fn read_output_tree(root: &Path) -> Vec<(PathBuf, Vec)> { + let mut files = Vec::new(); + collect_files(root, root, &mut files); + files.sort_by(|left, right| left.0.cmp(&right.0)); + files +} + +fn collect_files(root: &Path, dir: &Path, files: &mut Vec<(PathBuf, Vec)>) { + let mut entries = fs::read_dir(dir) + .expect("read output dir") + .collect::, _>>() + .expect("collect output entries"); + entries.sort_by_key(std::fs::DirEntry::path); + + for entry in entries { + let path = entry.path(); + if path.is_dir() { + collect_files(root, &path, files); + } else { + let rel_path = path + .strip_prefix(root) + .expect("relative path") + .to_path_buf(); + let bytes = fs::read(&path).expect("read output file"); + files.push((rel_path, bytes)); + } + } +} diff --git a/crates/vespertide-config/Cargo.toml b/crates/vespertide-config/Cargo.toml index 0cdcbe9f..39c8eb58 100644 --- a/crates/vespertide-config/Cargo.toml +++ b/crates/vespertide-config/Cargo.toml @@ -7,6 +7,9 @@ repository.workspace = true homepage.workspace = true documentation.workspace = true description = "Manages models/migrations directories and naming-case preferences" +keywords = ["database", "migration", "schema", "orm", "sql"] +categories = ["database"] +readme = "../../README.md" [dependencies] serde = { version = "1", features = ["derive"] } @@ -20,3 +23,6 @@ schema = ["dep:schemars"] [dev-dependencies] serde_json = "1" + +[lints] +workspace = true diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index 0b7a4822..4242f2c7 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -22,7 +22,7 @@ pub struct SeaOrmConfig { /// Additional derive macros to add to generated entity model types. #[serde(default)] pub extra_model_derives: Vec, - /// Naming case for serde rename_all attribute on generated enums. + /// Naming case for serde `rename_all` attribute on generated enums. /// Default: `Camel` (generates `#[serde(rename_all = "camelCase")]`) #[serde(default = "default_enum_naming_case")] pub enum_naming_case: NameCase, @@ -66,7 +66,7 @@ impl SeaOrmConfig { &self.extra_model_derives } - /// Get the naming case for serde rename_all attribute on generated enums. + /// Get the naming case for serde `rename_all` attribute on generated enums. pub fn enum_naming_case(&self) -> NameCase { self.enum_naming_case } diff --git a/crates/vespertide-config/src/lib.rs b/crates/vespertide-config/src/lib.rs index 1ec2e86a..e8f6640d 100644 --- a/crates/vespertide-config/src/lib.rs +++ b/crates/vespertide-config/src/lib.rs @@ -1,3 +1,8 @@ +//! Configuration parsing for vespertide projects. +//! +//! Reads `vespertide.json` (or `.yaml`) with paths, naming conventions, +//! and file format preferences. + pub mod config; pub mod file_format; pub mod name_case; @@ -67,7 +72,7 @@ mod tests { #[test] fn seaorm_config_deserialize_with_defaults() { - let json = r#"{}"#; + let json = r"{}"; let cfg: SeaOrmConfig = serde_json::from_str(json).unwrap(); assert_eq!(cfg.extra_enum_derives(), &["vespera::Schema".to_string()]); assert!(cfg.extra_model_derives().is_empty()); diff --git a/crates/vespertide-config/src/name_case.rs b/crates/vespertide-config/src/name_case.rs index edd01448..d5d9c702 100644 --- a/crates/vespertide-config/src/name_case.rs +++ b/crates/vespertide-config/src/name_case.rs @@ -26,7 +26,7 @@ impl NameCase { matches!(self, NameCase::Pascal) } - /// Returns the serde rename_all attribute value for this case. + /// Returns the serde `rename_all` attribute value for this case. pub fn serde_rename_all(self) -> &'static str { match self { NameCase::Snake => "snake_case", diff --git a/crates/vespertide-core/AGENTS.md b/crates/vespertide-core/AGENTS.md index fe3c76b6..f5650fa4 100644 --- a/crates/vespertide-core/AGENTS.md +++ b/crates/vespertide-core/AGENTS.md @@ -7,7 +7,7 @@ Core data structures for schema definition and migration planning. ``` src/ ├── lib.rs # Re-exports all public types -├── action.rs # MigrationAction (12 variants), MigrationPlan +├── action.rs # MigrationAction (14 variants), MigrationPlan (1236 lines; scheduled split) ├── migration.rs # MigrationError, MigrationOptions └── schema/ ├── column.rs # ColumnDef, ColumnType, SimpleColumnType, ComplexColumnType @@ -66,3 +66,11 @@ MigrationAction::AddColumn { table, column, fill_with } | Omitting inline fields in ColumnDef | Include all 4: `primary_key`, `unique`, `index`, `foreign_key` | | Using TableDef without normalize() | Call `normalize()` before diffing | | Direct TableConstraint in column | Use inline syntax, let normalize() convert | + +## NOTES + +- Model and migration serialization supports both JSON and YAML. +- Prefer typed `MigrationAction` enums; `MigrationAction::RawSql` exists as a documented emergency escape hatch, but is not recommended for normal use. +- Shared proptest strategies live behind the `arbitrary` feature. Run property tests with `cargo test -p vespertide-core --features arbitrary`. +- Every `.rs` file must stay ≤ 1000 lines (CI enforced); current hotspots include `action.rs` (1236 lines) and `schema/table.rs` (1526 lines). +- Workspace lints warn on unsafe code and Clippy all: `unsafe_code = "warn"`, `clippy::all = { level = "warn", priority = -1 }`. diff --git a/crates/vespertide-core/Cargo.toml b/crates/vespertide-core/Cargo.toml index 9f79e1e2..4bd6e87f 100644 --- a/crates/vespertide-core/Cargo.toml +++ b/crates/vespertide-core/Cargo.toml @@ -7,17 +7,32 @@ repository.workspace = true homepage.workspace = true documentation.workspace = true description = "Data models for tables, columns, constraints, indexes, and migration actions" +keywords = ["database", "migration", "schema", "orm", "sql"] +categories = ["database"] +readme = "../../README.md" [dependencies] +proptest = { version = "1", optional = true, default-features = false, features = ["std", "bit-set"] } serde = { version = "1", features = ["derive"] } +sea-orm = { version = "2.0.0-rc.38", default-features = false } schemars = { version = "1.2", optional = true } thiserror = "2" vespertide-naming = { workspace = true } [features] +arbitrary = ["dep:proptest"] default = ["schema"] schema = ["dep:schemars"] [dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } +proptest = "1" rstest = "0.26" serde_json = "1" + +[[bench]] +name = "normalize_benchmarks" +harness = false + +[lints] +workspace = true diff --git a/crates/vespertide-core/benches/normalize_benchmarks.rs b/crates/vespertide-core/benches/normalize_benchmarks.rs new file mode 100644 index 00000000..88c03236 --- /dev/null +++ b/crates/vespertide-core/benches/normalize_benchmarks.rs @@ -0,0 +1,81 @@ +use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use vespertide_core::schema::foreign_key::{ForeignKeyDef, ForeignKeySyntax}; +use vespertide_core::schema::primary_key::PrimaryKeySyntax; +use vespertide_core::{ + ColumnDef, ColumnType, ReferenceAction, SimpleColumnType, StrOrBoolOrArray, TableDef, +}; + +fn simple_type(ty: SimpleColumnType) -> ColumnType { + ColumnType::Simple(ty) +} + +fn build_table(n_columns: usize, with_inline_constraints: bool) -> TableDef { + let mut columns = Vec::with_capacity(n_columns.max(1)); + columns.push( + ColumnDef::new("id", simple_type(SimpleColumnType::Integer), false) + .primary_key(PrimaryKeySyntax::Bool(true)), + ); + + for i in 1..n_columns { + let mut column = ColumnDef::new( + format!("column_{i}"), + if i % 3 == 0 { + simple_type(SimpleColumnType::Integer) + } else { + simple_type(SimpleColumnType::Text) + }, + i % 7 == 0, + ); + + if with_inline_constraints { + if i % 5 == 0 { + column = column.index(StrOrBoolOrArray::Bool(true)); + } + if i % 11 == 0 { + column = column.unique(StrOrBoolOrArray::Str(format!("uq_norm__column_{i}"))); + } + if i % 17 == 0 { + column = column.foreign_key(ForeignKeySyntax::Object(ForeignKeyDef { + ref_table: "parent".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + })); + } + } + + columns.push(column); + } + + TableDef { + name: format!("normalize_{n_columns}_{with_inline_constraints}"), + description: None, + columns, + constraints: vec![], + } +} + +fn bench_normalize(c: &mut Criterion) { + let mut group = c.benchmark_group("table_normalize"); + for n_columns in [10, 100, 500] { + for with_inline_constraints in [false, true] { + let table = build_table(n_columns, with_inline_constraints); + group.bench_with_input( + BenchmarkId::new( + if with_inline_constraints { + "with_inline_constraints" + } else { + "without_inline_constraints" + }, + n_columns, + ), + &table, + |b, table| b.iter(|| black_box(table).normalize()), + ); + } + } + group.finish(); +} + +criterion_group!(benches, bench_normalize); +criterion_main!(benches); diff --git a/crates/vespertide-core/src/action.rs b/crates/vespertide-core/src/action.rs deleted file mode 100644 index a8fe7437..00000000 --- a/crates/vespertide-core/src/action.rs +++ /dev/null @@ -1,1270 +0,0 @@ -use crate::schema::{ColumnDef, ColumnName, ColumnType, TableConstraint, TableName}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::fmt; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(rename_all = "snake_case")] -pub struct MigrationPlan { - /// Unique identifier for this migration (UUID format). - /// Defaults to empty string for backward compatibility with old migration files. - #[serde(default)] - pub id: String, - pub comment: Option, - #[serde(default)] - pub created_at: Option, - pub version: u32, - pub actions: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum MigrationAction { - CreateTable { - table: TableName, - columns: Vec, - constraints: Vec, - }, - DeleteTable { - table: TableName, - }, - AddColumn { - table: TableName, - column: Box, - /// Optional fill value to backfill existing rows when adding NOT NULL without default. - fill_with: Option, - }, - RenameColumn { - table: TableName, - from: ColumnName, - to: ColumnName, - }, - DeleteColumn { - table: TableName, - column: ColumnName, - }, - ModifyColumnType { - table: TableName, - column: ColumnName, - new_type: ColumnType, - /// Mapping of removed enum values to replacement values for safe enum value removal. - /// e.g., {"cancelled": "'pending'"} generates UPDATE before type change. - #[serde(default, skip_serializing_if = "Option::is_none")] - fill_with: Option>, - }, - ModifyColumnNullable { - table: TableName, - column: ColumnName, - nullable: bool, - /// Required when changing from nullable to non-nullable to backfill existing NULL values. - fill_with: Option, - /// When true, rows with NULL values in the column are deleted instead of backfilled. - /// Mutually exclusive with `fill_with`. Useful for FK columns where a valid fill value - /// may not exist. - #[serde(default, skip_serializing_if = "Option::is_none")] - delete_null_rows: Option, - }, - ModifyColumnDefault { - table: TableName, - column: ColumnName, - /// The new default value, or None to remove the default. - new_default: Option, - }, - ModifyColumnComment { - table: TableName, - column: ColumnName, - /// The new comment, or None to remove the comment. - new_comment: Option, - }, - AddConstraint { - table: TableName, - constraint: TableConstraint, - }, - RemoveConstraint { - table: TableName, - constraint: TableConstraint, - }, - ReplaceConstraint { - table: TableName, - from: TableConstraint, - to: TableConstraint, - }, - RenameTable { - from: TableName, - to: TableName, - }, - RawSql { - sql: String, - }, -} - -impl MigrationPlan { - /// Apply a prefix to all table names in the migration plan. - /// This modifies all table references in all actions. - pub fn with_prefix(self, prefix: &str) -> Self { - if prefix.is_empty() { - return self; - } - Self { - actions: self - .actions - .into_iter() - .map(|action| action.with_prefix(prefix)) - .collect(), - ..self - } - } -} - -impl MigrationAction { - /// Apply a prefix to all table names in this action. - pub fn with_prefix(self, prefix: &str) -> Self { - if prefix.is_empty() { - return self; - } - match self { - MigrationAction::CreateTable { - table, - columns, - constraints, - } => MigrationAction::CreateTable { - table: format!("{}{}", prefix, table), - columns, - constraints: constraints - .into_iter() - .map(|c| c.with_prefix(prefix)) - .collect(), - }, - MigrationAction::DeleteTable { table } => MigrationAction::DeleteTable { - table: format!("{}{}", prefix, table), - }, - MigrationAction::AddColumn { - table, - column, - fill_with, - } => MigrationAction::AddColumn { - table: format!("{}{}", prefix, table), - column, - fill_with, - }, - MigrationAction::RenameColumn { table, from, to } => MigrationAction::RenameColumn { - table: format!("{}{}", prefix, table), - from, - to, - }, - MigrationAction::DeleteColumn { table, column } => MigrationAction::DeleteColumn { - table: format!("{}{}", prefix, table), - column, - }, - MigrationAction::ModifyColumnType { - table, - column, - new_type, - fill_with, - } => MigrationAction::ModifyColumnType { - table: format!("{}{}", prefix, table), - column, - new_type, - fill_with, - }, - MigrationAction::ModifyColumnNullable { - table, - column, - nullable, - fill_with, - delete_null_rows, - } => MigrationAction::ModifyColumnNullable { - table: format!("{}{}", prefix, table), - column, - nullable, - fill_with, - delete_null_rows, - }, - MigrationAction::ModifyColumnDefault { - table, - column, - new_default, - } => MigrationAction::ModifyColumnDefault { - table: format!("{}{}", prefix, table), - column, - new_default, - }, - MigrationAction::ModifyColumnComment { - table, - column, - new_comment, - } => MigrationAction::ModifyColumnComment { - table: format!("{}{}", prefix, table), - column, - new_comment, - }, - MigrationAction::AddConstraint { table, constraint } => { - MigrationAction::AddConstraint { - table: format!("{}{}", prefix, table), - constraint: constraint.with_prefix(prefix), - } - } - MigrationAction::RemoveConstraint { table, constraint } => { - MigrationAction::RemoveConstraint { - table: format!("{}{}", prefix, table), - constraint: constraint.with_prefix(prefix), - } - } - MigrationAction::ReplaceConstraint { table, from, to } => { - MigrationAction::ReplaceConstraint { - table: format!("{}{}", prefix, table), - from: from.with_prefix(prefix), - to: to.with_prefix(prefix), - } - } - MigrationAction::RenameTable { from, to } => MigrationAction::RenameTable { - from: format!("{}{}", prefix, from), - to: format!("{}{}", prefix, to), - }, - MigrationAction::RawSql { sql } => MigrationAction::RawSql { sql }, - } - } -} - -impl fmt::Display for MigrationAction { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - MigrationAction::CreateTable { table, .. } => { - write!(f, "CreateTable: {}", table) - } - MigrationAction::DeleteTable { table } => { - write!(f, "DeleteTable: {}", table) - } - MigrationAction::AddColumn { table, column, .. } => { - write!(f, "AddColumn: {}.{}", table, column.name) - } - MigrationAction::RenameColumn { table, from, to } => { - write!(f, "RenameColumn: {}.{} -> {}", table, from, to) - } - MigrationAction::DeleteColumn { table, column } => { - write!(f, "DeleteColumn: {}.{}", table, column) - } - MigrationAction::ModifyColumnType { table, column, .. } => { - write!(f, "ModifyColumnType: {}.{}", table, column) - } - MigrationAction::ModifyColumnNullable { - table, - column, - nullable, - .. - } => { - let nullability = if *nullable { "NULL" } else { "NOT NULL" }; - write!( - f, - "ModifyColumnNullable: {}.{} -> {}", - table, column, nullability - ) - } - MigrationAction::ModifyColumnDefault { - table, - column, - new_default, - } => { - if let Some(default) = new_default { - write!( - f, - "ModifyColumnDefault: {}.{} -> {}", - table, column, default - ) - } else { - write!(f, "ModifyColumnDefault: {}.{} -> (none)", table, column) - } - } - MigrationAction::ModifyColumnComment { - table, - column, - new_comment, - } => { - if let Some(comment) = new_comment { - let display = if comment.chars().count() > 30 { - format!("{}...", comment.chars().take(27).collect::()) - } else { - comment.clone() - }; - write!( - f, - "ModifyColumnComment: {}.{} -> '{}'", - table, column, display - ) - } else { - write!(f, "ModifyColumnComment: {}.{} -> (none)", table, column) - } - } - MigrationAction::AddConstraint { table, constraint } => { - let constraint_name = match constraint { - TableConstraint::PrimaryKey { .. } => "PRIMARY KEY", - TableConstraint::Unique { name, .. } => { - if let Some(n) = name { - return write!(f, "AddConstraint: {}.{} (UNIQUE)", table, n); - } - "UNIQUE" - } - TableConstraint::ForeignKey { name, .. } => { - if let Some(n) = name { - return write!(f, "AddConstraint: {}.{} (FOREIGN KEY)", table, n); - } - "FOREIGN KEY" - } - TableConstraint::Check { name, .. } => { - return write!(f, "AddConstraint: {}.{} (CHECK)", table, name); - } - TableConstraint::Index { name, .. } => { - if let Some(n) = name { - return write!(f, "AddConstraint: {}.{} (INDEX)", table, n); - } - "INDEX" - } - }; - write!(f, "AddConstraint: {}.{}", table, constraint_name) - } - MigrationAction::RemoveConstraint { table, constraint } => { - let constraint_name = match constraint { - TableConstraint::PrimaryKey { .. } => "PRIMARY KEY", - TableConstraint::Unique { name, .. } => { - if let Some(n) = name { - return write!(f, "RemoveConstraint: {}.{} (UNIQUE)", table, n); - } - "UNIQUE" - } - TableConstraint::ForeignKey { name, .. } => { - if let Some(n) = name { - return write!(f, "RemoveConstraint: {}.{} (FOREIGN KEY)", table, n); - } - "FOREIGN KEY" - } - TableConstraint::Check { name, .. } => { - return write!(f, "RemoveConstraint: {}.{} (CHECK)", table, name); - } - TableConstraint::Index { name, .. } => { - if let Some(n) = name { - return write!(f, "RemoveConstraint: {}.{} (INDEX)", table, n); - } - "INDEX" - } - }; - write!(f, "RemoveConstraint: {}.{}", table, constraint_name) - } - MigrationAction::ReplaceConstraint { table, to, .. } => { - let constraint_name = match to { - TableConstraint::PrimaryKey { .. } => "PRIMARY KEY", - TableConstraint::Unique { name, .. } => { - if let Some(n) = name { - return write!(f, "ReplaceConstraint: {}.{} (UNIQUE)", table, n); - } - "UNIQUE" - } - TableConstraint::ForeignKey { name, .. } => { - if let Some(n) = name { - return write!(f, "ReplaceConstraint: {}.{} (FOREIGN KEY)", table, n); - } - "FOREIGN KEY" - } - TableConstraint::Check { name, .. } => { - return write!(f, "ReplaceConstraint: {}.{} (CHECK)", table, name); - } - TableConstraint::Index { name, .. } => { - if let Some(n) = name { - return write!(f, "ReplaceConstraint: {}.{} (INDEX)", table, n); - } - "INDEX" - } - }; - write!(f, "ReplaceConstraint: {}.{}", table, constraint_name) - } - MigrationAction::RenameTable { from, to } => { - write!(f, "RenameTable: {} -> {}", from, to) - } - MigrationAction::RawSql { sql } => { - // Truncate SQL if too long for display - let display_sql = if sql.len() > 50 { - format!("{}...", &sql[..47]) - } else { - sql.clone() - }; - write!(f, "RawSql: {}", display_sql) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::schema::{ReferenceAction, SimpleColumnType}; - use rstest::rstest; - - fn default_column() -> ColumnDef { - ColumnDef { - name: "email".into(), - r#type: ColumnType::Simple(SimpleColumnType::Text), - nullable: true, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - } - } - - #[rstest] - #[case::create_table( - MigrationAction::CreateTable { - table: "users".into(), - columns: vec![], - constraints: vec![], - }, - "CreateTable: users" - )] - #[case::delete_table( - MigrationAction::DeleteTable { - table: "users".into(), - }, - "DeleteTable: users" - )] - #[case::add_column( - MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(default_column()), - fill_with: None, - }, - "AddColumn: users.email" - )] - #[case::rename_column( - MigrationAction::RenameColumn { - table: "users".into(), - from: "old_name".into(), - to: "new_name".into(), - }, - "RenameColumn: users.old_name -> new_name" - )] - #[case::delete_column( - MigrationAction::DeleteColumn { - table: "users".into(), - column: "email".into(), - }, - "DeleteColumn: users.email" - )] - #[case::modify_column_type( - MigrationAction::ModifyColumnType { - table: "users".into(), - column: "age".into(), - new_type: ColumnType::Simple(SimpleColumnType::Integer), - fill_with: None, - }, - "ModifyColumnType: users.age" - )] - #[case::add_constraint_index_with_name( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::Index { - name: Some("ix_users__email".into()), - columns: vec!["email".into()], - }, - }, - "AddConstraint: users.ix_users__email (INDEX)" - )] - #[case::add_constraint_index_without_name( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::Index { - name: None, - columns: vec!["email".into()], - }, - }, - "AddConstraint: users.INDEX" - )] - #[case::remove_constraint_index_with_name( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::Index { - name: Some("ix_users__email".into()), - columns: vec!["email".into()], - }, - }, - "RemoveConstraint: users.ix_users__email (INDEX)" - )] - #[case::remove_constraint_index_without_name( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::Index { - name: None, - columns: vec!["email".into()], - }, - }, - "RemoveConstraint: users.INDEX" - )] - #[case::rename_table( - MigrationAction::RenameTable { - from: "old_table".into(), - to: "new_table".into(), - }, - "RenameTable: old_table -> new_table" - )] - fn test_display_basic_actions(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[rstest] - #[case::add_constraint_primary_key( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }, - }, - "AddConstraint: users.PRIMARY KEY" - )] - #[case::add_constraint_unique_with_name( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::Unique { - name: Some("uq_email".into()), - columns: vec!["email".into()], - }, - }, - "AddConstraint: users.uq_email (UNIQUE)" - )] - #[case::add_constraint_unique_without_name( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::Unique { - name: None, - columns: vec!["email".into()], - }, - }, - "AddConstraint: users.UNIQUE" - )] - #[case::add_constraint_foreign_key_with_name( - MigrationAction::AddConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::Cascade), - on_update: None, - }, - }, - "AddConstraint: posts.fk_user (FOREIGN KEY)" - )] - #[case::add_constraint_foreign_key_without_name( - MigrationAction::AddConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - "AddConstraint: posts.FOREIGN KEY" - )] - #[case::add_constraint_check( - MigrationAction::AddConstraint { - table: "users".into(), - constraint: TableConstraint::Check { - name: "chk_age".into(), - expr: "age > 0".into(), - }, - }, - "AddConstraint: users.chk_age (CHECK)" - )] - fn test_display_add_constraint(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[rstest] - #[case::remove_constraint_primary_key( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }, - }, - "RemoveConstraint: users.PRIMARY KEY" - )] - #[case::remove_constraint_unique_with_name( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::Unique { - name: Some("uq_email".into()), - columns: vec!["email".into()], - }, - }, - "RemoveConstraint: users.uq_email (UNIQUE)" - )] - #[case::remove_constraint_unique_without_name( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::Unique { - name: None, - columns: vec!["email".into()], - }, - }, - "RemoveConstraint: users.UNIQUE" - )] - #[case::remove_constraint_foreign_key_with_name( - MigrationAction::RemoveConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - "RemoveConstraint: posts.fk_user (FOREIGN KEY)" - )] - #[case::remove_constraint_foreign_key_without_name( - MigrationAction::RemoveConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - "RemoveConstraint: posts.FOREIGN KEY" - )] - #[case::remove_constraint_check( - MigrationAction::RemoveConstraint { - table: "users".into(), - constraint: TableConstraint::Check { - name: "chk_age".into(), - expr: "age > 0".into(), - }, - }, - "RemoveConstraint: users.chk_age (CHECK)" - )] - fn test_display_remove_constraint(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[rstest] - #[case::raw_sql_short( - MigrationAction::RawSql { - sql: "SELECT 1".into(), - }, - "RawSql: SELECT 1" - )] - fn test_display_raw_sql_short(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[test] - fn test_display_raw_sql_long() { - let action = MigrationAction::RawSql { - sql: - "SELECT * FROM users WHERE id = 1 AND name = 'test' AND email = 'test@example.com'" - .into(), - }; - let result = action.to_string(); - assert!(result.starts_with("RawSql: ")); - assert!(result.ends_with("...")); - assert!(result.len() > 10); - } - - #[rstest] - #[case::modify_column_nullable_to_not_null( - MigrationAction::ModifyColumnNullable { - table: "users".into(), - column: "email".into(), - nullable: false, - fill_with: None, - delete_null_rows: None, - }, - "ModifyColumnNullable: users.email -> NOT NULL" - )] - #[case::modify_column_nullable_to_null( - MigrationAction::ModifyColumnNullable { - table: "users".into(), - column: "email".into(), - nullable: true, - fill_with: None, - delete_null_rows: None, - }, - "ModifyColumnNullable: users.email -> NULL" - )] - fn test_display_modify_column_nullable( - #[case] action: MigrationAction, - #[case] expected: &str, - ) { - assert_eq!(action.to_string(), expected); - } - - #[rstest] - #[case::modify_column_default_set( - MigrationAction::ModifyColumnDefault { - table: "users".into(), - column: "status".into(), - new_default: Some("'active'".into()), - }, - "ModifyColumnDefault: users.status -> 'active'" - )] - #[case::modify_column_default_drop( - MigrationAction::ModifyColumnDefault { - table: "users".into(), - column: "status".into(), - new_default: None, - }, - "ModifyColumnDefault: users.status -> (none)" - )] - fn test_display_modify_column_default(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[rstest] - #[case::modify_column_comment_set( - MigrationAction::ModifyColumnComment { - table: "users".into(), - column: "email".into(), - new_comment: Some("User email address".into()), - }, - "ModifyColumnComment: users.email -> 'User email address'" - )] - #[case::modify_column_comment_drop( - MigrationAction::ModifyColumnComment { - table: "users".into(), - column: "email".into(), - new_comment: None, - }, - "ModifyColumnComment: users.email -> (none)" - )] - fn test_display_modify_column_comment(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[test] - fn test_display_modify_column_comment_long() { - // Test truncation for long comments (> 30 chars) - let action = MigrationAction::ModifyColumnComment { - table: "users".into(), - column: "email".into(), - new_comment: Some( - "This is a very long comment that should be truncated in display".into(), - ), - }; - let result = action.to_string(); - assert!(result.contains("...")); - assert!(result.contains("This is a very long comment")); - // Should be truncated at 27 chars + "..." - assert!(!result.contains("truncated in display")); - } - - // Tests for with_prefix - #[test] - fn test_action_with_prefix_create_table() { - let action = MigrationAction::CreateTable { - table: "users".into(), - columns: vec![default_column()], - constraints: vec![TableConstraint::ForeignKey { - name: Some("fk_org".into()), - columns: vec!["org_id".into()], - ref_table: "organizations".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }], - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::CreateTable { - table, constraints, .. - } = prefixed - { - assert_eq!(table.as_str(), "myapp_users"); - if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] { - assert_eq!(ref_table.as_str(), "myapp_organizations"); - } - } else { - panic!("Expected CreateTable"); - } - } - - #[test] - fn test_action_with_prefix_delete_table() { - let action = MigrationAction::DeleteTable { - table: "users".into(), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::DeleteTable { table } = prefixed { - assert_eq!(table.as_str(), "myapp_users"); - } else { - panic!("Expected DeleteTable"); - } - } - - #[test] - fn test_action_with_prefix_add_column() { - let action = MigrationAction::AddColumn { - table: "users".into(), - column: Box::new(default_column()), - fill_with: None, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::AddColumn { table, .. } = prefixed { - assert_eq!(table.as_str(), "myapp_users"); - } else { - panic!("Expected AddColumn"); - } - } - - #[test] - fn test_action_with_prefix_rename_table() { - let action = MigrationAction::RenameTable { - from: "old_table".into(), - to: "new_table".into(), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::RenameTable { from, to } = prefixed { - assert_eq!(from.as_str(), "myapp_old_table"); - assert_eq!(to.as_str(), "myapp_new_table"); - } else { - panic!("Expected RenameTable"); - } - } - - #[test] - fn test_action_with_prefix_raw_sql_unchanged() { - let action = MigrationAction::RawSql { - sql: "SELECT * FROM users".into(), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::RawSql { sql } = prefixed { - // RawSql is not modified - user is responsible for table names - assert_eq!(sql, "SELECT * FROM users"); - } else { - panic!("Expected RawSql"); - } - } - - #[test] - fn test_action_with_prefix_empty_prefix() { - let action = MigrationAction::CreateTable { - table: "users".into(), - columns: vec![], - constraints: vec![], - }; - let prefixed = action.clone().with_prefix(""); - if let MigrationAction::CreateTable { table, .. } = prefixed { - assert_eq!(table.as_str(), "users"); - } - } - - #[test] - fn test_migration_plan_with_prefix() { - let plan = MigrationPlan { - id: String::new(), - comment: Some("test".into()), - created_at: None, - version: 1, - actions: vec![ - MigrationAction::CreateTable { - table: "users".into(), - columns: vec![], - constraints: vec![], - }, - MigrationAction::CreateTable { - table: "posts".into(), - columns: vec![], - constraints: vec![TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }], - }, - ], - }; - let prefixed = plan.with_prefix("myapp_"); - assert_eq!(prefixed.actions.len(), 2); - - if let MigrationAction::CreateTable { table, .. } = &prefixed.actions[0] { - assert_eq!(table.as_str(), "myapp_users"); - } - if let MigrationAction::CreateTable { - table, constraints, .. - } = &prefixed.actions[1] - { - assert_eq!(table.as_str(), "myapp_posts"); - if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] { - assert_eq!(ref_table.as_str(), "myapp_users"); - } - } - } - - #[test] - fn test_action_with_prefix_rename_column() { - let action = MigrationAction::RenameColumn { - table: "users".into(), - from: "name".into(), - to: "full_name".into(), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::RenameColumn { table, from, to } = prefixed { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(from.as_str(), "name"); - assert_eq!(to.as_str(), "full_name"); - } else { - panic!("Expected RenameColumn"); - } - } - - #[test] - fn test_action_with_prefix_delete_column() { - let action = MigrationAction::DeleteColumn { - table: "users".into(), - column: "old_field".into(), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::DeleteColumn { table, column } = prefixed { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(column.as_str(), "old_field"); - } else { - panic!("Expected DeleteColumn"); - } - } - - #[test] - fn test_action_with_prefix_modify_column_type() { - let action = MigrationAction::ModifyColumnType { - table: "users".into(), - column: "age".into(), - new_type: ColumnType::Simple(SimpleColumnType::BigInt), - fill_with: None, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::ModifyColumnType { - table, - column, - new_type, - fill_with, - } = prefixed - { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(column.as_str(), "age"); - assert!(matches!( - new_type, - ColumnType::Simple(SimpleColumnType::BigInt) - )); - assert_eq!(fill_with, None); - } else { - panic!("Expected ModifyColumnType"); - } - } - - #[test] - fn test_action_with_prefix_modify_column_nullable() { - let action = MigrationAction::ModifyColumnNullable { - table: "users".into(), - column: "email".into(), - nullable: false, - fill_with: Some("default@example.com".into()), - delete_null_rows: None, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::ModifyColumnNullable { - table, - column, - nullable, - fill_with, - delete_null_rows, - } = prefixed - { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(column.as_str(), "email"); - assert!(!nullable); - assert_eq!(fill_with, Some("default@example.com".into())); - assert_eq!(delete_null_rows, None); - } else { - panic!("Expected ModifyColumnNullable"); - } - } - - #[test] - fn test_action_with_prefix_modify_column_default() { - let action = MigrationAction::ModifyColumnDefault { - table: "users".into(), - column: "status".into(), - new_default: Some("active".into()), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::ModifyColumnDefault { - table, - column, - new_default, - } = prefixed - { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(column.as_str(), "status"); - assert_eq!(new_default, Some("active".into())); - } else { - panic!("Expected ModifyColumnDefault"); - } - } - - #[test] - fn test_action_with_prefix_modify_column_comment() { - let action = MigrationAction::ModifyColumnComment { - table: "users".into(), - column: "bio".into(), - new_comment: Some("User biography".into()), - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::ModifyColumnComment { - table, - column, - new_comment, - } = prefixed - { - assert_eq!(table.as_str(), "myapp_users"); - assert_eq!(column.as_str(), "bio"); - assert_eq!(new_comment, Some("User biography".into())); - } else { - panic!("Expected ModifyColumnComment"); - } - } - - #[test] - fn test_action_with_prefix_add_constraint() { - let action = MigrationAction::AddConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::AddConstraint { table, constraint } = prefixed { - assert_eq!(table.as_str(), "myapp_posts"); - if let TableConstraint::ForeignKey { ref_table, .. } = constraint { - assert_eq!(ref_table.as_str(), "myapp_users"); - } else { - panic!("Expected ForeignKey constraint"); - } - } else { - panic!("Expected AddConstraint"); - } - } - - #[test] - fn test_action_with_prefix_remove_constraint() { - let action = MigrationAction::RemoveConstraint { - table: "posts".into(), - constraint: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::RemoveConstraint { table, constraint } = prefixed { - assert_eq!(table.as_str(), "myapp_posts"); - if let TableConstraint::ForeignKey { ref_table, .. } = constraint { - assert_eq!(ref_table.as_str(), "myapp_users"); - } else { - panic!("Expected ForeignKey constraint"); - } - } else { - panic!("Expected RemoveConstraint"); - } - } - - #[rstest] - #[case::replace_constraint_primary_key( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["id".into()], - }, - to: TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - }, - }, - "ReplaceConstraint: users.PRIMARY KEY" - )] - #[case::replace_constraint_unique_with_name( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::Unique { - name: None, - columns: vec!["email".into()], - }, - to: TableConstraint::Unique { - name: Some("uq_email".into()), - columns: vec!["email".into()], - }, - }, - "ReplaceConstraint: users.uq_email (UNIQUE)" - )] - #[case::replace_constraint_unique_without_name( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::Unique { - name: Some("uq_email".into()), - columns: vec!["email".into()], - }, - to: TableConstraint::Unique { - name: None, - columns: vec!["email".into()], - }, - }, - "ReplaceConstraint: users.UNIQUE" - )] - #[case::replace_constraint_foreign_key_with_name( - MigrationAction::ReplaceConstraint { - table: "posts".into(), - from: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - to: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - "ReplaceConstraint: posts.fk_user (FOREIGN KEY)" - )] - #[case::replace_constraint_foreign_key_without_name( - MigrationAction::ReplaceConstraint { - table: "posts".into(), - from: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - to: TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }, - }, - "ReplaceConstraint: posts.FOREIGN KEY" - )] - #[case::replace_constraint_check( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::Check { - name: "chk_age".into(), - expr: "age > 0".into(), - }, - to: TableConstraint::Check { - name: "chk_age".into(), - expr: "age >= 0".into(), - }, - }, - "ReplaceConstraint: users.chk_age (CHECK)" - )] - #[case::replace_constraint_index_with_name( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::Index { - name: None, - columns: vec!["email".into()], - }, - to: TableConstraint::Index { - name: Some("ix_users__email".into()), - columns: vec!["email".into()], - }, - }, - "ReplaceConstraint: users.ix_users__email (INDEX)" - )] - #[case::replace_constraint_index_without_name( - MigrationAction::ReplaceConstraint { - table: "users".into(), - from: TableConstraint::Index { - name: Some("ix_users__email".into()), - columns: vec!["email".into()], - }, - to: TableConstraint::Index { - name: None, - columns: vec!["email".into()], - }, - }, - "ReplaceConstraint: users.INDEX" - )] - fn test_display_replace_constraint(#[case] action: MigrationAction, #[case] expected: &str) { - assert_eq!(action.to_string(), expected); - } - - #[test] - fn test_action_with_prefix_replace_constraint() { - let action = MigrationAction::ReplaceConstraint { - table: "posts".into(), - from: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::Cascade), - on_update: None, - }, - to: TableConstraint::ForeignKey { - name: Some("fk_user".into()), - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::SetNull), - on_update: None, - }, - }; - let prefixed = action.with_prefix("myapp_"); - if let MigrationAction::ReplaceConstraint { table, from, to } = prefixed { - assert_eq!(table.as_str(), "myapp_posts"); - if let TableConstraint::ForeignKey { ref_table, .. } = from { - assert_eq!(ref_table.as_str(), "myapp_users"); - } else { - panic!("Expected ForeignKey constraint in from"); - } - if let TableConstraint::ForeignKey { ref_table, .. } = to { - assert_eq!(ref_table.as_str(), "myapp_users"); - } else { - panic!("Expected ForeignKey constraint in to"); - } - } else { - panic!("Expected ReplaceConstraint"); - } - } -} diff --git a/crates/vespertide-core/src/action/display.rs b/crates/vespertide-core/src/action/display.rs new file mode 100644 index 00000000..a0e3fa7b --- /dev/null +++ b/crates/vespertide-core/src/action/display.rs @@ -0,0 +1,148 @@ +use super::MigrationAction; +use crate::schema::TableConstraint; +use std::fmt; + +impl fmt::Display for MigrationAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write_migration_action(f, self) + } +} + +fn write_migration_action(f: &mut fmt::Formatter<'_>, action: &MigrationAction) -> fmt::Result { + match action { + MigrationAction::CreateTable { table, .. } => write!(f, "CreateTable: {table}"), + MigrationAction::DeleteTable { table } => write!(f, "DeleteTable: {table}"), + MigrationAction::AddColumn { table, column, .. } => { + write!(f, "AddColumn: {}.{}", table, column.name) + } + MigrationAction::RenameColumn { table, from, to } => { + write!(f, "RenameColumn: {table}.{from} -> {to}") + } + MigrationAction::DeleteColumn { table, column } => { + write!(f, "DeleteColumn: {table}.{column}") + } + MigrationAction::ModifyColumnType { table, column, .. } => { + write!(f, "ModifyColumnType: {table}.{column}") + } + MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + .. + } => write_nullable_action(f, table, column, *nullable), + MigrationAction::ModifyColumnDefault { + table, + column, + new_default, + } => write_default_action(f, table, column, new_default.as_deref()), + MigrationAction::ModifyColumnComment { + table, + column, + new_comment, + } => write_comment_action(f, table, column, new_comment.as_deref()), + MigrationAction::AddConstraint { table, constraint } => { + write_constraint_action(f, "AddConstraint", table, constraint) + } + MigrationAction::RemoveConstraint { table, constraint } => { + write_constraint_action(f, "RemoveConstraint", table, constraint) + } + MigrationAction::ReplaceConstraint { table, to, .. } => { + write_constraint_action(f, "ReplaceConstraint", table, to) + } + MigrationAction::RenameTable { from, to } => write!(f, "RenameTable: {from} -> {to}"), + MigrationAction::RawSql { sql } => write_raw_sql_action(f, sql), + } +} + +fn write_nullable_action( + f: &mut fmt::Formatter<'_>, + table: &str, + column: &str, + nullable: bool, +) -> fmt::Result { + let nullability = if nullable { "NULL" } else { "NOT NULL" }; + write!(f, "ModifyColumnNullable: {table}.{column} -> {nullability}") +} + +fn write_default_action( + f: &mut fmt::Formatter<'_>, + table: &str, + column: &str, + default: Option<&str>, +) -> fmt::Result { + if let Some(default) = default { + write!(f, "ModifyColumnDefault: {table}.{column} -> {default}") + } else { + write!(f, "ModifyColumnDefault: {table}.{column} -> (none)") + } +} + +fn write_comment_action( + f: &mut fmt::Formatter<'_>, + table: &str, + column: &str, + comment: Option<&str>, +) -> fmt::Result { + if let Some(comment) = comment { + let display = truncate_comment(comment); + write!(f, "ModifyColumnComment: {table}.{column} -> '{display}'") + } else { + write!(f, "ModifyColumnComment: {table}.{column} -> (none)") + } +} + +fn truncate_comment(comment: &str) -> String { + if comment.chars().count() > 30 { + format!("{}...", truncate_chars(comment, 27)) + } else { + comment.to_string() + } +} + +fn truncate_chars(s: &str, max_chars: usize) -> String { + s.chars().take(max_chars).collect() +} + +fn write_raw_sql_action(f: &mut fmt::Formatter<'_>, sql: &str) -> fmt::Result { + let display_sql = if sql.chars().count() > 50 { + format!("{}...", truncate_chars(sql, 47)) + } else { + sql.to_string() + }; + write!(f, "RawSql: {display_sql}") +} + +fn write_constraint_action( + f: &mut fmt::Formatter<'_>, + action: &str, + table: &str, + constraint: &TableConstraint, +) -> fmt::Result { + match constraint { + TableConstraint::PrimaryKey { .. } => write!(f, "{action}: {table}.PRIMARY KEY"), + TableConstraint::Unique { name, .. } => { + write_named_constraint(f, action, table, name.as_ref(), "UNIQUE") + } + TableConstraint::ForeignKey { name, .. } => { + write_named_constraint(f, action, table, name.as_ref(), "FOREIGN KEY") + } + TableConstraint::Check { name, .. } => write!(f, "{action}: {table}.{name} (CHECK)"), + TableConstraint::Index { name, .. } => { + write_named_constraint(f, action, table, name.as_ref(), "INDEX") + } + } +} + +fn write_named_constraint( + f: &mut fmt::Formatter<'_>, + action: &str, + table: &str, + name: Option<&String>, + fallback: &str, +) -> fmt::Result { + if let Some(name) = name { + write!(f, "{action}: {table}.{name} ({fallback})") + } else { + write!(f, "{action}: {table}.{fallback}") + } +} diff --git a/crates/vespertide-core/src/action/mod.rs b/crates/vespertide-core/src/action/mod.rs new file mode 100644 index 00000000..bf76a8f5 --- /dev/null +++ b/crates/vespertide-core/src/action/mod.rs @@ -0,0 +1,165 @@ +mod display; +mod prefix; + +#[cfg(test)] +mod tests; + +use crate::schema::{ColumnDef, ColumnName, ColumnType, TableConstraint, TableName}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// A single versioned migration, grouping a set of [`MigrationAction`]s under a version number. +/// +/// Migration plans are auto-generated by `vespertide revision` and stored as JSON or YAML files +/// in the `migrations/` directory. **Never create or edit these files manually.** +/// +/// The `version` field is a monotonically increasing integer. The `id` is a UUID that guards +/// against accidental plan substitution when the same version number appears in two different +/// migration histories. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub struct MigrationPlan { + /// Unique identifier for this migration (UUID format). + /// Defaults to empty string for backward compatibility with old migration files. + #[serde(default)] + pub id: String, + /// Human-readable description of what this migration does (from `-m "message"`). + pub comment: Option, + /// ISO 8601 timestamp of when the migration file was generated. + #[serde(default)] + pub created_at: Option, + /// Monotonically increasing version number, starting at 1. + pub version: u32, + /// Ordered list of schema changes to apply in this migration. + pub actions: Vec, +} + +/// A single schema change produced by the planner and consumed by the SQL generator. +/// +/// The planner emits a `Vec` when diffing two schemas. The SQL generator +/// (`vespertide-query`) translates each action into backend-specific DDL statements. +/// +/// Prefer typed actions over [`MigrationAction::RawSql`]. Raw SQL is an emergency escape hatch: +/// it is not portable across backends and is skipped during baseline replay, which means the +/// planner cannot reason about it. +/// +/// This enum is `#[non_exhaustive]`: new variants may be added in future releases. +/// Downstream `match` expressions should include a wildcard arm. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(tag = "type", rename_all = "snake_case")] +#[non_exhaustive] +pub enum MigrationAction { + /// Create a new table with the given columns and constraints (`CREATE TABLE`). + CreateTable { + table: TableName, + columns: Vec, + constraints: Vec, + }, + /// Drop an existing table and all its data (`DROP TABLE`). + DeleteTable { table: TableName }, + /// Add a new column to an existing table (`ALTER TABLE ... ADD COLUMN`). + AddColumn { + table: TableName, + column: Box, + /// Optional fill value to backfill existing rows when adding NOT NULL without default. + fill_with: Option, + }, + /// Rename a column in an existing table (`ALTER TABLE ... RENAME COLUMN`). + RenameColumn { + table: TableName, + from: ColumnName, + to: ColumnName, + }, + /// Remove a column from an existing table (`ALTER TABLE ... DROP COLUMN`). + DeleteColumn { + table: TableName, + column: ColumnName, + }, + /// Change the SQL type of an existing column (`ALTER TABLE ... ALTER COLUMN ... TYPE`). + ModifyColumnType { + table: TableName, + column: ColumnName, + new_type: ColumnType, + /// Mapping of removed enum values to replacement values for safe enum value removal. + /// e.g., `{"cancelled": "'pending'"}` generates an `UPDATE` before the type change. + #[serde(default, skip_serializing_if = "Option::is_none")] + fill_with: Option>, + }, + /// Change whether a column accepts `NULL` values. + ModifyColumnNullable { + table: TableName, + column: ColumnName, + nullable: bool, + /// Required when changing from nullable to non-nullable to backfill existing NULL values. + fill_with: Option, + /// When true, rows with NULL values in the column are deleted instead of backfilled. + /// Mutually exclusive with `fill_with`. Useful for FK columns where a valid fill value + /// may not exist. + #[serde(default, skip_serializing_if = "Option::is_none")] + delete_null_rows: Option, + }, + /// Change or remove the default value of a column. + ModifyColumnDefault { + table: TableName, + column: ColumnName, + /// The new default value, or `None` to remove the default. + new_default: Option, + }, + /// Change or remove the comment on a column. + ModifyColumnComment { + table: TableName, + column: ColumnName, + /// The new comment, or `None` to remove the comment. + new_comment: Option, + }, + /// Add a constraint (primary key, unique, foreign key, check, or index) to a table. + AddConstraint { + table: TableName, + constraint: TableConstraint, + }, + /// Remove a constraint from a table. + RemoveConstraint { + table: TableName, + constraint: TableConstraint, + }, + /// Atomically replace one constraint with another (e.g. when columns in a composite key change). + ReplaceConstraint { + table: TableName, + from: TableConstraint, + to: TableConstraint, + }, + /// Rename a table (`ALTER TABLE ... RENAME TO`). + RenameTable { from: TableName, to: TableName }, + /// Execute a raw SQL statement verbatim. + /// + /// **Emergency escape hatch only.** Raw SQL is not portable across backends and is invisible + /// to baseline replay, so the planner cannot reason about schema state after this action. + /// Use typed actions whenever possible. + RawSql { sql: String }, +} + +impl MigrationAction { + /// Returns the primary table this action affects, if any. + /// Returns None for actions that don't bind to a single table (e.g. `RawSql`). + #[must_use] + pub fn table_name(&self) -> Option<&str> { + match self { + Self::CreateTable { table, .. } + | Self::DeleteTable { table } + | Self::AddColumn { table, .. } + | Self::DeleteColumn { table, .. } + | Self::RenameColumn { table, .. } + | Self::ModifyColumnType { table, .. } + | Self::ModifyColumnNullable { table, .. } + | Self::ModifyColumnDefault { table, .. } + | Self::ModifyColumnComment { table, .. } + | Self::AddConstraint { table, .. } + | Self::RemoveConstraint { table, .. } + | Self::ReplaceConstraint { table, .. } => Some(table.as_str()), + Self::RenameTable { from, .. } => Some(from.as_str()), + Self::RawSql { .. } => None, + } + } +} diff --git a/crates/vespertide-core/src/action/prefix.rs b/crates/vespertide-core/src/action/prefix.rs new file mode 100644 index 00000000..b2bb91f8 --- /dev/null +++ b/crates/vespertide-core/src/action/prefix.rs @@ -0,0 +1,150 @@ +use super::{MigrationAction, MigrationPlan}; + +impl MigrationPlan { + /// Apply a prefix to all table names in the migration plan. + /// This modifies all table references in all actions. + pub fn with_prefix(self, prefix: &str) -> Self { + if prefix.is_empty() { + return self; + } + Self { + actions: self + .actions + .into_iter() + .map(|action| action.with_prefix(prefix)) + .collect(), + ..self + } + } +} + +impl MigrationAction { + /// Apply a prefix to all table names in this action. + pub fn with_prefix(self, prefix: &str) -> Self { + if prefix.is_empty() { + return self; + } + + prefix_migration_action(self, prefix) + } +} + +fn prefix_migration_action(action: MigrationAction, prefix: &str) -> MigrationAction { + match action { + MigrationAction::CreateTable { + table, + columns, + constraints, + } => MigrationAction::CreateTable { + table: add_prefix(table, prefix), + columns, + constraints: constraints + .into_iter() + .map(|c| c.with_prefix(prefix)) + .collect(), + }, + MigrationAction::DeleteTable { table } => MigrationAction::DeleteTable { + table: add_prefix(table, prefix), + }, + MigrationAction::RenameTable { from, to } => MigrationAction::RenameTable { + from: add_prefix(from, prefix), + to: add_prefix(to, prefix), + }, + MigrationAction::RawSql { sql } => MigrationAction::RawSql { sql }, + action => prefix_column_or_constraint_action(action, prefix), + } +} + +fn prefix_column_or_constraint_action(action: MigrationAction, prefix: &str) -> MigrationAction { + match action { + MigrationAction::AddColumn { + table, + column, + fill_with, + } => MigrationAction::AddColumn { + table: add_prefix(table, prefix), + column, + fill_with, + }, + MigrationAction::RenameColumn { table, from, to } => MigrationAction::RenameColumn { + table: add_prefix(table, prefix), + from, + to, + }, + MigrationAction::DeleteColumn { table, column } => MigrationAction::DeleteColumn { + table: add_prefix(table, prefix), + column, + }, + MigrationAction::ModifyColumnType { + table, + column, + new_type, + fill_with, + } => MigrationAction::ModifyColumnType { + table: add_prefix(table, prefix), + column, + new_type, + fill_with, + }, + MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + fill_with, + delete_null_rows, + } => MigrationAction::ModifyColumnNullable { + table: add_prefix(table, prefix), + column, + nullable, + fill_with, + delete_null_rows, + }, + action => prefix_remaining_action(action, prefix), + } +} + +fn prefix_remaining_action(action: MigrationAction, prefix: &str) -> MigrationAction { + match action { + MigrationAction::ModifyColumnDefault { + table, + column, + new_default, + } => MigrationAction::ModifyColumnDefault { + table: add_prefix(table, prefix), + column, + new_default, + }, + MigrationAction::ModifyColumnComment { + table, + column, + new_comment, + } => MigrationAction::ModifyColumnComment { + table: add_prefix(table, prefix), + column, + new_comment, + }, + MigrationAction::AddConstraint { table, constraint } => MigrationAction::AddConstraint { + table: format!("{prefix}{table}"), + constraint: constraint.with_prefix(prefix), + }, + MigrationAction::RemoveConstraint { table, constraint } => { + MigrationAction::RemoveConstraint { + table: add_prefix(table, prefix), + constraint: constraint.with_prefix(prefix), + } + } + MigrationAction::ReplaceConstraint { table, from, to } => { + MigrationAction::ReplaceConstraint { + table: add_prefix(table, prefix), + from: from.with_prefix(prefix), + to: to.with_prefix(prefix), + } + } + other => other, + } +} + +fn add_prefix(mut table: String, prefix: &str) -> String { + table.insert_str(0, prefix); + table +} diff --git a/crates/vespertide-core/src/action/tests.rs b/crates/vespertide-core/src/action/tests.rs new file mode 100644 index 00000000..30cda79a --- /dev/null +++ b/crates/vespertide-core/src/action/tests.rs @@ -0,0 +1,897 @@ +use super::*; +use crate::schema::{ReferenceAction, SimpleColumnType}; +use rstest::rstest; + +fn default_column() -> ColumnDef { + ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + } +} + +#[rstest] +#[case::create_table( + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![], + constraints: vec![], + }, + "CreateTable: users" + )] +#[case::delete_table( + MigrationAction::DeleteTable { + table: "users".into(), + }, + "DeleteTable: users" + )] +#[case::add_column( + MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(default_column()), + fill_with: None, + }, + "AddColumn: users.email" + )] +#[case::rename_column( + MigrationAction::RenameColumn { + table: "users".into(), + from: "old_name".into(), + to: "new_name".into(), + }, + "RenameColumn: users.old_name -> new_name" + )] +#[case::delete_column( + MigrationAction::DeleteColumn { + table: "users".into(), + column: "email".into(), + }, + "DeleteColumn: users.email" + )] +#[case::modify_column_type( + MigrationAction::ModifyColumnType { + table: "users".into(), + column: "age".into(), + new_type: ColumnType::Simple(SimpleColumnType::Integer), + fill_with: None, + }, + "ModifyColumnType: users.age" + )] +#[case::add_constraint_index_with_name( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::Index { + name: Some("ix_users__email".into()), + columns: vec!["email".into()], + }, + }, + "AddConstraint: users.ix_users__email (INDEX)" + )] +#[case::add_constraint_index_without_name( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::Index { + name: None, + columns: vec!["email".into()], + }, + }, + "AddConstraint: users.INDEX" + )] +#[case::remove_constraint_index_with_name( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Index { + name: Some("ix_users__email".into()), + columns: vec!["email".into()], + }, + }, + "RemoveConstraint: users.ix_users__email (INDEX)" + )] +#[case::remove_constraint_index_without_name( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Index { + name: None, + columns: vec!["email".into()], + }, + }, + "RemoveConstraint: users.INDEX" + )] +#[case::rename_table( + MigrationAction::RenameTable { + from: "old_table".into(), + to: "new_table".into(), + }, + "RenameTable: old_table -> new_table" + )] +fn test_display_basic_actions(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[test] +fn test_display_raw_sql_truncates_unicode_without_panicking() { + let sql = "COMMENT ON COLUMN 한국어테이블.이름 IS '日本語 café 📊';".repeat(3); + let action = MigrationAction::RawSql { sql }; + + let display = action.to_string(); + + assert!(display.starts_with("RawSql: COMMENT ON COLUMN 한국어테이블")); + assert!(display.ends_with("...")); +} + +#[rstest] +#[case::create_table( + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![], + constraints: vec![], + }, + Some("users") +)] +#[case::rename_table( + MigrationAction::RenameTable { + from: "old_users".into(), + to: "users".into(), + }, + Some("old_users") +)] +#[case::raw_sql(MigrationAction::RawSql { sql: "SELECT 1".into() }, None)] +fn test_table_name(#[case] action: MigrationAction, #[case] expected: Option<&str>) { + assert_eq!(action.table_name(), expected); +} + +#[rstest] +#[case::add_constraint_primary_key( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }, + }, + "AddConstraint: users.PRIMARY KEY" + )] +#[case::add_constraint_unique_with_name( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::Unique { + name: Some("uq_email".into()), + columns: vec!["email".into()], + }, + }, + "AddConstraint: users.uq_email (UNIQUE)" + )] +#[case::add_constraint_unique_without_name( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + }, + "AddConstraint: users.UNIQUE" + )] +#[case::add_constraint_foreign_key_with_name( + MigrationAction::AddConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + }, + }, + "AddConstraint: posts.fk_user (FOREIGN KEY)" + )] +#[case::add_constraint_foreign_key_without_name( + MigrationAction::AddConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + "AddConstraint: posts.FOREIGN KEY" + )] +#[case::add_constraint_check( + MigrationAction::AddConstraint { + table: "users".into(), + constraint: TableConstraint::Check { + name: "chk_age".into(), + expr: "age > 0".into(), + }, + }, + "AddConstraint: users.chk_age (CHECK)" + )] +fn test_display_add_constraint(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[rstest] +#[case::remove_constraint_primary_key( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }, + }, + "RemoveConstraint: users.PRIMARY KEY" + )] +#[case::remove_constraint_unique_with_name( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Unique { + name: Some("uq_email".into()), + columns: vec!["email".into()], + }, + }, + "RemoveConstraint: users.uq_email (UNIQUE)" + )] +#[case::remove_constraint_unique_without_name( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + }, + "RemoveConstraint: users.UNIQUE" + )] +#[case::remove_constraint_foreign_key_with_name( + MigrationAction::RemoveConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + "RemoveConstraint: posts.fk_user (FOREIGN KEY)" + )] +#[case::remove_constraint_foreign_key_without_name( + MigrationAction::RemoveConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + "RemoveConstraint: posts.FOREIGN KEY" + )] +#[case::remove_constraint_check( + MigrationAction::RemoveConstraint { + table: "users".into(), + constraint: TableConstraint::Check { + name: "chk_age".into(), + expr: "age > 0".into(), + }, + }, + "RemoveConstraint: users.chk_age (CHECK)" + )] +fn test_display_remove_constraint(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[rstest] +#[case::raw_sql_short( + MigrationAction::RawSql { + sql: "SELECT 1".into(), + }, + "RawSql: SELECT 1" + )] +fn test_display_raw_sql_short(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[test] +fn test_display_raw_sql_long() { + let action = MigrationAction::RawSql { + sql: "SELECT * FROM users WHERE id = 1 AND name = 'test' AND email = 'test@example.com'" + .into(), + }; + let result = action.to_string(); + assert!(result.starts_with("RawSql: ")); + assert!(result.ends_with("...")); + assert!(result.len() > 10); +} + +#[rstest] +#[case::modify_column_nullable_to_not_null( + MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "email".into(), + nullable: false, + fill_with: None, + delete_null_rows: None, + }, + "ModifyColumnNullable: users.email -> NOT NULL" + )] +#[case::modify_column_nullable_to_null( + MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "email".into(), + nullable: true, + fill_with: None, + delete_null_rows: None, + }, + "ModifyColumnNullable: users.email -> NULL" + )] +fn test_display_modify_column_nullable(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[rstest] +#[case::modify_column_default_set( + MigrationAction::ModifyColumnDefault { + table: "users".into(), + column: "status".into(), + new_default: Some("'active'".into()), + }, + "ModifyColumnDefault: users.status -> 'active'" + )] +#[case::modify_column_default_drop( + MigrationAction::ModifyColumnDefault { + table: "users".into(), + column: "status".into(), + new_default: None, + }, + "ModifyColumnDefault: users.status -> (none)" + )] +fn test_display_modify_column_default(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[rstest] +#[case::modify_column_comment_set( + MigrationAction::ModifyColumnComment { + table: "users".into(), + column: "email".into(), + new_comment: Some("User email address".into()), + }, + "ModifyColumnComment: users.email -> 'User email address'" + )] +#[case::modify_column_comment_drop( + MigrationAction::ModifyColumnComment { + table: "users".into(), + column: "email".into(), + new_comment: None, + }, + "ModifyColumnComment: users.email -> (none)" + )] +fn test_display_modify_column_comment(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[test] +fn test_display_modify_column_comment_long() { + // Test truncation for long comments (> 30 chars) + let action = MigrationAction::ModifyColumnComment { + table: "users".into(), + column: "email".into(), + new_comment: Some("This is a very long comment that should be truncated in display".into()), + }; + let result = action.to_string(); + assert!(result.contains("...")); + assert!(result.contains("This is a very long comment")); + // Should be truncated at 27 chars + "..." + assert!(!result.contains("truncated in display")); +} + +// Tests for with_prefix +#[test] +fn test_action_with_prefix_create_table() { + let action = MigrationAction::CreateTable { + table: "users".into(), + columns: vec![default_column()], + constraints: vec![TableConstraint::ForeignKey { + name: Some("fk_org".into()), + columns: vec!["org_id".into()], + ref_table: "organizations".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }], + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::CreateTable { + table, constraints, .. + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] { + assert_eq!(ref_table.as_str(), "myapp_organizations"); + } + } else { + panic!("Expected CreateTable"); + } +} + +#[test] +fn test_action_with_prefix_delete_table() { + let action = MigrationAction::DeleteTable { + table: "users".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::DeleteTable { table } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + } else { + panic!("Expected DeleteTable"); + } +} + +#[test] +fn test_action_with_prefix_add_column() { + let action = MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(default_column()), + fill_with: None, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::AddColumn { table, .. } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + } else { + panic!("Expected AddColumn"); + } +} + +#[test] +fn test_action_with_prefix_rename_table() { + let action = MigrationAction::RenameTable { + from: "old_table".into(), + to: "new_table".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RenameTable { from, to } = prefixed { + assert_eq!(from.as_str(), "myapp_old_table"); + assert_eq!(to.as_str(), "myapp_new_table"); + } else { + panic!("Expected RenameTable"); + } +} + +#[test] +fn test_action_with_prefix_raw_sql_unchanged() { + let action = MigrationAction::RawSql { + sql: "SELECT * FROM users".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RawSql { sql } = prefixed { + // RawSql is not modified - user is responsible for table names + assert_eq!(sql, "SELECT * FROM users"); + } else { + panic!("Expected RawSql"); + } +} + +#[test] +fn test_action_with_prefix_empty_prefix() { + let action = MigrationAction::CreateTable { + table: "users".into(), + columns: vec![], + constraints: vec![], + }; + let prefixed = action.clone().with_prefix(""); + if let MigrationAction::CreateTable { table, .. } = prefixed { + assert_eq!(table.as_str(), "users"); + } +} + +#[test] +fn test_migration_plan_with_prefix() { + let plan = MigrationPlan { + id: String::new(), + comment: Some("test".into()), + created_at: None, + version: 1, + actions: vec![ + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![], + constraints: vec![], + }, + MigrationAction::CreateTable { + table: "posts".into(), + columns: vec![], + constraints: vec![TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }], + }, + ], + }; + let prefixed = plan.with_prefix("myapp_"); + assert_eq!(prefixed.actions.len(), 2); + + if let MigrationAction::CreateTable { table, .. } = &prefixed.actions[0] { + assert_eq!(table.as_str(), "myapp_users"); + } + if let MigrationAction::CreateTable { + table, constraints, .. + } = &prefixed.actions[1] + { + assert_eq!(table.as_str(), "myapp_posts"); + if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] { + assert_eq!(ref_table.as_str(), "myapp_users"); + } + } +} + +#[test] +fn test_action_with_prefix_rename_column() { + let action = MigrationAction::RenameColumn { + table: "users".into(), + from: "name".into(), + to: "full_name".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RenameColumn { table, from, to } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(from.as_str(), "name"); + assert_eq!(to.as_str(), "full_name"); + } else { + panic!("Expected RenameColumn"); + } +} + +#[test] +fn test_action_with_prefix_delete_column() { + let action = MigrationAction::DeleteColumn { + table: "users".into(), + column: "old_field".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::DeleteColumn { table, column } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "old_field"); + } else { + panic!("Expected DeleteColumn"); + } +} + +#[test] +fn test_action_with_prefix_modify_column_type() { + let action = MigrationAction::ModifyColumnType { + table: "users".into(), + column: "age".into(), + new_type: ColumnType::Simple(SimpleColumnType::BigInt), + fill_with: None, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnType { + table, + column, + new_type, + fill_with, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "age"); + assert!(matches!( + new_type, + ColumnType::Simple(SimpleColumnType::BigInt) + )); + assert_eq!(fill_with, None); + } else { + panic!("Expected ModifyColumnType"); + } +} + +#[test] +fn test_action_with_prefix_modify_column_nullable() { + let action = MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "email".into(), + nullable: false, + fill_with: Some("default@example.com".into()), + delete_null_rows: None, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + fill_with, + delete_null_rows, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "email"); + assert!(!nullable); + assert_eq!(fill_with, Some("default@example.com".into())); + assert_eq!(delete_null_rows, None); + } else { + panic!("Expected ModifyColumnNullable"); + } +} + +#[test] +fn test_action_with_prefix_modify_column_default() { + let action = MigrationAction::ModifyColumnDefault { + table: "users".into(), + column: "status".into(), + new_default: Some("active".into()), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnDefault { + table, + column, + new_default, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "status"); + assert_eq!(new_default, Some("active".into())); + } else { + panic!("Expected ModifyColumnDefault"); + } +} + +#[test] +fn test_action_with_prefix_modify_column_comment() { + let action = MigrationAction::ModifyColumnComment { + table: "users".into(), + column: "bio".into(), + new_comment: Some("User biography".into()), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnComment { + table, + column, + new_comment, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "bio"); + assert_eq!(new_comment, Some("User biography".into())); + } else { + panic!("Expected ModifyColumnComment"); + } +} + +#[test] +fn test_action_with_prefix_add_constraint() { + let action = MigrationAction::AddConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::AddConstraint { table, constraint } = prefixed { + assert_eq!(table.as_str(), "myapp_posts"); + if let TableConstraint::ForeignKey { ref_table, .. } = constraint { + assert_eq!(ref_table.as_str(), "myapp_users"); + } else { + panic!("Expected ForeignKey constraint"); + } + } else { + panic!("Expected AddConstraint"); + } +} + +#[test] +fn test_action_with_prefix_remove_constraint() { + let action = MigrationAction::RemoveConstraint { + table: "posts".into(), + constraint: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RemoveConstraint { table, constraint } = prefixed { + assert_eq!(table.as_str(), "myapp_posts"); + if let TableConstraint::ForeignKey { ref_table, .. } = constraint { + assert_eq!(ref_table.as_str(), "myapp_users"); + } else { + panic!("Expected ForeignKey constraint"); + } + } else { + panic!("Expected RemoveConstraint"); + } +} + +#[rstest] +#[case::replace_constraint_primary_key( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }, + to: TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }, + }, + "ReplaceConstraint: users.PRIMARY KEY" + )] +#[case::replace_constraint_unique_with_name( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + to: TableConstraint::Unique { + name: Some("uq_email".into()), + columns: vec!["email".into()], + }, + }, + "ReplaceConstraint: users.uq_email (UNIQUE)" + )] +#[case::replace_constraint_unique_without_name( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::Unique { + name: Some("uq_email".into()), + columns: vec!["email".into()], + }, + to: TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + }, + "ReplaceConstraint: users.UNIQUE" + )] +#[case::replace_constraint_foreign_key_with_name( + MigrationAction::ReplaceConstraint { + table: "posts".into(), + from: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + to: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + "ReplaceConstraint: posts.fk_user (FOREIGN KEY)" + )] +#[case::replace_constraint_foreign_key_without_name( + MigrationAction::ReplaceConstraint { + table: "posts".into(), + from: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + to: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + "ReplaceConstraint: posts.FOREIGN KEY" + )] +#[case::replace_constraint_check( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::Check { + name: "chk_age".into(), + expr: "age > 0".into(), + }, + to: TableConstraint::Check { + name: "chk_age".into(), + expr: "age >= 0".into(), + }, + }, + "ReplaceConstraint: users.chk_age (CHECK)" + )] +#[case::replace_constraint_index_with_name( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::Index { + name: None, + columns: vec!["email".into()], + }, + to: TableConstraint::Index { + name: Some("ix_users__email".into()), + columns: vec!["email".into()], + }, + }, + "ReplaceConstraint: users.ix_users__email (INDEX)" + )] +#[case::replace_constraint_index_without_name( + MigrationAction::ReplaceConstraint { + table: "users".into(), + from: TableConstraint::Index { + name: Some("ix_users__email".into()), + columns: vec!["email".into()], + }, + to: TableConstraint::Index { + name: None, + columns: vec!["email".into()], + }, + }, + "ReplaceConstraint: users.INDEX" + )] +fn test_display_replace_constraint(#[case] action: MigrationAction, #[case] expected: &str) { + assert_eq!(action.to_string(), expected); +} + +#[test] +fn test_action_with_prefix_replace_constraint() { + let action = MigrationAction::ReplaceConstraint { + table: "posts".into(), + from: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + }, + to: TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::SetNull), + on_update: None, + }, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ReplaceConstraint { table, from, to } = prefixed { + assert_eq!(table.as_str(), "myapp_posts"); + if let TableConstraint::ForeignKey { ref_table, .. } = from { + assert_eq!(ref_table.as_str(), "myapp_users"); + } else { + panic!("Expected ForeignKey constraint in from"); + } + if let TableConstraint::ForeignKey { ref_table, .. } = to { + assert_eq!(ref_table.as_str(), "myapp_users"); + } else { + panic!("Expected ForeignKey constraint in to"); + } + } else { + panic!("Expected ReplaceConstraint"); + } +} diff --git a/crates/vespertide-core/src/arbitrary/mod.rs b/crates/vespertide-core/src/arbitrary/mod.rs new file mode 100644 index 00000000..70027f82 --- /dev/null +++ b/crates/vespertide-core/src/arbitrary/mod.rs @@ -0,0 +1,430 @@ +use std::collections::BTreeSet; + +use proptest::{collection, prelude::*}; + +use crate::{ + MigrationAction, + schema::{ + ColumnDef, ColumnType, ComplexColumnType, DefaultValue, EnumValues, NumValue, + ReferenceAction, SimpleColumnType, StrOrBoolOrArray, StringOrBool, TableConstraint, + TableDef, + foreign_key::{ForeignKeyDef, ForeignKeySyntax, ReferenceSyntaxDef}, + primary_key::{PrimaryKeyDef, PrimaryKeySyntax}, + }, +}; + +/// Generates `snake_case` SQL-safe identifiers matching `[a-z][a-z0-9_]{0,20}`. +pub fn arb_safe_ident() -> impl Strategy { + ( + prop::char::range('a', 'z'), + collection::vec( + prop_oneof![ + prop::char::range('a', 'z'), + prop::char::range('0', '9'), + Just('_'), + ], + 0..=20, + ), + ) + .prop_map(|(first, rest)| { + let mut ident = String::with_capacity(rest.len() + 1); + ident.push(first); + ident.extend(rest); + ident + }) +} + +pub fn arb_simple_column_type() -> impl Strategy { + prop_oneof![ + Just(SimpleColumnType::SmallInt), + Just(SimpleColumnType::Integer), + Just(SimpleColumnType::BigInt), + Just(SimpleColumnType::Real), + Just(SimpleColumnType::DoublePrecision), + Just(SimpleColumnType::Text), + Just(SimpleColumnType::Boolean), + Just(SimpleColumnType::Date), + Just(SimpleColumnType::Time), + Just(SimpleColumnType::Timestamp), + Just(SimpleColumnType::Timestamptz), + Just(SimpleColumnType::Interval), + Just(SimpleColumnType::Bytea), + Just(SimpleColumnType::Uuid), + Just(SimpleColumnType::Json), + Just(SimpleColumnType::Inet), + Just(SimpleColumnType::Cidr), + Just(SimpleColumnType::Macaddr), + Just(SimpleColumnType::Xml), + ] +} + +pub fn arb_complex_column_type() -> impl Strategy { + prop_oneof![ + (1_u32..=512).prop_map(|length| ComplexColumnType::Varchar { length }), + (1_u32..=64, 0_u32..=16) + .prop_filter("scale must be <= precision", |(precision, scale)| { + scale <= precision + }) + .prop_map(|(precision, scale)| ComplexColumnType::Numeric { precision, scale }), + (1_u32..=64).prop_map(|length| ComplexColumnType::Char { length }), + arb_safe_ident().prop_map(|custom_type| ComplexColumnType::Custom { custom_type }), + (arb_safe_ident(), arb_enum_values()) + .prop_map(|(name, values)| { ComplexColumnType::Enum { name, values } }), + ] +} + +pub fn arb_column_type() -> impl Strategy { + prop_oneof![ + arb_simple_column_type().prop_map(ColumnType::Simple), + arb_complex_column_type().prop_map(ColumnType::Complex), + ] +} + +pub fn arb_reference_action() -> impl Strategy { + prop_oneof![ + Just(ReferenceAction::Cascade), + Just(ReferenceAction::Restrict), + Just(ReferenceAction::SetNull), + Just(ReferenceAction::SetDefault), + Just(ReferenceAction::NoAction), + ] +} + +pub fn arb_default_value() -> impl Strategy { + prop_oneof![ + any::().prop_map(DefaultValue::Bool), + (-10_000_i64..=10_000).prop_map(DefaultValue::Integer), + (-10_000_i32..=10_000).prop_map(|n| DefaultValue::Float(f64::from(n) / 10.0)), + arb_default_string().prop_map(DefaultValue::String), + ] +} + +pub fn arb_str_or_bool() -> impl Strategy { + arb_default_value() +} + +pub fn arb_str_or_bool_or_array() -> impl Strategy { + prop_oneof![ + arb_safe_ident().prop_map(StrOrBoolOrArray::Str), + unique_idents(1..=4).prop_map(StrOrBoolOrArray::Array), + any::().prop_map(StrOrBoolOrArray::Bool), + ] +} + +pub fn arb_column_def() -> impl Strategy { + ( + arb_safe_ident(), + arb_column_type(), + any::(), + prop::option::of(arb_str_or_bool()), + prop::option::of(arb_comment()), + prop::option::of(arb_primary_key_syntax()), + prop::option::of(arb_str_or_bool_or_array()), + prop::option::of(arb_str_or_bool_or_array()), + prop::option::of(arb_foreign_key_syntax()), + ) + .prop_map( + |(name, ty, nullable, default, comment, primary_key, unique, index, foreign_key)| { + let mut column = ColumnDef::new(name, ty, nullable); + if let Some(default) = default { + column = column.default(default); + } + if let Some(comment) = comment { + column = column.comment(comment); + } + if let Some(primary_key) = primary_key { + column = column.primary_key(primary_key); + } + if let Some(unique) = unique { + column = column.unique(unique); + } + if let Some(index) = index { + column = column.index(index); + } + if let Some(foreign_key) = foreign_key { + column = column.foreign_key(foreign_key); + } + column + }, + ) +} + +pub fn arb_table_def() -> impl Strategy { + ( + arb_safe_ident(), + prop::option::of(arb_comment()), + collection::vec(arb_column_def(), 0..=8).prop_filter("unique column names", |columns| { + names_are_unique(columns.iter().map(|column| column.name.as_str())) + }), + collection::vec(arb_table_constraint(), 0..=4), + ) + .prop_map(|(name, description, columns, constraints)| TableDef { + name, + description, + columns, + constraints, + }) +} + +pub fn arb_table_constraint() -> impl Strategy { + prop_oneof![ + (any::(), unique_idents(1..=4)).prop_map(|(auto_increment, columns)| { + TableConstraint::PrimaryKey { + auto_increment, + columns, + } + }), + (prop::option::of(arb_safe_ident()), unique_idents(1..=4)) + .prop_map(|(name, columns)| TableConstraint::Unique { name, columns },), + ( + prop::option::of(arb_safe_ident()), + unique_idents(1..=4), + arb_safe_ident(), + unique_idents(1..=4), + prop::option::of(arb_reference_action()), + prop::option::of(arb_reference_action()), + ) + .prop_map( + |(name, columns, ref_table, ref_columns, on_delete, on_update)| { + TableConstraint::ForeignKey { + name, + columns, + ref_table, + ref_columns, + on_delete, + on_update, + } + }, + ), + (arb_safe_ident(), arb_check_expr()) + .prop_map(|(name, expr)| { TableConstraint::Check { name, expr } }), + (prop::option::of(arb_safe_ident()), unique_idents(1..=4)) + .prop_map(|(name, columns)| TableConstraint::Index { name, columns }), + ] +} + +pub fn arb_migration_action() -> impl Strategy { + prop_oneof![ + arb_create_table_action(), + arb_safe_ident().prop_map(|table| MigrationAction::DeleteTable { table }), + arb_add_column_action(), + (arb_safe_ident(), arb_safe_ident(), arb_safe_ident()) + .prop_map(|(table, from, to)| { MigrationAction::RenameColumn { table, from, to } }), + (arb_safe_ident(), arb_safe_ident()) + .prop_map(|(table, column)| { MigrationAction::DeleteColumn { table, column } }), + arb_modify_column_type_action(), + arb_modify_column_nullable_action(), + ( + arb_safe_ident(), + arb_safe_ident(), + prop::option::of(arb_default_string()) + ) + .prop_map(|(table, column, new_default)| { + MigrationAction::ModifyColumnDefault { + table, + column, + new_default, + } + }), + ( + arb_safe_ident(), + arb_safe_ident(), + prop::option::of(arb_comment()) + ) + .prop_map(|(table, column, new_comment)| { + MigrationAction::ModifyColumnComment { + table, + column, + new_comment, + } + }), + (arb_safe_ident(), arb_table_constraint()).prop_map(|(table, constraint)| { + MigrationAction::AddConstraint { table, constraint } + }), + (arb_safe_ident(), arb_table_constraint()).prop_map(|(table, constraint)| { + MigrationAction::RemoveConstraint { table, constraint } + }), + ( + arb_safe_ident(), + arb_table_constraint(), + arb_table_constraint() + ) + .prop_map(|(table, from, to)| MigrationAction::ReplaceConstraint { + table, + from, + to + },), + (arb_safe_ident(), arb_safe_ident()) + .prop_map(|(from, to)| { MigrationAction::RenameTable { from, to } }), + arb_sql().prop_map(|sql| MigrationAction::RawSql { sql }), + ] +} + +fn arb_create_table_action() -> impl Strategy { + ( + arb_safe_ident(), + collection::vec(arb_column_def(), 0..=8), + collection::vec(arb_table_constraint(), 0..=4), + ) + .prop_map( + |(table, columns, constraints)| MigrationAction::CreateTable { + table, + columns, + constraints, + }, + ) +} + +fn arb_add_column_action() -> impl Strategy { + ( + arb_safe_ident(), + arb_column_def(), + prop::option::of(arb_default_string()), + ) + .prop_map(|(table, column, fill_with)| MigrationAction::AddColumn { + table, + column: Box::new(column), + fill_with, + }) +} + +fn arb_modify_column_type_action() -> impl Strategy { + ( + arb_safe_ident(), + arb_safe_ident(), + arb_column_type(), + prop::option::of(collection::btree_map( + arb_safe_ident(), + arb_default_string(), + 0..=4, + )), + ) + .prop_map( + |(table, column, new_type, fill_with)| MigrationAction::ModifyColumnType { + table, + column, + new_type, + fill_with, + }, + ) +} + +fn arb_modify_column_nullable_action() -> impl Strategy { + ( + arb_safe_ident(), + arb_safe_ident(), + any::(), + prop::option::of(arb_default_string()), + prop::option::of(any::()), + ) + .prop_map(|(table, column, nullable, fill_with, delete_null_rows)| { + MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + fill_with, + delete_null_rows, + } + }) +} + +fn arb_enum_values() -> impl Strategy { + prop_oneof![ + unique_idents(1..=6).prop_map(EnumValues::String), + collection::vec((arb_safe_ident(), -1_000_i32..=1_000), 1..=6) + .prop_filter("unique enum variant names", |values| { + names_are_unique(values.iter().map(|(name, _)| name.as_str())) + }) + .prop_map(|values| { + EnumValues::Integer( + values + .into_iter() + .map(|(name, value)| NumValue { + name, + value: i64::from(value), + }) + .collect(), + ) + }), + ] +} + +fn arb_primary_key_syntax() -> impl Strategy { + prop_oneof![ + any::().prop_map(PrimaryKeySyntax::Bool), + any::() + .prop_map(|auto_increment| PrimaryKeySyntax::Object(PrimaryKeyDef { auto_increment })), + ] +} + +fn arb_foreign_key_syntax() -> impl Strategy { + prop_oneof![ + (arb_safe_ident(), arb_safe_ident()) + .prop_map(|(table, column)| ForeignKeySyntax::String(format!("{table}.{column}"))), + ( + arb_safe_ident(), + arb_safe_ident(), + prop::option::of(arb_reference_action()), + prop::option::of(arb_reference_action()) + ) + .prop_map(|(table, column, on_delete, on_update)| { + ForeignKeySyntax::Reference(ReferenceSyntaxDef { + references: format!("{table}.{column}"), + on_delete, + on_update, + }) + }), + ( + arb_safe_ident(), + unique_idents(1..=4), + prop::option::of(arb_reference_action()), + prop::option::of(arb_reference_action()) + ) + .prop_map(|(ref_table, ref_columns, on_delete, on_update)| { + ForeignKeySyntax::Object(ForeignKeyDef { + ref_table, + ref_columns, + on_delete, + on_update, + }) + }), + ] +} + +fn unique_idents(size: impl Into) -> impl Strategy> { + collection::vec(arb_safe_ident(), size).prop_filter("unique identifiers", |values| { + names_are_unique(values.iter().map(String::as_str)) + }) +} + +fn names_are_unique<'a>(names: impl Iterator) -> bool { + let mut seen = BTreeSet::new(); + names.into_iter().all(|name| seen.insert(name)) +} + +fn arb_default_string() -> impl Strategy { + prop_oneof![ + arb_safe_ident(), + arb_safe_ident().prop_map(|ident| format!("'{ident}'")), + Just("NOW()".to_string()), + Just("CURRENT_TIMESTAMP".to_string()), + ] +} + +fn arb_comment() -> impl Strategy { + collection::vec(prop::char::range('a', 'z'), 0..=80) + .prop_map(|chars| chars.into_iter().collect()) +} + +fn arb_check_expr() -> impl Strategy { + ( + arb_safe_ident(), + prop_oneof![Just(">"), Just(">="), Just("<"), Just("<=")], + 0_i32..=100, + ) + .prop_map(|(column, op, value)| format!("{column} {op} {value}")) +} + +fn arb_sql() -> impl Strategy { + arb_safe_ident().prop_map(|name| format!("SELECT 1 AS {name}")) +} diff --git a/crates/vespertide-core/src/lib.rs b/crates/vespertide-core/src/lib.rs index c433aba2..1fa203a4 100644 --- a/crates/vespertide-core/src/lib.rs +++ b/crates/vespertide-core/src/lib.rs @@ -1,11 +1,19 @@ +//! Core data structures for vespertide schema definition and migration planning. +//! +//! - [`TableDef`], [`ColumnDef`]: schema model +//! - [`MigrationAction`], [`MigrationPlan`]: typed migration operations +//! - [`MigrationError`]: runtime migration error type + pub mod action; +#[cfg(feature = "arbitrary")] +pub mod arbitrary; pub mod migration; pub mod schema; pub use action::{MigrationAction, MigrationPlan}; pub use migration::{MigrationError, MigrationOptions}; pub use schema::{ - ColumnDef, ColumnName, ColumnType, ComplexColumnType, DefaultValue, EnumValues, IndexDef, - IndexName, NumValue, ReferenceAction, SimpleColumnType, StrOrBoolOrArray, StringOrBool, - TableConstraint, TableDef, TableName, TableValidationError, + ColumnDef, ColumnName, ColumnType, ComplexColumnType, ConstraintKind, DefaultValue, EnumValues, + IndexDef, IndexName, NumValue, ReferenceAction, SimpleColumnType, StrOrBoolOrArray, + StringOrBool, TableConstraint, TableDef, TableName, TableValidationError, }; diff --git a/crates/vespertide-core/src/migration.rs b/crates/vespertide-core/src/migration.rs index ad551f29..a2153cbc 100644 --- a/crates/vespertide-core/src/migration.rs +++ b/crates/vespertide-core/src/migration.rs @@ -1,5 +1,13 @@ +/// Runtime options controlling how Vespertide tracks applied migrations. +/// +/// Pass this to the migration runner to configure the version-tracking table name. +/// The default table name used by the `vespertide_migration!` macro is `"vespertide_migrations"`. #[derive(Debug, Clone)] pub struct MigrationOptions { + /// Name of the table used to record which migration versions have been applied. + /// + /// Defaults to `"vespertide_migrations"`. Override this when multiple Vespertide-managed + /// schemas share the same database and need separate version tables. pub version_table: String, } @@ -8,7 +16,17 @@ pub enum MigrationError { #[error("migration execution is not yet implemented")] NotImplemented, #[error("database error: {0}")] + #[deprecated( + since = "0.1.62", + note = "Use Database { message, source } for proper error source chains" + )] DatabaseError(String), + #[error("database error: {message}")] + Database { + message: String, + #[source] + source: Option>, + }, #[error( "migration id mismatch for version {version}: expected '{expected}', found '{found}' in database" )] @@ -18,3 +36,12 @@ pub enum MigrationError { found: String, }, } + +impl From for MigrationError { + fn from(err: sea_orm::DbErr) -> Self { + Self::Database { + message: err.to_string(), + source: Some(Box::new(err)), + } + } +} diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index ee838724..1d3a62e8 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -7,6 +7,18 @@ use crate::schema::{ str_or_bool::{StrOrBoolOrArray, StringOrBool}, }; +/// Definition of a single table column, including its type, nullability, and inline constraints. +/// +/// Inline constraints (`primary_key`, `unique`, `index`, `foreign_key`) are the preferred way to +/// declare constraints in model JSON files. Call [`TableDef::normalize`] to convert them into +/// table-level [`TableConstraint`] entries before diffing or SQL generation. +/// +/// Use [`ColumnDef::new`] to construct a column programmatically, then chain the setter methods +/// (`.primary_key()`, `.unique()`, `.index()`, `.foreign_key()`, `.default()`, `.comment()`) to +/// attach optional fields. +/// +/// [`TableDef::normalize`]: crate::schema::TableDef::normalize +/// [`TableConstraint`]: crate::schema::TableConstraint #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] @@ -28,16 +40,30 @@ pub struct ColumnDef { pub foreign_key: Option, } +/// The SQL type of a column, either a parameter-free simple type or a parameterised complex type. +/// +/// In JSON model files a simple type is written as a plain string (`"integer"`, `"text"`, etc.) +/// while a complex type is written as an object with a `"kind"` discriminant +/// (`{"kind": "varchar", "length": 255}`). +/// +/// Always construct via the wrapped variants: +/// ``` +/// use vespertide_core::{ColumnType, SimpleColumnType, ComplexColumnType}; +/// let t1 = ColumnType::Simple(SimpleColumnType::Integer); +/// let t2 = ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }); +/// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", untagged)] pub enum ColumnType { + /// A parameter-free SQL type such as `INTEGER`, `TEXT`, or `UUID`. Simple(SimpleColumnType), + /// A parameterised SQL type such as `VARCHAR(n)`, `NUMERIC(p,s)`, or a named enum. Complex(ComplexColumnType), } impl ColumnType { - /// Returns true if this type supports auto_increment (integer types only) + /// Returns true if this type supports `auto_increment` (integer types only) pub fn supports_auto_increment(&self) -> bool { match self { ColumnType::Simple(ty) => ty.supports_auto_increment(), @@ -47,7 +73,7 @@ impl ColumnType { /// Check if two column types require a migration. /// For integer enums, no migration is ever needed because the underlying DB type is always INTEGER. - /// The enum name and values only affect code generation (SeaORM entities), not the database schema. + /// The enum name and values only affect code generation (`SeaORM` entities), not the database schema. pub fn requires_migration(&self, other: &ColumnType) -> bool { match (self, other) { ( @@ -73,7 +99,7 @@ impl ColumnType { } } - /// Convert column type to Rust type string (for SeaORM entity generation) + /// Convert column type to Rust type string (for `SeaORM` entity generation) pub fn to_rust_type(&self, nullable: bool) -> String { let base = match self { ColumnType::Simple(ty) => match ty { @@ -82,32 +108,32 @@ impl ColumnType { SimpleColumnType::BigInt => "i64".to_string(), SimpleColumnType::Real => "f32".to_string(), SimpleColumnType::DoublePrecision => "f64".to_string(), - SimpleColumnType::Text => "String".to_string(), + SimpleColumnType::Text + | SimpleColumnType::Interval + | SimpleColumnType::Inet + | SimpleColumnType::Cidr + | SimpleColumnType::Macaddr + | SimpleColumnType::Xml => "String".to_string(), SimpleColumnType::Boolean => "bool".to_string(), SimpleColumnType::Date => "Date".to_string(), SimpleColumnType::Time => "Time".to_string(), SimpleColumnType::Timestamp => "DateTime".to_string(), SimpleColumnType::Timestamptz => "DateTimeWithTimeZone".to_string(), - SimpleColumnType::Interval => "String".to_string(), SimpleColumnType::Bytea => "Vec".to_string(), SimpleColumnType::Uuid => "Uuid".to_string(), SimpleColumnType::Json => "Json".to_string(), - // SimpleColumnType::Jsonb => "Json".to_string(), - SimpleColumnType::Inet | SimpleColumnType::Cidr => "String".to_string(), - SimpleColumnType::Macaddr => "String".to_string(), - SimpleColumnType::Xml => "String".to_string(), }, ColumnType::Complex(ty) => match ty { - ComplexColumnType::Varchar { .. } => "String".to_string(), ComplexColumnType::Numeric { .. } => "Decimal".to_string(), - ComplexColumnType::Char { .. } => "String".to_string(), - ComplexColumnType::Custom { .. } => "String".to_string(), // Default for custom types - ComplexColumnType::Enum { .. } => "String".to_string(), + ComplexColumnType::Varchar { .. } + | ComplexColumnType::Char { .. } + | ComplexColumnType::Custom { .. } + | ComplexColumnType::Enum { .. } => "String".to_string(), }, }; if nullable { - format!("Option<{}>", base) + format!("Option<{base}>") } else { base } @@ -147,50 +173,170 @@ impl ColumnType { } } +impl ColumnDef { + /// Construct a new column with required fields only. + /// Use the `.primary_key()`, `.unique()`, `.index()`, `.foreign_key()`, + /// `.default()`, `.comment()` setters to add optional fields. + /// + /// # Examples + /// ``` + /// use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + /// let id = ColumnDef::new("id", ColumnType::Simple(SimpleColumnType::Integer), false); + /// ``` + #[must_use] + pub fn new(name: impl Into, r#type: ColumnType, nullable: bool) -> Self { + Self { + name: name.into(), + r#type, + nullable, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + } + } + + /// Mark this column as part of the primary key. + #[must_use] + pub fn primary_key(mut self, pk: PrimaryKeySyntax) -> Self { + self.primary_key = Some(pk); + self + } + + /// Add a unique constraint to this column. + #[must_use] + pub fn unique(mut self, unique: StrOrBoolOrArray) -> Self { + self.unique = Some(unique); + self + } + + /// Add an index on this column. + #[must_use] + pub fn index(mut self, index: StrOrBoolOrArray) -> Self { + self.index = Some(index); + self + } + + /// Add a foreign key reference from this column. + #[must_use] + pub fn foreign_key(mut self, fk: ForeignKeySyntax) -> Self { + self.foreign_key = Some(fk); + self + } + + /// Set the column default value. + #[must_use] + pub fn default(mut self, default: StringOrBool) -> Self { + self.default = Some(default); + self + } + + /// Add a column comment. + #[must_use] + pub fn comment(mut self, comment: impl Into) -> Self { + self.comment = Some(comment.into()); + self + } +} + +/// Parameter-free SQL column types supported across all backends. +/// +/// Each variant maps directly to a standard SQL type. Use these via +/// [`ColumnType::Simple`] when no length, precision, or scale is needed. +/// +/// This enum is `#[non_exhaustive]`: new variants may be added in future releases. +/// Downstream `match` expressions should include a wildcard arm. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum SimpleColumnType { + /// 16-bit signed integer (`SMALLINT`). SmallInt, + /// 32-bit signed integer (`INTEGER`). Supports `auto_increment`. Integer, + /// 64-bit signed integer (`BIGINT`). Supports `auto_increment`. BigInt, + /// 32-bit floating-point number (`REAL`). Real, + /// 64-bit floating-point number (`DOUBLE PRECISION`). DoublePrecision, // Text types + /// Unbounded Unicode text (`TEXT`). Text, // Boolean type + /// Boolean true/false value (`BOOLEAN`). Boolean, // Date/Time types + /// Calendar date without time (`DATE`). Date, + /// Time of day without date (`TIME`). Time, + /// Date and time without timezone (`TIMESTAMP`). Timestamp, + /// Date and time with timezone (`TIMESTAMPTZ`). Prefer this over `Timestamp`. Timestamptz, + /// Time span / duration (`INTERVAL`). Interval, // Binary type + /// Variable-length binary data (`BYTEA`). Bytea, // UUID type + /// Universally unique identifier (`UUID`). Uuid, // JSON types + /// JSON value stored as text (`JSON`). Cross-backend compatible; prefer over `jsonb`. Json, - // Jsonb, // Network types + /// IPv4 or IPv6 host address (`INET`). PostgreSQL-specific. Inet, + /// IPv4 or IPv6 network address (`CIDR`). PostgreSQL-specific. Cidr, + /// MAC address (`MACADDR`). PostgreSQL-specific. Macaddr, // XML type + /// XML document (`XML`). PostgreSQL-specific. Xml, } impl SimpleColumnType { - /// Returns true if this type supports auto_increment (integer types only) + /// Returns the SQL type name for this simple column type. + #[must_use] + pub fn sql_type(&self) -> &'static str { + match self { + SimpleColumnType::SmallInt => "SMALLINT", + SimpleColumnType::Integer => "INTEGER", + SimpleColumnType::BigInt => "BIGINT", + SimpleColumnType::Real => "REAL", + SimpleColumnType::DoublePrecision => "DOUBLE PRECISION", + SimpleColumnType::Text => "TEXT", + SimpleColumnType::Boolean => "BOOLEAN", + SimpleColumnType::Date => "DATE", + SimpleColumnType::Time => "TIME", + SimpleColumnType::Timestamp => "TIMESTAMP", + SimpleColumnType::Timestamptz => "TIMESTAMPTZ", + SimpleColumnType::Interval => "INTERVAL", + SimpleColumnType::Bytea => "BYTEA", + SimpleColumnType::Uuid => "UUID", + SimpleColumnType::Json => "JSON", + SimpleColumnType::Inet => "INET", + SimpleColumnType::Cidr => "CIDR", + SimpleColumnType::Macaddr => "MACADDR", + SimpleColumnType::Xml => "XML", + } + } + + /// Returns true if this type supports `auto_increment` (integer types only) pub fn supports_auto_increment(&self) -> bool { matches!( self, @@ -232,14 +378,13 @@ impl SimpleColumnType { } SimpleColumnType::Real | SimpleColumnType::DoublePrecision => "0.0", SimpleColumnType::Boolean => "false", - SimpleColumnType::Text => "''", + SimpleColumnType::Text | SimpleColumnType::Bytea => "''", SimpleColumnType::Date => "'1970-01-01'", SimpleColumnType::Time => "'00:00:00'", SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz => "CURRENT_TIMESTAMP", SimpleColumnType::Interval => "'0'", SimpleColumnType::Uuid => "'00000000-0000-0000-0000-000000000000'", SimpleColumnType::Json => "'{}'", - SimpleColumnType::Bytea => "''", SimpleColumnType::Inet | SimpleColumnType::Cidr => "'0.0.0.0'", SimpleColumnType::Macaddr => "'00:00:00:00:00:00'", SimpleColumnType::Xml => "''", @@ -247,20 +392,37 @@ impl SimpleColumnType { } } -/// Integer enum variant with name and numeric value +/// A single variant of an integer-backed enum, pairing a Rust-friendly name with its stored value. +/// +/// Used inside [`EnumValues::Integer`] to define enums that are stored as `INTEGER` in the +/// database. Leave gaps between values (e.g. 0, 10, 20) so new variants can be inserted later +/// without renumbering. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct NumValue { + /// The variant name used in generated code (e.g. `"active"`). pub name: String, - pub value: i32, + /// The integer value stored in the database column. + pub value: i64, } -/// Enum values definition - either all string or all integer +/// The set of allowed values for an enum column, either string-based or integer-based. +/// +/// **String enums** map to a native `PostgreSQL` `ENUM` type. Adding or removing values requires a +/// database migration (`ALTER TYPE`). +/// +/// **Integer enums** are stored as `INTEGER`. New variants can be added to the model without any +/// database migration because the underlying column type never changes. +/// +/// Choose integer enums for expandable value sets (roles, priorities) and string enums for +/// stable, human-readable status fields. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(untagged)] pub enum EnumValues { + /// String enum: each variant is a plain string stored in a native DB enum type. String(Vec), + /// Integer enum: each variant has an explicit numeric value stored as `INTEGER`. Integer(Vec), } @@ -278,7 +440,7 @@ impl EnumValues { /// Get all variant names pub fn variant_names(&self) -> Vec<&str> { match self { - EnumValues::String(values) => values.iter().map(|s| s.as_str()).collect(), + EnumValues::String(values) => values.iter().map(std::string::String::as_str).collect(), EnumValues::Integer(values) => values.iter().map(|v| v.name.as_str()).collect(), } } @@ -293,7 +455,10 @@ impl EnumValues { /// Check if there are no variants pub fn is_empty(&self) -> bool { - self.len() == 0 + match self { + EnumValues::String(values) => values.is_empty(), + EnumValues::Integer(values) => values.is_empty(), + } } /// Get SQL values for CREATE TYPE ENUM (only for string enums) @@ -317,36 +482,70 @@ impl From> for EnumValues { impl From> for EnumValues { fn from(values: Vec<&str>) -> Self { - EnumValues::String(values.into_iter().map(|s| s.to_string()).collect()) + EnumValues::String( + values + .into_iter() + .map(std::string::ToString::to_string) + .collect(), + ) } } +/// Parameterised SQL column types that require additional configuration beyond a simple keyword. +/// +/// In JSON model files these are written as objects with a `"kind"` discriminant, for example +/// `{"kind": "varchar", "length": 255}` or `{"kind": "enum", "name": "status", "values": [...]}`. +/// +/// Use these via [`ColumnType::Complex`]. +/// +/// This enum is `#[non_exhaustive]`: new variants may be added in future releases. +/// Downstream `match` expressions should include a wildcard arm. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", tag = "kind")] +#[non_exhaustive] pub enum ComplexColumnType { + /// Variable-length character string with a maximum byte length (`VARCHAR(n)`). Varchar { length: u32 }, + /// Exact fixed-point number with configurable precision and scale (`NUMERIC(p, s)`). Numeric { precision: u32, scale: u32 }, + /// Fixed-length character string padded with spaces (`CHAR(n)`). Char { length: u32 }, + /// Escape hatch for database-specific types not covered by other variants. + /// Breaks cross-database portability; avoid unless absolutely necessary. Custom { custom_type: String }, + /// Named enum type. String enums map to a native DB enum; integer enums store as `INTEGER`. + /// See [`EnumValues`] for the distinction. Enum { name: String, values: EnumValues }, } impl ComplexColumnType { + /// Returns the base SQL type name for this complex column type, without parameters. + #[must_use] + pub fn sql_type(&self) -> &'static str { + match self { + ComplexColumnType::Varchar { .. } => "VARCHAR", + ComplexColumnType::Numeric { .. } => "NUMERIC", + ComplexColumnType::Char { .. } => "CHAR", + ComplexColumnType::Custom { .. } => "CUSTOM", + ComplexColumnType::Enum { .. } => "ENUM", + } + } + /// Convert to human-readable display string pub fn to_display_string(&self) -> String { match self { - ComplexColumnType::Varchar { length } => format!("varchar({})", length), + ComplexColumnType::Varchar { length } => format!("varchar({length})"), ComplexColumnType::Numeric { precision, scale } => { - format!("numeric({},{})", precision, scale) + format!("numeric({precision},{scale})") } - ComplexColumnType::Char { length } => format!("char({})", length), + ComplexColumnType::Char { length } => format!("char({length})"), ComplexColumnType::Custom { custom_type } => custom_type.to_lowercase(), ComplexColumnType::Enum { name, values } => { if values.is_integer() { - format!("enum<{}> (integer)", name) + format!("enum<{name}> (integer)") } else { - format!("enum<{}>", name) + format!("enum<{name}>") } } } @@ -355,624 +554,15 @@ impl ComplexColumnType { /// Get the default fill value for this type. pub fn default_fill_value(&self) -> &'static str { match self { - ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => "''", ComplexColumnType::Numeric { .. } => "0", - ComplexColumnType::Custom { .. } => "''", - ComplexColumnType::Enum { .. } => "''", + ComplexColumnType::Varchar { .. } + | ComplexColumnType::Char { .. } + | ComplexColumnType::Custom { .. } + | ComplexColumnType::Enum { .. } => "''", } } } #[cfg(test)] -mod tests { - use super::*; - use rstest::rstest; - - #[rstest] - #[case(SimpleColumnType::SmallInt, "i16")] - #[case(SimpleColumnType::Integer, "i32")] - #[case(SimpleColumnType::BigInt, "i64")] - #[case(SimpleColumnType::Real, "f32")] - #[case(SimpleColumnType::DoublePrecision, "f64")] - #[case(SimpleColumnType::Text, "String")] - #[case(SimpleColumnType::Boolean, "bool")] - #[case(SimpleColumnType::Date, "Date")] - #[case(SimpleColumnType::Time, "Time")] - #[case(SimpleColumnType::Timestamp, "DateTime")] - #[case(SimpleColumnType::Timestamptz, "DateTimeWithTimeZone")] - #[case(SimpleColumnType::Interval, "String")] - #[case(SimpleColumnType::Bytea, "Vec")] - #[case(SimpleColumnType::Uuid, "Uuid")] - #[case(SimpleColumnType::Json, "Json")] - // #[case(SimpleColumnType::Jsonb, "Json")] - #[case(SimpleColumnType::Inet, "String")] - #[case(SimpleColumnType::Cidr, "String")] - #[case(SimpleColumnType::Macaddr, "String")] - #[case(SimpleColumnType::Xml, "String")] - fn test_simple_column_type_to_rust_type_not_nullable( - #[case] column_type: SimpleColumnType, - #[case] expected: &str, - ) { - assert_eq!( - ColumnType::Simple(column_type).to_rust_type(false), - expected - ); - } - - #[rstest] - #[case(SimpleColumnType::SmallInt, "Option")] - #[case(SimpleColumnType::Integer, "Option")] - #[case(SimpleColumnType::BigInt, "Option")] - #[case(SimpleColumnType::Real, "Option")] - #[case(SimpleColumnType::DoublePrecision, "Option")] - #[case(SimpleColumnType::Text, "Option")] - #[case(SimpleColumnType::Boolean, "Option")] - #[case(SimpleColumnType::Date, "Option")] - #[case(SimpleColumnType::Time, "Option